Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_materials.js
T

1162 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { loadMaterialPrompts } from './material_prompt_data.js';
import { bindMaterialDrag } from './material_drag.js';
import { selectedMaterialNode } from './node_material_actions.js';
import { applyMaterialToSelectedNode, applyMaterialToNode } from './ui_material_application.js';
import { showPromptComposer, addPromptToDraft, renderSidePromptComposer, appendPromptToStudio } from './ui_prompt_composer.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, sectionLabel, fileBaseName, jsonResponse, 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 `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="#38bdf8" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:auto;filter:drop-shadow(0 2px 8px rgba(56,189,248,0.3));"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`;
}
if (material?.kind === 'recipe_parameter_selection') {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="#2dd4bf" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:auto;filter:drop-shadow(0 2px 8px rgba(45,212,191,0.3));"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;
}
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:auto;"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`;
}
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';
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 (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 add = text(content, 'button', t('promptAddToDraft'), 'anomalous-btn-ghost');
add.onclick = () => addPromptToDraft(owner, material.name, note.promptEn || '');
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');
}
if (renderMaterialPromptGroups(content, payload.prompt_groups, { manual: hasManualRole })) {
const add = text(content, 'button', t('promptAddToDraft'), 'anomalous-btn-ghost');
add.onclick = () => addPromptToDraft(owner, material.name,
(payload.prompt_groups.positive || []).join('\n'), (payload.prompt_groups.negative || []).join('\n'));
}
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';
if ((material.capabilities || []).includes('open_workflow')) {
const open = text(headerActions, 'button', `🚀 ${t('materialOpenWorkflow')}`, 'anomalous-btn-primary');
open.type = 'button';
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', `🗑️ ${t('materialDelete')}`, 'anomalous-btn-ghost');
remove.type = 'button';
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) {
if (material.kind === 'prompt_plan') return showPromptComposer(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 showMixerToast(message) {
const toast = document.createElement('div');
toast.className = 'anomalous-mixer-toast';
toast.innerHTML = message;
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('is-show'), 10);
setTimeout(() => {
toast.classList.remove('is-show');
setTimeout(() => toast.remove(), 300);
}, 2200);
}
async function sendMaterialToStudio(owner, material) {
try {
const { positive, negative } = await loadMaterialPrompts(material.filename);
if (!positive && !negative) { await anomalousAlert(t('materialNoPromptContent')); return; }
if (positive) appendPromptToStudio(owner, positive, true, material.name);
if (negative) appendPromptToStudio(owner, negative, false, `${material.name || ''} (Neg)`);
showMixerToast(`🎛️ ${window.anomalous_browser_lang === 'zh' ? `已将【${material.name || '素材'}】送入调音台` : `Added "${material.name || 'Material'}" to Prompt Mixer`}`);
} catch (error) { await anomalousAlert(t('materialDetailLoadError')); }
}
function renderMaterialCard(owner, material) {
const card = document.createElement('article');
card.className = 'anomalous-material-card';
card.title = t('materialViewDetails') || '点击查看详细参数';
const activate = () => owner.materialApplyMode ? applyLibraryMaterial(owner, material) : 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();
}
};
if (owner.materialApplyMode) {
card.title = t('materialApplyCard');
card.setAttribute('aria-label', `${material.name} — ${t('materialApplyCard')}`);
const inspect = text(card, 'button', t('materialViewDetails'), 'anomalous-material-inspect-action');
inspect.onclick = event => { event.stopPropagation(); showMaterialDetail(owner, material); };
}
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);
actionsWrapper.appendChild(remove.cloneNode(true));
actionsWrapper.lastChild.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);
actionsWrapper.appendChild(quickOpen.cloneNode(true));
actionsWrapper.lastChild.onclick = quickOpen.onclick;
}
const hasPromptTrait = isPromptMaterial(material) || material.kind === 'prompt_plan' || (Array.isArray(material.node_types) && material.node_types.some(tp => /cliptextencode/i.test(tp)));
if (hasPromptTrait) {
const sendToStudioBtn = document.createElement('button');
sendToStudioBtn.type = 'button';
sendToStudioBtn.className = 'anomalous-material-card-action-btn anomalous-material-card-send-studio';
sendToStudioBtn.innerHTML = '🎛️';
sendToStudioBtn.title = window.anomalous_browser_lang === 'zh' ? '送入提示词调音台拼装' : 'Dock to Prompt Mixer';
sendToStudioBtn.onclick = async (e) => {
e.stopPropagation();
await sendMaterialToStudio(owner, material);
};
card.appendChild(sendToStudioBtn);
actionsWrapper.appendChild(sendToStudioBtn.cloneNode(true));
actionsWrapper.lastChild.onclick = sendToStudioBtn.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 = `
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z"></path>
</svg>
`;
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'));
}
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 buildStudioTopbar(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 = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`;
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 || localStorage.getItem('anomalous_material_view_mode') || '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 = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg>`;
const listBtn = text(viewSwitch, 'button', '', `anomalous-material-view-btn${currentMode === 'list' ? ' is-active' : ''}`);
listBtn.type = 'button';
listBtn.title = t('materialViewList') || '列表视图';
listBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`;
gridBtn.onclick = () => {
owner.materialViewMode = 'grid';
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';
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);
// 提示词工坊:单开全幅左右双分栏工作台
const studioToggle = text(right, 'button', `🎛️ ${t('materialPromptStudio') || '提示词工坊'}`, 'anomalous-material-topbar-btn anomalous-material-studio-toggle');
studioToggle.type = 'button';
owner.materialStudioToggle = studioToggle;
studioToggle.onclick = () => showPromptComposer(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.closePromptImportDrawer?.();
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);
this.promptComposerView?.remove();
this.promptComposerView = null;
this.refreshPromptTarget = null;
hideSiblingWorkspaceViews(this);
leaveMaterialDetail(this);
if (this.materialView && this.materialViewLocale !== resolveLocale()) {
this.materialView.remove();
this.materialView = null;
}
if (!this.materialView) {
this.sideStudioInitialized = false;
this.sidePromptComposerControl = null;
this.materialViewLocale = resolveLocale();
this.materialView = document.createElement('div');
this.materialView.className = 'anomalous-material-body';
// 1. 一体化 Studio 顶栏
buildStudioTopbar(this);
// 2. 主区域分屏容器
const mainArea = text(this.materialView, 'div', '', 'anomalous-material-main-area');
this.materialMainArea = mainArea;
// 2.1 左侧主内容区
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'));
// 2.2 右侧并排便签工坊
const sideStudio = text(mainArea, 'aside', '', 'anomalous-material-side-studio');
this.materialSideStudio = sideStudio;
this.openSideStudio = () => {
this.materialSideStudio.classList.add('is-open');
this.materialStudioToggle?.classList.add('is-active');
localStorage.setItem('anomalous_side_studio_open', '1');
if (!this.sideStudioInitialized) {
renderSidePromptComposer(this, this.materialSideStudio, () => this.closeSideStudio());
this.sideStudioInitialized = true;
}
};
this.closeSideStudio = () => {
this.closePromptImportDrawer?.();
this.materialSideStudio.classList.remove('is-open');
this.materialStudioToggle?.classList.remove('is-active');
localStorage.setItem('anomalous_side_studio_open', '0');
};
this.toggleSideStudio = () => {
if (this.materialSideStudio.classList.contains('is-open')) {
this.closeSideStudio();
} else {
this.openSideStudio();
}
};
this.materialContainer.appendChild(this.materialView);
// 如果用户上次打开了工坊,自动保持状态
if (localStorage.getItem('anomalous_side_studio_open') === '1') {
this.openSideStudio();
}
}
this.materialView.style.display = 'flex';
if (this.materialSideStudio?.classList.contains('is-open')) {
renderSidePromptComposer(this, this.materialSideStudio, () => this.closeSideStudio());
this.sideStudioInitialized = true;
}
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) {
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;
const composing = !!owner.promptComposerView;
await owner.showMaterials();
if (composing) await showPromptComposer(owner);
else 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);
owner.refreshPromptTarget?.();
owner.refreshSidePromptTarget?.();
if (!owner.promptComposerView && !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');
for (const block of blocks) {
const choose = text(dialog, 'button', `${materialNodeHeading(block)} #${block.node_id}`, 'anomalous-btn-primary');
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); }
};
}
const close = text(dialog, 'button', t('close'), 'anomalous-btn-ghost'); 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') 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();
}