/** Material Library detail view and detail editing. */ import { app } from '../../../scripts/app.js'; import { translate } from './locales.js'; import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; import { text, jsonResponse } from './ui_dom.js'; import { sectionLabel, fileBaseName, renderDetailedNodeCards, applyPromptRolesToBlocks, renderMaterialPromptGroups, materialNodeHeading, } from './material_inspector.js'; import { selectedMaterialNode } from './node_material_actions.js'; import { fetchMaterial, applyLibraryMaterial } from './ui_material_application.js'; const t = (key, params) => translate(key, params); export const isPromptMaterial = material => ['prompt_note_bundle', 'prompt_text'].includes(material?.kind); export const promptKindLabel = material => t(material?.kind === 'prompt_text' ? 'materialPromptTextKind' : 'materialPromptNoteBundle'); export function getMaterialPromptInfo(material) { const isZh = window.anomalous_browser_lang === 'zh'; let text = ''; let role = 'positive'; const note = material?.data?.note || material?.note; if (note) { text = (isZh && note.promptZh) ? note.promptZh : (note.promptEn || note.promptZh || ''); } if (!text) { const plan = material?.data?.plan || material?.plan; if (plan) { if (plan.negative && !plan.positive) { text = plan.negative; role = 'negative'; } else if (plan.positive) { text = plan.positive; role = 'positive'; } } } if (!text && Array.isArray(material?.node_blocks)) { for (const block of material.node_blocks) { if (Array.isArray(block.widgets_values)) { for (const val of block.widgets_values) { if (typeof val === 'string' && val.trim()) { text = val.trim(); if (block.promptRole === 'negative') role = 'negative'; break; } } } if (text) break; } } if (!text) { text = material?.summary || material?.name || ''; } if (role !== 'negative') { const lowerName = String(material?.name || '').toLowerCase(); const tags = Array.isArray(material?.tags) ? material.tags.map(t => String(t).toLowerCase()) : []; if (lowerName.includes('negative') || lowerName.includes('负向') || lowerName.includes('反向') || tags.some(t => t.includes('negative') || t.includes('负向') || t.includes('反向'))) { role = 'negative'; } } return { text: text.trim(), role }; } export function getMaterialPlaceholderSvg(material, size = 36) { if (isPromptMaterial(material) || material?.kind === 'prompt_plan') { return ``; } if (material?.kind === 'recipe_parameter_selection') { return ``; } return ``; } export function materialAssetUrl(filename, asset) { if (!filename || !asset) return ''; return `/anomalous/material_asset?filename=${encodeURIComponent(filename)}&asset=${encodeURIComponent(asset)}`; } export 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'); } } export 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 = ''; } export 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; } export 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; } export 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 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(); }