import { bindMaterialDrag } from './material_drag.js';
import { selectedMaterialNode } from './node_material_actions.js';
import { applyMaterialToSelectedNode, applyMaterialToNode } from './ui_material_application.js';
/** Curated image/workflow and Recipe parameter materials. */
import { app } from '../../../scripts/app.js';
import { translate, resolveLocale } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { showImageWorkbench } from './ui_gallery_detail.js';
import { text, jsonResponse } from './ui_dom.js';
import { escapeHtml } from './safe_dom.js';
import { sectionLabel, fileBaseName, renderDetailedNodeCards, applyPromptRolesToBlocks, renderMaterialPromptGroups, materialNodeHeading } from './material_inspector.js';
const t = (key, params) => translate(key, params);
const isPromptMaterial = material => ['prompt_note_bundle', 'prompt_text'].includes(material.kind);
const promptKindLabel = material => t(material.kind === 'prompt_text' ? 'materialPromptTextKind' : 'materialPromptNoteBundle');
function getMaterialPlaceholderSvg(material, size = 36) {
if (isPromptMaterial(material) || material?.kind === 'prompt_plan') {
return ``;
}
if (material?.kind === 'recipe_parameter_selection') {
return ``;
}
return ``;
}
function materialAssetUrl(filename, asset) {
if (!filename || !asset) return '';
return `/anomalous/material_asset?filename=${encodeURIComponent(filename)}&asset=${encodeURIComponent(asset)}`;
}
function hideSiblingWorkspaceViews(owner) {
if (owner.notebookContainer) owner.notebookContainer.style.display = 'none';
if (owner.notebookBody) owner.notebookBody.style.display = 'none';
if (owner.recipeView) owner.recipeView.style.display = 'none';
if (owner.recipeContainer) owner.recipeContainer.style.display = 'none';
owner.notebookNotesTab?.classList.remove('active');
owner.notebookRecipesTab?.classList.remove('active');
}
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;
}
function renderSourceRecipeMark(parent, info, className = '') {
if (!info?.filename) return null;
const status = info.status === 'missing' || info.status === 'modified' ? info.status : 'current';
const name = info.name || info.filename;
const key = status === 'missing'
? 'materialSourceRecipeMissing'
: status === 'modified'
? 'materialSourceRecipeModified'
: 'materialSourceRecipeCurrent';
return text(parent, 'span', t(key, { name }), `anomalous-material-source-mark is-${status}${className ? ` ${className}` : ''}`);
}
async function updateMaterialPromptRole(owner, material, payload, block, selectedRole) {
const overrides = { ...(payload.data?.promptRoleOverrides || {}) };
const key = String(block.node_id);
if (selectedRole === 'auto') delete overrides[key];
else overrides[key] = { role: selectedRole, nodeType: block.type || null };
const response = await fetch('/anomalous/update_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: material.filename,
name: payload.data?.name || material.name,
tags: payload.data?.tags || material.tags || [],
promptRoleOverrides: overrides,
}),
});
const result = await jsonResponse(response, 'material prompt role update failed');
if (result.status !== 'success') throw new Error(result.message || 'material prompt role update failed');
Object.assign(material, result.material);
await showMaterialDetail(owner, material);
}
function renderMaterialInspector(content, payload, owner, material) {
if (material?.kind === 'prompt_plan') {
const plan = payload.data?.plan || {};
if (plan.positive) {
sectionLabel(content, window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive Prompt');
text(content, 'pre', plan.positive, 'anomalous-material-note-text');
const copy = text(content, 'button', t('materialCopyPrompt'), 'anomalous-btn-ghost');
copy.type = 'button';
copy.onclick = async () => {
try { await navigator.clipboard.writeText(plan.positive); copy.textContent = t('materialCopied'); }
catch (error) { await anomalousAlert(t('materialCopyError')); }
};
}
if (plan.negative) {
sectionLabel(content, window.anomalous_browser_lang === 'zh' ? '负向提示词' : 'Negative Prompt');
text(content, 'pre', plan.negative, 'anomalous-material-note-text');
const copyNeg = text(content, 'button', t('materialCopyPrompt'), 'anomalous-btn-ghost');
copyNeg.type = 'button';
copyNeg.onclick = async () => {
try { await navigator.clipboard.writeText(plan.negative); copyNeg.textContent = t('materialCopied'); }
catch (error) { await anomalousAlert(t('materialCopyError')); }
};
}
return;
}
if (isPromptMaterial(material)) {
const note = payload.data?.note || {};
sectionLabel(content, t('notebookPromptTitle'));
const prompt = text(content, 'pre', note.promptEn || '', 'anomalous-material-note-text');
const copy = text(content, 'button', t('materialCopyPrompt'), 'anomalous-btn-ghost');
copy.type = 'button';
copy.disabled = !note.promptEn;
copy.onclick = async () => {
try { await navigator.clipboard.writeText(prompt.textContent); copy.textContent = t('materialCopied'); }
catch (error) { await anomalousAlert(t('materialCopyError')); }
};
const models = [note.mainModel, ...(note.loras || [])].filter(Boolean);
if (models.length) {
sectionLabel(content, t('notebookCompanionModels'));
models.forEach(model => text(content, 'p', model.filename || model.name || t('materialUntitled')));
}
text(content, 'p', t('materialRestoreNoteHint'), 'anomalous-material-muted');
const restore = text(content, 'button', t('materialRestoreNote'), 'anomalous-btn-primary');
restore.type = 'button';
restore.onclick = async () => {
restore.disabled = true;
try {
clearTimeout(owner.pTimeout);
if (owner.currentNotebook && !await owner.saveCurrentNotebook()) throw new Error('pending note save failed');
const data = JSON.parse(JSON.stringify(note));
const name = t('materialNoteCopyName', { name: material.name }).slice(0, 120);
const notebook = { filename: `nb_${crypto.randomUUID()}.json`,
name, data: { ...data, name } };
const response = await fetch('/anomalous/save_notebook', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(notebook),
});
const result = await jsonResponse(response, 'notebook restore failed');
if (result.status !== 'success') throw new Error('notebook restore failed');
leaveMaterialDetail(owner);
owner.currentNotebook = notebook;
await owner.showNotebooks();
owner.renderNotebookEditor();
} catch (error) { await anomalousAlert(t('notebookSaveError')); }
finally { restore.disabled = false; }
};
return;
}
const references = Array.isArray(payload.data?.model_references) ? payload.data.model_references : [];
const blocks = applyPromptRolesToBlocks(
Array.isArray(payload.node_blocks) ? payload.node_blocks : [],
payload.prompt_roles,
);
const promptRoles = payload.prompt_roles || {};
const hasManualRole = Object.values(promptRoles).some(info => info?.source === 'manual');
renderSourceRecipeMark(content, payload.source_recipe || payload.data?.source_recipe, 'is-detail');
if (payload.data?.selection?.scope === 'nodes') {
text(content, 'p', t('materialUseFromNodeAssistant'), 'anomalous-material-muted');
}
renderMaterialPromptGroups(content, payload.prompt_groups, { manual: hasManualRole });
if (references.length) {
sectionLabel(content, t('materialModelReferences'));
const models = document.createElement('div');
models.className = 'anomalous-material-expanded-models';
for (const reference of references) {
const item = document.createElement('div');
item.className = 'anomalous-material-expanded-model';
const rawValue = reference.saved_value || reference.name || t('materialUntitled');
const name = text(item, 'span', fileBaseName(rawValue));
name.title = String(rawValue);
text(item, 'small', reference.category || 'model');
models.appendChild(item);
}
content.appendChild(models);
}
const blocksHeader = document.createElement('div');
blocksHeader.className = 'anomalous-material-blocks-header';
sectionLabel(blocksHeader, t('materialDetailedNodeParameters', { count: blocks.length }));
content.appendChild(blocksHeader);
if (blocks.length) {
const list = renderDetailedNodeCards(content, blocks, {
onPromptRoleChange: async (block, selectedRole) => {
try {
await updateMaterialPromptRole(owner, material, payload, block, selectedRole);
} catch (error) {
console.error('Could not update material prompt role:', error);
await anomalousAlert(t('recipePromptRoleSaveError'));
throw error;
}
},
});
if (blocks.length > 1) {
const cards = Array.from(list.children);
const toggleAllBtn = text(blocksHeader, 'button', '', 'anomalous-material-toggle-all-btn');
toggleAllBtn.type = 'button';
const updateToggle = () => {
toggleAllBtn.textContent = t(cards.every(card => card.open) ? 'materialCollapseAll' : 'materialExpandAllNodes');
};
toggleAllBtn.onclick = () => {
const open = cards.some(card => !card.open);
cards.forEach(card => { card.open = open; });
updateToggle();
};
list.addEventListener('toggle', updateToggle, true);
updateToggle();
}
} else {
text(content, 'p', t('materialNoNodeParameters'), 'anomalous-material-muted');
}
}
function leaveMaterialDetail(owner) {
owner.materialOpenedDetail = null;
owner.materialDetailController?.abort();
owner.materialDetailController = null;
owner.materialDetailView?.remove();
owner.materialDetailView = null;
if (owner.materialTopbar) owner.materialTopbar.style.display = 'flex';
if (owner.materialMainArea) owner.materialMainArea.style.display = 'flex';
if (owner.materialIntro) owner.materialIntro.style.display = 'flex';
if (owner.materialList) owner.materialList.style.display = '';
if (owner.materialToolbar) owner.materialToolbar.style.display = 'flex';
if (owner.materialPager) owner.materialPager.style.display = 'flex';
if (owner.materialContext) owner.materialContext.style.display = '';
}
async function deleteMaterial(owner, material) {
if (!await anomalousConfirm(t('materialDeleteConfirm'))) return false;
const response = await fetch('/anomalous/delete_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: material.filename }),
});
if (!response.ok) {
await anomalousAlert(t('materialDeleteError'));
return false;
}
leaveMaterialDetail(owner);
await owner.refreshMaterials?.();
return true;
}
async function openMaterialWorkflow(owner, filename) {
const payload = await fetchMaterial(filename, { includeWorkflow: true });
if (!payload.data?.workflow || typeof app.loadGraphData !== 'function') throw new Error('material workflow unavailable');
await app.loadGraphData(JSON.parse(JSON.stringify(payload.data.workflow)));
app.canvas?.setDirty?.(true, true);
owner.closeWorkspace?.();
owner.close?.();
window.setTimeout(() => window.anomalous_resolve_all_missing_nodes?.(true, false), 0);
}
function buildMaterialDetailHeader(owner, material) {
const header = document.createElement('header');
header.className = 'anomalous-library-detail-header';
const back = text(header, 'button', `← ${t('materialBackToLibrary')}`, 'anomalous-library-detail-back');
back.type = 'button';
back.onclick = () => leaveMaterialDetail(owner);
const heading = document.createElement('div');
heading.className = 'anomalous-library-detail-heading';
const title = text(heading, 'h2', material.name || t('materialUntitled'));
title.title = material.name || t('materialUntitled');
const scopeLabel = isPromptMaterial(material) ? promptKindLabel(material) : material.kind === 'recipe_parameter_selection'
? t('materialRecipeParameterMaterial')
: material.selection?.scope === 'nodes'
? t('materialSelectedNodeMaterial')
: t('materialFullWorkflowMaterial');
text(heading, 'span', scopeLabel, 'anomalous-material-scope-badge');
header.appendChild(heading);
const headerActions = document.createElement('div');
headerActions.className = 'anomalous-library-detail-actions';
const targetNode = owner.materialTarget || selectedMaterialNode(app);
if (targetNode) {
const applyBtn = text(headerActions, 'button', '', 'anomalous-btn-primary anomalous-material-header-apply-btn');
applyBtn.type = 'button';
const targetTitle = materialNodeHeading(targetNode);
const normalLabel = `⚡ ${window.anomalous_browser_lang === 'zh' ? `应用到节点 (${targetTitle})` : `Apply to Node (${targetTitle})`}`;
applyBtn.textContent = normalLabel;
applyBtn.title = window.anomalous_browser_lang === 'zh'
? `将本素材的参数或提示词应用替换到当前选中的节点 #${targetNode.id}`
: `Apply parameters or prompts of this material to selected node #${targetNode.id}`;
applyBtn.onclick = async () => {
applyBtn.disabled = true;
applyBtn.textContent = `⏳ ${t('assistantApplying') || '应用中...'}`;
try {
await applyLibraryMaterial(owner, material, targetNode);
applyBtn.textContent = `✅ ${t('assistantApplied') || '已应用'}`;
setTimeout(() => {
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.textContent = normalLabel;
}
}, 2000);
} catch (err) {
console.error('Error applying material to node:', err);
applyBtn.disabled = false;
applyBtn.textContent = normalLabel;
}
};
}
if ((material.capabilities || []).includes('open_workflow')) {
const open = text(headerActions, 'button', '', 'anomalous-btn-primary');
open.type = 'button';
open.innerHTML = `${t('materialOpenWorkflow')}`;
open.onclick = async () => {
open.disabled = true;
try {
await openMaterialWorkflow(owner, material.filename);
} catch (error) {
console.error('Could not open material workflow:', error);
await anomalousAlert(t('materialOpenError'));
open.disabled = false;
}
};
}
const edit = text(headerActions, 'button', t('materialEditDetails'), 'anomalous-btn-ghost');
edit.type = 'button';
edit.onclick = () => toggleMaterialEditor(owner, material, header);
const remove = text(headerActions, 'button', '', 'anomalous-btn-ghost');
remove.type = 'button';
remove.innerHTML = `${t('materialDelete')}`;
remove.onclick = () => deleteMaterial(owner, material);
header.appendChild(headerActions);
return header;
}
function buildMaterialMediaStage(owner, material, sourceNameElement) {
const media = document.createElement('aside');
media.className = 'anomalous-library-detail-media';
const previewUrl = materialAssetUrl(material.filename, material.image?.preview_asset_id || material.image?.source_asset_id);
const sourceUrl = materialAssetUrl(material.filename, material.image?.source_asset_id || material.image?.preview_asset_id);
const imageStage = document.createElement('button');
imageStage.className = 'anomalous-library-detail-image-stage';
imageStage.type = 'button';
imageStage.title = t('materialOpenSourceImage');
if (previewUrl) {
const image = document.createElement('img');
image.src = previewUrl;
image.alt = material.name || t('materialUntitled');
image.decoding = 'async';
imageStage.appendChild(image);
imageStage.onclick = () => owner.showGalleryViewer?.(sourceUrl);
} else {
imageStage.innerHTML = getMaterialPlaceholderSvg(material, 56);
imageStage.disabled = true;
}
media.appendChild(imageStage);
const sourceMeta = document.createElement('div');
sourceMeta.className = 'anomalous-library-detail-source';
const sourceCopy = document.createElement('div');
sourceCopy.className = 'anomalous-library-detail-source-copy';
text(sourceCopy, 'span', isPromptMaterial(material) ? t('materialPromptNoteBundle') : material.kind === 'recipe_parameter_selection'
? t('materialSourceParameters')
: t('materialSourceImage'));
sourceCopy.appendChild(sourceNameElement);
sourceMeta.appendChild(sourceCopy);
if (sourceUrl) {
const openSource = text(sourceMeta, 'button', t('materialOpenSourceImage'), 'anomalous-library-detail-source-open');
openSource.type = 'button';
openSource.onclick = () => owner.showGalleryViewer?.(sourceUrl);
sourceMeta.appendChild(openSource);
}
media.appendChild(sourceMeta);
return media;
}
async function showMaterialDetail(owner, material) {
owner.materialOpenedDetail = material;
owner.materialDetailController?.abort();
owner.materialDetailView?.remove();
owner.materialDetailView = null;
if (owner.materialTopbar) owner.materialTopbar.style.display = 'none';
if (owner.materialMainArea) owner.materialMainArea.style.display = 'none';
if (owner.materialIntro) owner.materialIntro.style.display = 'none';
if (owner.materialList) owner.materialList.style.display = 'none';
if (owner.materialToolbar) owner.materialToolbar.style.display = 'none';
if (owner.materialPager) owner.materialPager.style.display = 'none';
const detail = document.createElement('section');
detail.className = 'anomalous-library-detail-view';
owner.materialDetailView = detail;
const sourceNameElement = document.createElement('strong');
sourceNameElement.textContent = t('loading');
const header = buildMaterialDetailHeader(owner, material);
detail.appendChild(header);
const layout = document.createElement('div');
layout.className = 'anomalous-library-detail-layout';
if (material.kind === 'recipe_parameter_selection' || isPromptMaterial(material)) layout.classList.add('is-parameter');
const media = buildMaterialMediaStage(owner, material, sourceNameElement);
layout.appendChild(media);
const inspector = document.createElement('main');
inspector.className = 'anomalous-library-detail-inspector';
text(inspector, 'p', t('loading'), 'anomalous-material-muted');
layout.appendChild(inspector);
detail.appendChild(layout);
owner.materialView.appendChild(detail);
const controller = new AbortController();
owner.materialDetailController = controller;
try {
const payload = await fetchMaterial(material.filename, { signal: controller.signal });
if (owner.materialDetailView !== detail) return;
const sourceImage = payload.data?.source?.image || {};
const materialSource = payload.data?.source || {};
const sourceName = sourceImage.filename || materialSource.parameter_name
|| materialSource.recipe_name || materialSource.notebook_name || material.name || t('materialUntitled');
sourceNameElement.textContent = sourceName;
sourceNameElement.title = sourceName;
inspector.replaceChildren();
renderMaterialInspector(inspector, payload, owner, material);
} catch (error) {
if (error?.name === 'AbortError') return;
console.error('Could not load material detail:', error);
inspector.replaceChildren();
text(inspector, 'p', t('materialDetailLoadError'), 'anomalous-material-empty');
} finally {
if (owner.materialDetailController === controller) owner.materialDetailController = null;
}
}
function startInlineTitleEdit(owner, material, titleRow, cardTitle, editBtn) {
if (titleRow.querySelector('.anomalous-material-inline-input')) return;
const originalName = material.name || '';
const input = document.createElement('input');
input.type = 'text';
input.className = 'anomalous-material-inline-input';
input.value = originalName;
input.maxLength = 120;
input.placeholder = t('materialName') || '输入短标题…';
cardTitle.style.display = 'none';
editBtn.style.display = 'none';
titleRow.appendChild(input);
input.focus();
input.select();
let isSaving = false;
const finish = async (shouldSave) => {
if (isSaving) return;
const newName = input.value.trim();
if (shouldSave && newName && newName !== originalName) {
isSaving = true;
input.disabled = true;
try {
const response = await fetch('/anomalous/update_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: material.filename,
name: newName,
tags: material.tags || [],
}),
});
const payload = await jsonResponse(response, 'material update failed');
if (payload.status === 'success' && payload.material) {
Object.assign(material, payload.material);
cardTitle.textContent = material.name;
cardTitle.title = material.name;
}
} catch (err) {
console.warn('Failed to update title:', err);
}
}
input.remove();
cardTitle.style.display = '';
editBtn.style.display = '';
};
input.onclick = (e) => e.stopPropagation();
input.onkeydown = (e) => {
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
finish(true);
} else if (e.key === 'Escape') {
e.preventDefault();
finish(false);
}
};
input.onblur = () => finish(true);
}
function renderMaterialCard(owner, material) {
const card = document.createElement('article');
card.className = 'anomalous-material-card';
card.title = t('materialViewDetails') || '点击查看详细参数';
const activate = () => showMaterialDetail(owner, material);
card.onclick = activate;
if (material.node_types?.length) {
bindMaterialDrag(card, owner, {
payload: () => ({ ...material, node_types: [...material.node_types], dragHint: t('materialDragParameters') || '拖拽素材参数至目标节点' }),
accepts: (node, source) => source.node_types.includes(node.type),
drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph),
});
}
card.tabIndex = 0;
card.setAttribute('aria-label', `${material.name} — ${t('materialViewDetails')}`);
card.onkeydown = event => {
if (event.target === card && ['Enter', ' '].includes(event.key)) {
event.preventDefault();
activate();
}
};
const actionsWrapper = document.createElement('div');
actionsWrapper.className = 'anomalous-material-card-actions-wrapper';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'anomalous-material-card-action-btn anomalous-material-card-delete';
remove.innerHTML = '';
remove.title = t('materialDelete');
remove.onclick = (e) => {
e.stopPropagation();
deleteMaterial(owner, material);
};
card.appendChild(remove);
const removeClone = typeof remove.cloneNode === 'function' ? remove.cloneNode(true) : document.createElement('button');
actionsWrapper.appendChild(removeClone);
removeClone.onclick = remove.onclick;
if (!owner.materialApplyMode && (material.capabilities || []).includes('open_workflow')) {
const quickOpen = document.createElement('button');
quickOpen.type = 'button';
quickOpen.className = 'anomalous-material-card-action-btn anomalous-material-card-quick-load';
quickOpen.innerHTML = '';
quickOpen.title = t('materialOpenWorkflow');
quickOpen.onclick = async (e) => {
e.stopPropagation();
try {
await openMaterialWorkflow(owner, material.filename);
} catch (error) {
await anomalousAlert(t('materialOpenError'));
}
};
card.appendChild(quickOpen);
const quickOpenClone = typeof quickOpen.cloneNode === 'function' ? quickOpen.cloneNode(true) : document.createElement('button');
actionsWrapper.appendChild(quickOpenClone);
quickOpenClone.onclick = quickOpen.onclick;
}
const preview = document.createElement('div');
preview.className = 'anomalous-material-card-preview';
const previewUrl = materialAssetUrl(material.filename, material.image?.preview_asset_id || material.image?.source_asset_id);
if (previewUrl) {
const image = document.createElement('img');
image.src = previewUrl;
image.alt = material.name || t('materialUntitled');
image.loading = 'lazy';
image.draggable = false;
preview.appendChild(image);
} else {
if (isPromptMaterial(material) || material.kind === 'prompt_plan') {
preview.classList.add('is-prompt-fallback');
preview.innerHTML = getMaterialPlaceholderSvg(material, 28);
const snippet = document.createElement('div');
snippet.className = 'anomalous-material-preview-snippet';
snippet.textContent = material.summary || material.name || 'Prompt Note';
preview.appendChild(snippet);
} else if (material.kind === 'recipe_parameter_selection') {
preview.classList.add('is-params-fallback');
preview.innerHTML = getMaterialPlaceholderSvg(material, 28);
const snippet = document.createElement('div');
snippet.className = 'anomalous-material-preview-snippet';
snippet.textContent = material.name || 'Parameters Scheme';
preview.appendChild(snippet);
} else {
preview.classList.add('is-workflow-fallback');
preview.innerHTML = getMaterialPlaceholderSvg(material, 28);
}
}
card.appendChild(preview);
const body = document.createElement('div');
body.className = 'anomalous-material-card-body';
const titleRow = document.createElement('div');
titleRow.className = 'anomalous-material-card-title-row';
const cardTitle = document.createElement('h3');
cardTitle.className = 'anomalous-material-card-title-text';
cardTitle.textContent = material.name || t('materialUntitled');
cardTitle.title = material.name || t('materialUntitled');
titleRow.appendChild(cardTitle);
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'anomalous-material-card-edit-btn';
editBtn.title = t('materialQuickEditTitle') || '修改短标题';
editBtn.innerHTML = `
`;
editBtn.onclick = (e) => {
e.stopPropagation();
startInlineTitleEdit(owner, material, titleRow, cardTitle, editBtn);
};
titleRow.appendChild(editBtn);
body.appendChild(titleRow);
const metadata = document.createElement('div');
metadata.className = 'anomalous-material-card-meta';
if (material.kind === 'prompt_plan') {
text(metadata, 'span', t('promptPlan'), 'anomalous-material-scope-badge');
} else if (isPromptMaterial(material)) {
text(metadata, 'span', promptKindLabel(material), 'anomalous-material-scope-badge is-prompt');
} else if (material.kind === 'recipe_parameter_selection') {
text(metadata, 'span', t('materialRecipeParameterMaterial'), 'anomalous-material-scope-badge is-nodes');
} else if (material.selection?.scope === 'nodes') {
const isPromptOnly = Array.isArray(material.node_types) &&
material.node_types.length > 0 &&
material.node_types.every(tp => /cliptextencode/i.test(tp));
const badgeText = isPromptOnly
? `💬 ${t('materialPromptNode') || '提示词'}`
: t('materialSelectedNodeMaterial');
text(metadata, 'span', badgeText, 'anomalous-material-scope-badge is-nodes');
} else {
text(metadata, 'span', t('materialFullWorkflowMaterial'), 'anomalous-material-scope-badge is-workflow');
}
if (!isPromptMaterial(material) && material.kind !== 'prompt_plan') text(metadata, 'span', t('materialNodeSummary', { count: material.node_count || 0 }), 'anomalous-material-meta-pill');
body.appendChild(metadata);
const secondaryRow = document.createElement('div');
secondaryRow.className = 'anomalous-material-card-subinfo';
if (material.timestamp) {
const dateStr = new Date(material.timestamp).toLocaleDateString();
text(secondaryRow, 'span', dateStr, 'anomalous-material-card-date');
}
renderSourceRecipeMark(secondaryRow, material.source_recipe);
if (secondaryRow.hasChildNodes()) {
body.appendChild(secondaryRow);
}
if (Array.isArray(material.node_types) && material.node_types.length) {
const displayLimit = 2;
const shownTypes = material.node_types.slice(0, displayLimit);
const labels = shownTypes.map(type => materialNodeHeading({ type }));
const remaining = material.node_types.length - shownTypes.length;
let summaryText = labels.join(' · ');
if (remaining > 0) {
summaryText += t('materialOtherNodeTypes', { count: remaining });
}
const types = text(body, 'small', summaryText, 'anomalous-material-types');
types.title = material.node_types.map(type => materialNodeHeading({ type })).join(' · ');
}
if (material.tags?.length) {
const tags = text(body, 'div', '', 'anomalous-material-tags');
material.tags.forEach(tag => text(tags, 'span', tag, 'anomalous-material-tag'));
}
if (owner.materialApplyMode) {
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.className = 'anomalous-material-card-apply-btn';
applyBtn.innerHTML = `⚡ ${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}`;
applyBtn.title = t('materialApplyCard') || (window.anomalous_browser_lang === 'zh' ? '点击将本方案参数应用替换到当前节点' : 'Click to apply parameters to current node');
applyBtn.onclick = async (e) => {
e.stopPropagation();
applyBtn.disabled = true;
applyBtn.innerHTML = `⏳ ${t('assistantApplying') || '应用中...'}`;
try {
await applyLibraryMaterial(owner, material);
applyBtn.innerHTML = `✅ ${t('assistantApplied') || '已应用'}`;
setTimeout(() => {
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.innerHTML = `⚡ ${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}`;
}
}, 2000);
} catch (err) {
console.error(err);
applyBtn.disabled = false;
applyBtn.innerHTML = `⚡ ${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}`;
}
};
body.appendChild(applyBtn);
const applyBtnClone = applyBtn.cloneNode(true);
applyBtnClone.onclick = applyBtn.onclick;
actionsWrapper.insertBefore(applyBtnClone, actionsWrapper.firstChild);
}
card.appendChild(body);
card.appendChild(actionsWrapper);
return card;
}
function toggleMaterialEditor(owner, material, header) {
const existing = header.querySelector('.anomalous-material-editor');
if (existing) { existing.remove(); return; }
const form = text(header, 'form', '', 'anomalous-material-editor');
const nameLabel = text(form, 'label', t('materialName'));
const name = text(nameLabel, 'input', '');
name.value = material.name || '';
name.maxLength = 120;
name.required = true;
const tagsLabel = text(form, 'label', t('materialTags'));
const tags = text(tagsLabel, 'input', '');
tags.value = (material.tags || []).join(', ');
tags.placeholder = t('materialTagsHint');
tags.maxLength = 1200;
const save = text(form, 'button', t('materialSaveDetails'), 'anomalous-btn-primary');
save.type = 'submit';
const status = text(form, 'span', '', 'anomalous-material-muted');
status.setAttribute('role', 'status');
form.onsubmit = async event => {
event.preventDefault();
save.disabled = true;
try {
const response = await fetch('/anomalous/update_material', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: material.filename, name: name.value.trim(),
tags: tags.value.split(/[,,]/).map(value => value.trim()).filter(Boolean) }),
});
const payload = await jsonResponse(response, 'material update failed');
if (payload.status !== 'success') throw new Error('material update failed');
Object.assign(material, payload.material);
header.querySelector('h2').textContent = material.name;
header.querySelector('h2').title = material.name;
const preview = owner.materialDetailView?.querySelector('.anomalous-library-detail-image-stage img');
if (preview) preview.alt = material.name;
await owner.refreshMaterials?.();
form.remove();
} catch (error) {
status.textContent = t('materialUpdateError');
save.disabled = false;
}
};
name.focus();
}
function buildMaterialTopbar(owner) {
const topbar = text(owner.materialView, 'header', '', 'anomalous-material-topbar');
owner.materialTopbar = topbar;
// 1. 左侧微胶囊分类切换
const left = text(topbar, 'div', '', 'anomalous-material-topbar-left');
const pills = text(left, 'div', '', 'anomalous-material-pills');
const categories = [
{ id: 'all', key: 'materialKindPill_all', kind: '' },
{ id: 'workflow', key: 'materialKindPill_workflow', kind: 'image_workflow_snapshot' },
{ id: 'params', key: 'materialKindPill_params', kind: 'recipe_parameter_selection' },
{ id: 'prompts', key: 'materialKindPill_prompts', kind: 'prompt_plan' },
];
owner.materialKindPills = {};
for (const cat of categories) {
const pill = text(pills, 'button', t(cat.key) || cat.id, 'anomalous-material-pill');
pill.type = 'button';
if ((!owner.materialKindCategory && cat.id === 'all') || owner.materialKindCategory === cat.id) {
pill.classList.add('is-active');
}
pill.onclick = () => {
for (const p of Object.values(owner.materialKindPills)) p.classList.remove('is-active');
pill.classList.add('is-active');
owner.materialKindCategory = cat.id;
owner.materialKind = '';
if (cat.id === 'prompts') owner.materialApplyMode = false;
owner.refreshMaterials(1);
};
owner.materialKindPills[cat.id] = pill;
}
// 2. 中间紧凑搜索与标签下拉
const center = text(topbar, 'div', '', 'anomalous-material-topbar-center');
const searchWrap = text(center, 'div', '', 'anomalous-material-search-wrap');
const searchIcon = text(searchWrap, 'span', '', 'anomalous-material-search-icon');
searchIcon.innerHTML = ``;
const search = text(searchWrap, 'input', '', 'anomalous-material-search-input');
search.type = 'search';
search.placeholder = t('materialSearchHint') || '搜索素材名称或标签...';
search.value = owner.materialQuery || '';
search.oninput = () => {
clearTimeout(owner.materialSearchTimer);
owner.materialQuery = search.value;
owner.materialSearchTimer = setTimeout(() => owner.refreshMaterials(1), 250);
};
owner.materialTagSelect = text(center, 'select', '', 'anomalous-material-tag-select');
owner.materialTagSelect.setAttribute('aria-label', t('materialTags'));
owner.materialTagSelect.onchange = () => {
owner.materialTag = owner.materialTagSelect.value;
owner.refreshMaterials(1);
};
// 3. 右侧操作组
const right = text(topbar, 'div', '', 'anomalous-material-topbar-right');
// 视图切换 (网格 / 列表)
const viewSwitch = text(right, 'div', '', 'anomalous-material-view-switch');
const currentMode = owner.materialViewMode || (typeof localStorage !== 'undefined' ? localStorage.getItem('anomalous_material_view_mode') : null) || 'grid';
owner.materialViewMode = currentMode;
const gridBtn = text(viewSwitch, 'button', '', `anomalous-material-view-btn${currentMode === 'grid' ? ' is-active' : ''}`);
gridBtn.type = 'button';
gridBtn.title = t('materialViewGrid') || '网格视图';
gridBtn.innerHTML = ``;
const listBtn = text(viewSwitch, 'button', '', `anomalous-material-view-btn${currentMode === 'list' ? ' is-active' : ''}`);
listBtn.type = 'button';
listBtn.title = t('materialViewList') || '列表视图';
listBtn.innerHTML = ``;
gridBtn.onclick = () => {
owner.materialViewMode = 'grid';
if (typeof localStorage !== 'undefined') localStorage.setItem('anomalous_material_view_mode', 'grid');
gridBtn.classList.add('is-active');
listBtn.classList.remove('is-active');
owner.materialList?.classList.remove('is-list');
owner.materialList?.classList.add('is-grid');
};
listBtn.onclick = () => {
owner.materialViewMode = 'list';
if (typeof localStorage !== 'undefined') localStorage.setItem('anomalous_material_view_mode', 'list');
listBtn.classList.add('is-active');
gridBtn.classList.remove('is-active');
owner.materialList?.classList.remove('is-grid');
owner.materialList?.classList.add('is-list');
};
// 刷新按钮
const refreshBtn = text(right, 'button', '↻', 'anomalous-material-topbar-btn');
refreshBtn.type = 'button';
refreshBtn.title = t('refresh');
refreshBtn.onclick = () => owner.refreshMaterials();
// 更多/转移中心
const more = text(right, 'details', '', 'anomalous-secondary-actions');
text(more, 'summary', '···');
more.title = t('notebookMore') || '更多';
const transfer = text(more, 'button', t('materialTransferCenter'), 'anomalous-btn-ghost');
transfer.onclick = () => showTransferCenter(owner);
}
function renderMaterialPagination(owner, payload) {
owner.materialPager.replaceChildren();
const previous = text(owner.materialPager, 'button', t('materialPrevious'), 'anomalous-btn-ghost');
previous.type = 'button';
previous.disabled = payload.page <= 1;
previous.onclick = () => owner.refreshMaterials(payload.page - 1);
text(owner.materialPager, 'span', t('materialPageSummary', { page: payload.page, pages: payload.pages, count: payload.total }));
const next = text(owner.materialPager, 'button', t('materialNext'), 'anomalous-btn-ghost');
next.type = 'button';
next.disabled = payload.page >= payload.pages;
next.onclick = () => owner.refreshMaterials(payload.page + 1);
if (owner.materialTagSelect) {
const tags = owner.materialTagSelect;
tags.replaceChildren();
text(tags, 'option', t('materialAllTags')).value = '';
for (const tag of payload.tags || []) text(tags, 'option', tag).value = tag;
if (owner.materialTag && !(payload.tags || []).includes(owner.materialTag)) text(tags, 'option', owner.materialTag).value = owner.materialTag;
tags.value = owner.materialTag || '';
}
}
export async function refreshMaterials(page = this.materialPage || 1) {
if (!this.materialList) return;
this.materialListController?.abort();
const controller = new AbortController();
this.materialListController = controller;
this.materialList.replaceChildren();
text(this.materialList, 'p', t('loading'), 'anomalous-material-empty');
updateMaterialContext(this);
for (const [key, pill] of Object.entries(this.materialKindPills || {})) pill.classList.toggle('is-active', key === (this.materialKindCategory || 'all'));
const query = new URLSearchParams({
page,
limit: 48,
q: this.materialQuery || '',
tag: this.materialTag || '',
kind: this.materialKind || '',
category: this.materialKindCategory || 'all',
});
if (this.materialApplyMode && this.materialTarget) query.set('node_type', this.materialTarget.type);
try {
const response = await fetch(`/anomalous/materials?${query}`, { cache: 'no-store', signal: controller.signal });
const payload = await jsonResponse(response, 'material list failed');
if (this.materialListController !== controller) return;
this.materialPage = payload.page;
this.materialList.replaceChildren();
renderMaterialPagination(this, payload);
const materials = Array.isArray(payload.materials) ? payload.materials : [];
if (!materials.length) {
text(this.materialList, 'p', t(this.materialQuery || this.materialTag || this.materialKind ? 'materialNoMatches' : 'materialEmpty'), 'anomalous-material-empty');
return;
}
const fragment = document.createDocumentFragment();
for (const material of materials) fragment.appendChild(renderMaterialCard(this, material));
this.materialList.appendChild(fragment);
} catch (error) {
if (error?.name === 'AbortError' || this.materialListController !== controller) return;
console.error('Could not load materials:', error);
this.materialList.replaceChildren();
text(this.materialList, 'p', t('materialLoadError'), 'anomalous-material-empty');
} finally {
if (this.materialListController === controller) this.materialListController = null;
}
}
export async function showMaterials() {
this.nbPanel.style.display = 'flex';
if (!this.materialContainer) {
this.materialContainer = text(this.nbPanel, 'div', '', 'anomalous-nb-container anomalous-material-container');
const header = text(this.materialContainer, 'div', '', 'anomalous-nb-header');
this.materialHeading = text(header, 'h2', t('materialLibrary'));
const close = text(header, 'button', '×', 'anomalous-btn-ghost');
close.setAttribute('aria-label', t('close'));
close.onclick = () => this.closeWorkspace();
}
this.materialHeading.textContent = t('materialLibrary');
this.materialContainer.style.display = 'flex';
watchMaterialSelection(this);
hideSiblingWorkspaceViews(this);
leaveMaterialDetail(this);
if (this.materialView && this.materialViewLocale !== resolveLocale()) {
this.materialView.remove();
this.materialView = null;
}
if (!this.materialView) {
this.materialViewLocale = resolveLocale();
this.materialView = document.createElement('div');
this.materialView.className = 'anomalous-material-body';
buildMaterialTopbar(this);
const mainArea = text(this.materialView, 'div', '', 'anomalous-material-main-area');
this.materialMainArea = mainArea;
const contentArea = text(mainArea, 'div', '', 'anomalous-material-content');
this.materialContentArea = contentArea;
this.materialContext = text(contentArea, 'div', '', 'anomalous-material-target-context');
const initialMode = this.materialViewMode || localStorage.getItem('anomalous_material_view_mode') || 'grid';
this.materialList = document.createElement('div');
this.materialList.className = `anomalous-material-list is-${initialMode}`;
contentArea.appendChild(this.materialList);
this.materialPager = text(contentArea, 'nav', '', 'anomalous-material-pager');
this.materialPager.setAttribute('aria-label', t('materialPagination'));
this.materialContainer.appendChild(this.materialView);
}
this.materialView.style.display = 'flex';
updateMaterialContext(this);
await this.refreshMaterials();
}
/**
* Direct client-side PNG chunk parser to safely extract embedded ComfyUI metadata
* without requiring a server reboot or server-side re-encoding.
*/
export async function showImageMaterialDetail(owner, sourceImage, imageUrl, options = {}) {
return showImageWorkbench(owner, sourceImage, imageUrl, options);
}
export async function openSavedMaterial(material) {
this.recipeDetailFinish?.('closed');
this.modal?.classList.add('visible');
if (this.nbPanel.style.display !== 'flex' && !this.workspaceReturnState) {
this.workspaceReturnState = Object.fromEntries([
['grid', this.grid], ['detail', this.detailPanel], ['gallery', this.galleryPanel],
['doctor', this.doctorPanel], ['assistant', this.assistantPanel],
].filter(([, panel]) => panel).map(([key, panel]) => [key, panel.style.display]));
}
this.nbPanel.style.display = 'flex';
for (const panel of [this.grid, this.detailPanel, this.galleryPanel, this.doctorPanel, this.assistantPanel, this.paramPanel]) {
if (panel) panel.style.display = 'none';
}
await showMaterials.call(this);
if (material) await showMaterialDetail(this, material);
}
export async function openMaterialLibrary() {
this.materialApplyMode = !!selectedMaterialNode(app);
this.materialTarget = selectedMaterialNode(app);
if (this.materialApplyMode) { this.materialKind = ''; this.materialKindCategory = 'all'; }
await openSavedMaterial.call(this, null);
}
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', t('materialApplyContextHint'));
}
function watchMaterialSelection(owner) {
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;
};
}
}
async function applyLibraryMaterial(owner, material, droppedNode = null, graph = app.graph) {
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;
const blocks = (payload.node_blocks || []).filter(block => block.type === node.type && block.widgets_values?.length);
if (!blocks.length) throw new Error('materialNoCompatibleValues');
const apply = droppedNode ? applyMaterialToNode : applyMaterialToSelectedNode;
if (blocks.length === 1) {
apply(node, blocks[0], payload.workflow_hashes, owner.materialContext);
} 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');
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 rgba(255,255,255,0.1);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.role] || '';
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);
// 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;
}
}
}
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);
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') || '展开全部 ▾';
}
};
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');
apply(node, block, payload.workflow_hashes, owner.materialContext);
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();
}
} catch (error) { await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); }
finally { owner.materialApplying = false; }
}
function showTransferCenter(owner) {
const dialog = document.createElement('dialog'); dialog.className = 'anomalous-material-choice';
text(dialog, 'h3', t('materialTransferCenter'));
const status = text(dialog, 'p', ''); status.setAttribute('role', 'alert');
const workflow = text(dialog, 'button', t('materialTransferWorkflow'), 'anomalous-btn-primary');
workflow.onclick = async () => {
if (!window.AMB_WorkflowShare) { status.textContent = t('sidebarModuleNotLoaded'); return; }
dialog.close(); window.AMB_WorkflowShare.showUnifiedModal();
};
const recipe = text(dialog, 'button', t('materialTransferRecipes'), 'anomalous-btn-ghost');
recipe.onclick = () => { dialog.close(); owner.showRecipes?.(); };
const importPlan = text(dialog, 'button', t('promptImportPlan'), 'anomalous-btn-ghost');
const file = text(dialog, 'input', ''); file.type = 'file'; file.accept = '.json'; file.hidden = true;
importPlan.onclick = () => file.click();
file.onchange = async () => {
const source = file.files?.[0]; if (!source) return;
importPlan.disabled = true;
try {
if (source.size > 2 * 1024 * 1024) throw new Error('oversize');
const plan = JSON.parse(await source.text());
if (!dialog.open) return;
if (plan.format !== 'anomalous-prompt-plan-v1' && plan.format !== 'anomalous-prompt-mixer-v2') throw new Error('format');
const response = await fetch('/anomalous/save_prompt_plan', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: plan.name, tags: plan.tags || [], plan: plan.plan }) });
if (response.status === 409) { status.textContent = t('promptAlreadyImported'); return; }
const result = await jsonResponse(response, 'plan import failed');
if (result.status !== 'success') throw new Error('import failed');
if (!dialog.open) return;
dialog.close(); owner.materialApplyMode = false; owner.materialKind = 'prompt_plan'; owner.materialKindCategory = 'prompts'; await owner.showMaterials();
} catch (error) { status.textContent = t('promptImportError'); }
finally { file.value = ''; importPlan.disabled = false; }
};
text(dialog, 'p', t('promptExportLocation'));
const close = text(dialog, 'button', t('close'), 'anomalous-btn-ghost'); close.onclick = () => dialog.close();
dialog.onclose = () => dialog.remove(); document.body.appendChild(dialog); dialog.showModal();
}