/** * ui_notebook_editor.js * Combo (搭配) editor with unfolded cards, sticky toolbar, and compatible-model galleries. */ import { translate } from './locales.js'; import { escapeHtml } from './safe_dom.js'; import { isUnetModel, UNLABELED_BASE_MODEL } from './notebook_canvas.js'; import { STRUCTURED } from './combo_slots.js'; import { renderStructuredCombo } from './ui_combo_structure.js'; const t = (key, params) => translate(key, params); /** * Sticky top action toolbar for the active notebook with floating dropdown menu. */ export function createNotebookToolbar(ctx, notebook) { const tb = document.createElement('div'); tb.className = 'anomalous-nb-toolbar'; const titleBox = document.createElement('div'); titleBox.style.display = 'flex'; titleBox.style.alignItems = 'center'; titleBox.style.gap = '8px'; const titleIcon = document.createElement('span'); titleIcon.textContent = '📝'; titleIcon.style.fontSize = '1.2rem'; const titleArea = document.createElement('h3'); titleArea.textContent = notebook.name; titleArea.style.margin = '0'; titleArea.style.fontSize = '1.15rem'; titleArea.style.fontWeight = '700'; titleBox.append(titleIcon, titleArea); const rightBtns = document.createElement('div'); rightBtns.className = 'anomalous-notebook-actions'; const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.textContent = `💾 ${t('saveNotebook')}`; saveBtn.className = 'anomalous-btn-primary'; saveBtn.onclick = async () => { const orig = saveBtn.innerHTML; saveBtn.innerHTML = '⏳...'; const saved = await ctx.saveCurrentNotebook(); if (!saved) { saveBtn.innerHTML = orig; return; } saveBtn.innerHTML = '✅'; saveBtn.style.background = '#2e8b57'; setTimeout(() => { saveBtn.innerHTML = orig; saveBtn.style.background = ''; }, 1500); }; // Put on the canvas: the combo as a new group of nodes. const useBtn = Object.assign(document.createElement('button'), { type: 'button', className: 'anomalous-btn-success', textContent: t('comboUse'), title: t('comboUseHint') }); useBtn.onclick = () => ctx.sendNotebookToCanvas(); // Floating More Dropdown Menu const moreWrapper = document.createElement('div'); moreWrapper.className = 'anomalous-nb-dropdown-wrapper'; const moreBtn = document.createElement('button'); moreBtn.type = 'button'; moreBtn.className = 'anomalous-nb-more-btn'; moreBtn.innerHTML = `··· ${t('notebookMore')} ▾`; const dropdownMenu = document.createElement('div'); dropdownMenu.className = 'anomalous-nb-dropdown-menu'; let delTimer = null; const delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'anomalous-nb-dropdown-item anomalous-nb-dropdown-item-danger'; const normalDelHtml = `🗑️ ${t('deleteNotebook')}`; delBtn.innerHTML = normalDelHtml; const resetDel = () => { clearTimeout(delTimer); delBtn.classList.remove('confirming'); delBtn.innerHTML = normalDelHtml; }; delBtn.onclick = (e) => { e.stopPropagation(); if (!delBtn.classList.contains('confirming')) { delBtn.classList.add('confirming'); const delSureText = (t('delSure') || (window.anomalous_browser_lang === 'zh' ? '确认删除?' : 'Confirm Delete?')).replace(/^⚠️\s*/, ''); delBtn.innerHTML = `⚠️ ${delSureText}`; delTimer = setTimeout(resetDel, 4000); } else { resetDel(); dropdownMenu.classList.remove('show'); moreBtn.classList.remove('active'); ctx.deleteCurrentNotebook(); } }; dropdownMenu.append(delBtn); moreBtn.onclick = (e) => { e.stopPropagation(); const isOpen = dropdownMenu.classList.toggle('show'); moreBtn.classList.toggle('active', isOpen); if (!isOpen) resetDel(); }; const onDocClick = (e) => { if (!moreWrapper.contains(e.target)) { dropdownMenu.classList.remove('show'); moreBtn.classList.remove('active'); resetDel(); } }; document.addEventListener('click', onDocClick); moreWrapper.append(moreBtn, dropdownMenu); rightBtns.append(saveBtn, useBtn, moreWrapper); tb.append(titleBox, rightBtns); return tb; } /** * Flat companion models section (Base model + Checkpoints + LoRAs). */ function createCompanionModelsCard(ctx, data) { const card = document.createElement('div'); card.id = 'amb-nb-sec-models'; card.className = 'anomalous-nb-card anomalous-nb-models-card anomalous-nb-models-fold'; card.open = true; const header = document.createElement('div'); header.className = 'anomalous-nb-card-header'; const titleLeft = document.createElement('div'); titleLeft.style.display = 'flex'; titleLeft.style.alignItems = 'center'; titleLeft.style.gap = '8px'; const titleText = document.createElement('span'); titleText.textContent = `📦 ${t('notebookCompanionModels') || (window.anomalous_browser_lang === 'zh' ? '配套模型' : 'Companion Models')}`; titleText.style.fontWeight = '600'; const modelsBadge = document.createElement('span'); modelsBadge.className = 'anomalous-nb-models-badge'; titleLeft.append(titleText, modelsBadge); header.appendChild(titleLeft); const mainSelectedBadge = document.createElement('span'); mainSelectedBadge.className = 'anomalous-nb-selected-badge'; mainSelectedBadge.style.color = 'var(--amb-lt-violet, #c084fc)'; const loraSelectedBadge = document.createElement('span'); loraSelectedBadge.className = 'anomalous-nb-selected-badge'; loraSelectedBadge.style.color = 'var(--amb-warn)'; const updateSummary = () => { const baseInfo = data.baseModel === UNLABELED_BASE_MODEL ? t('notebookUnlabeledBase') : (data.baseModel || 'SDXL'); const mainInfo = data.mainModel?.filename ? ` · ${data.mainModel.filename}` : ''; const loraCount = data.loras?.length ? ` · ${data.loras.length} LoRA` : ''; modelsBadge.textContent = `${baseInfo}${mainInfo}${loraCount}`; mainSelectedBadge.textContent = data.mainModel?.filename ? `✓ ${data.mainModel.filename}` : (t('recipeDiffNone') || '未选择'); loraSelectedBadge.textContent = data.loras?.length ? `✓ ${data.loras.length} LoRA` : (t('recipeDiffNone') || '未选择'); }; ctx.updateNotebookModelsSummary = updateSummary; // Base model selection const baseRow = document.createElement('div'); baseRow.className = 'anomalous-nb-row anomalous-nb-base-row'; baseRow.style.display = 'flex'; baseRow.style.alignItems = 'center'; baseRow.style.gap = '10px'; baseRow.style.marginBottom = '12px'; const baseTitle = document.createElement('span'); baseTitle.style.fontWeight = '600'; baseTitle.style.fontSize = '0.88rem'; baseTitle.style.color = 'var(--amb-lt-soft, #cbd5e1)'; baseTitle.textContent = `${t('baseModel') || (window.anomalous_browser_lang === 'zh' ? '基础模型' : 'Base Model')}:`; baseRow.appendChild(baseTitle); const baseSelect = document.createElement('select'); baseSelect.className = 'anomalous-nb-select'; const buildSelect = (bases) => { baseSelect.innerHTML = ''; bases.forEach(b => { const opt = document.createElement('option'); opt.value = b; opt.text = b === UNLABELED_BASE_MODEL ? t('notebookUnlabeledBase') : b; if (data.baseModel === b) opt.selected = true; baseSelect.appendChild(opt); }); if (!data.baseModel && bases.length > 0) data.baseModel = bases[0]; updateSummary(); }; if (ctx.baseModelsCache) { buildSelect(ctx.baseModelsCache); } else { const tempBases = ['SD 1.5', 'SD 2.1', 'SDXL', 'SD 3.0', 'SD 3.5', 'Flux.1', 'Pony', 'HunyuanVideo', 'LTX-Video', 'OmniGen']; buildSelect(tempBases); fetch('/anomalous/base_models').then(r => r.json()).then(d => { const bases = [...(d.base_models || []), ...(d.unlabeled ? [UNLABELED_BASE_MODEL] : [])]; if (bases.length > 0) { ctx.baseModelsCache = bases; buildSelect(ctx.baseModelsCache); } }).catch(() => { }); } if (!data.baseModel) data.baseModel = 'SDXL'; baseSelect.onchange = () => { data.baseModel = baseSelect.value; data.mainModel = null; data.loras = []; ctx.saveCurrentNotebook(); ctx.renderNotebookEditor(); }; baseRow.appendChild(baseSelect); // Main Model Gallery const mainBox = document.createElement('div'); mainBox.className = 'anomalous-nb-gallery-box'; const mainRow = document.createElement('div'); mainRow.className = 'anomalous-nb-row'; mainRow.style.display = 'flex'; mainRow.style.alignItems = 'center'; mainRow.style.justifyContent = 'space-between'; mainRow.style.marginBottom = '8px'; const mainLabel = document.createElement('strong'); mainLabel.textContent = `🎨 ${t('mainModel') || (window.anomalous_browser_lang === 'zh' ? '主模型' : 'Main Model')}`; mainRow.append(mainLabel, mainSelectedBadge); const mainGallery = document.createElement('div'); mainGallery.className = 'anomalous-nb-gallery-wrap'; mainBox.append(mainRow, mainGallery); // LoRA Gallery const loraBox = document.createElement('div'); loraBox.className = 'anomalous-nb-gallery-box'; const loraRow = document.createElement('div'); loraRow.className = 'anomalous-nb-row'; loraRow.style.display = 'flex'; loraRow.style.alignItems = 'center'; loraRow.style.justifyContent = 'space-between'; loraRow.style.marginBottom = '8px'; const loraLabel = document.createElement('strong'); loraLabel.textContent = `⚡ ${t('loras') || (window.anomalous_browser_lang === 'zh' ? 'LoRA 模型' : 'LoRA Models')}`; loraRow.append(loraLabel, loraSelectedBadge); const loraGallery = document.createElement('div'); loraGallery.className = 'anomalous-nb-gallery-wrap'; loraBox.append(loraRow, loraGallery); const unetHint = document.createElement('div'); unetHint.className = 'anomalous-nb-unet-hint'; unetHint.textContent = t('notebookUnetNeedsTextEncoder'); mainBox.appendChild(unetHint); const showUnetHint = () => { unetHint.hidden = !isUnetModel(data.mainModel); }; ctx.updateNotebookUnetHint = showUnetHint; showUnetHint(); card.append(header, baseRow, mainBox, loraBox); updateSummary(); ctx.fillNotebookGalleries(data.baseModel, mainGallery, loraGallery, data); return card; } /** * Prompt composer section with bilingual dual pane and compact inline tools. */ function createPromptSection(ctx, data) { const card = document.createElement('div'); card.id = 'amb-nb-sec-prompt'; card.className = 'anomalous-nb-card anomalous-nb-prompt-card anomalous-nb-prompt-section'; const header = document.createElement('div'); header.className = 'anomalous-nb-card-header'; const title = document.createElement('span'); title.innerHTML = `✍️ ${t('notebookPromptTitle') || (window.anomalous_browser_lang === 'zh' ? '提示词' : 'Prompt')}`; const rawBtn = document.createElement('button'); rawBtn.type = 'button'; rawBtn.className = 'anomalous-btn-primary'; rawBtn.textContent = t('notebookDoneEditing') || (window.anomalous_browser_lang === 'zh' ? '📑 查看双语分词' : '📑 View Bilingual Tags'); header.append(title, rawBtn); const rawArea = document.createElement('textarea'); rawArea.className = 'anomalous-nb-textarea'; rawArea.value = data.promptEn || ''; rawArea.placeholder = t('notebookPromptPlaceholder'); const dualPane = document.createElement('div'); dualPane.className = 'anomalous-nb-dual-pane'; dualPane.style.display = 'none'; if (!data.translations) data.translations = {}; let visualDebounceTimer = null; // One save after a burst (typing, or every tag's translation arriving), not one per change. const saveSoon = () => { clearTimeout(ctx.pTimeout); ctx.pTimeout = setTimeout(() => ctx.saveCurrentNotebook(), 500); }; const updateVisualTags = () => { const txt = rawArea.value; if (!txt.trim()) { dualPane.replaceChildren(); return; } const tags = txt.split(',').map(s => s.trim()).filter(Boolean); const fragment = document.createDocumentFragment(); tags.forEach((tag, idx) => { const tagRow = document.createElement('div'); tagRow.className = 'anomalous-nb-tag-row'; const tagL = document.createElement('div'); tagL.className = 'anomalous-nb-visual-tag'; tagL.style.flex = '1'; tagL.style.justifyContent = 'space-between'; const txtL = document.createElement('span'); txtL.innerText = tag; const copyL = document.createElement('span'); copyL.className = 'anomalous-nb-copy-btn'; copyL.innerHTML = '📋'; copyL.onclick = (e) => { e.stopPropagation(); navigator.clipboard.writeText(tag).then(() => { copyL.innerHTML = '✅'; setTimeout(() => copyL.innerHTML = '📋', 1000); }); }; tagL.append(txtL, copyL); const tagR = document.createElement('div'); tagR.className = 'anomalous-nb-visual-tag'; tagR.style.flex = '1'; tagR.style.justifyContent = 'space-between'; const transTxt = data.translations[tag] ? data.translations[tag] : '...'; const txtR = document.createElement('span'); txtR.innerText = transTxt; const copyR = document.createElement('span'); copyR.className = 'anomalous-nb-copy-btn'; copyR.innerHTML = '📋'; copyR.onclick = (e) => { e.stopPropagation(); navigator.clipboard.writeText(txtR.innerText).then(() => { copyR.innerHTML = '✅'; setTimeout(() => copyR.innerHTML = '📋', 1000); }); }; tagR.append(txtR, copyR); tagL.onmouseenter = () => { tagL.classList.add('hover'); tagR.classList.add('hover'); }; tagL.onmouseleave = () => { tagL.classList.remove('hover'); tagR.classList.remove('hover'); }; tagR.onmouseenter = () => { tagL.classList.add('hover'); tagR.classList.add('hover'); }; tagR.onmouseleave = () => { tagL.classList.remove('hover'); tagR.classList.remove('hover'); }; tagL.onclick = () => { const inp = document.createElement('input'); inp.value = tag; inp.className = 'anomalous-nb-tag-edit'; tagL.innerHTML = ''; tagL.appendChild(inp); inp.focus(); const finish = () => { tags[idx] = inp.value.trim(); rawArea.value = tags.join(', '); updateVisualTags(); }; inp.onblur = finish; inp.onkeydown = (e) => { if (e.key === 'Enter') inp.blur(); }; }; tagR.onclick = () => { const inp = document.createElement('input'); inp.value = data.translations[tag] || ''; inp.className = 'anomalous-nb-tag-edit'; tagR.innerHTML = ''; tagR.appendChild(inp); inp.focus(); const finish = () => { data.translations[tag] = inp.value.trim(); ctx.saveCurrentNotebook(); updateVisualTags(); }; inp.onblur = finish; inp.onkeydown = (e) => { if (e.key === 'Enter') inp.blur(); }; }; tagRow.append(tagL, tagR); fragment.appendChild(tagRow); if (!data.translations[tag]) { fetch('/anomalous/translate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: tag, target_lang: data.targetLang || 'zh-CN' }) }).then(r => r.json()).then(d => { if (d.translated) { data.translations[tag] = d.translated; txtR.innerText = d.translated; saveSoon(); } }).catch(() => { }); } }); dualPane.replaceChildren(fragment); }; rawBtn.onclick = () => { if (rawArea.style.display === 'none') { rawArea.style.display = 'block'; dualPane.style.display = 'none'; rawBtn.textContent = t('notebookDoneEditing') || (window.anomalous_browser_lang === 'zh' ? '📑 查看双语分词' : '📑 View Bilingual Tags'); } else { rawArea.style.display = 'none'; dualPane.style.display = 'flex'; rawBtn.textContent = t('editRaw') || (window.anomalous_browser_lang === 'zh' ? '✏️ 编辑原始文本' : '✏️ Edit Raw Prompt'); updateVisualTags(); } }; rawArea.oninput = () => { data.promptEn = rawArea.value; saveSoon(); if (dualPane.style.display !== 'none') { clearTimeout(visualDebounceTimer); visualDebounceTimer = setTimeout(() => updateVisualTags(), 300); } }; const toolsBar = createPromptToolsBar(ctx, data, rawArea, updateVisualTags); card.append(header, rawArea, dualPane, toolsBar); return card; } /** * Compact, unfolded single-line toolbar for Find & Replace and Language selection. */ function createPromptToolsBar(ctx, data, rawArea, onTagsUpdated) { const bar = document.createElement('div'); bar.id = 'amb-nb-sec-tools'; bar.className = 'anomalous-nb-tools-bar'; const langSelect = document.createElement('select'); langSelect.className = 'anomalous-nb-select'; const langs = [ { v: 'zh-CN', l: '🇨🇳 中文 (zh-CN)' }, { v: 'en', l: '🇬🇧 English (en)' }, { v: 'ja', l: '🇯🇵 日本语 (ja)' }, { v: 'ko', l: '🇰🇷 한국어 (ko)' }, { v: 'fr', l: '🇫🇷 Français (fr)' }, { v: 'de', l: '🇩🇪 Deutsch (de)' }, { v: 'es', l: '🇪🇸 Español (es)' }, { v: 'ru', l: '🇷🇺 Русский (ru)' } ]; langs.forEach(lg => { const opt = document.createElement('option'); opt.value = lg.v; opt.text = lg.l; if ((data.targetLang || 'zh-CN') === lg.v) opt.selected = true; langSelect.appendChild(opt); }); langSelect.onchange = () => { data.targetLang = langSelect.value; data.translations = {}; ctx.saveCurrentNotebook(); onTagsUpdated(); }; const findInput = document.createElement('input'); findInput.className = 'anomalous-nb-select'; findInput.placeholder = t('findPlaceholder') || (window.anomalous_browser_lang === 'zh' ? '查找词条...' : 'Find tag...'); findInput.style.flex = '1'; const replaceInput = document.createElement('input'); replaceInput.className = 'anomalous-nb-select'; replaceInput.placeholder = t('replacePlaceholder') || (window.anomalous_browser_lang === 'zh' ? '替换为...' : 'Replace with...'); replaceInput.style.flex = '1'; const replaceBtn = document.createElement('button'); replaceBtn.type = 'button'; replaceBtn.className = 'anomalous-btn-primary'; replaceBtn.innerHTML = t('replaceAll') || (window.anomalous_browser_lang === 'zh' ? '全部替换' : 'Replace All'); replaceBtn.onclick = () => { const findStr = findInput.value; const repStr = replaceInput.value; if (!findStr) return; rawArea.value = rawArea.value.split(findStr).join(repStr); data.promptEn = rawArea.value; ctx.saveCurrentNotebook(); onTagsUpdated(); }; bar.append(langSelect, findInput, replaceInput, replaceBtn); return bar; } /** * Primary notebook editor render entrypoint. */ export function renderNotebookEditor() { try { this.nbEditor.innerHTML = ''; if (!this.currentNotebook) return; const data = this.currentNotebook.data || (this.currentNotebook.data = {}); if (data.kind === STRUCTURED) return renderStructuredCombo(this, this.nbEditor, this.currentNotebook, createNotebookToolbar(this, this.currentNotebook)); if (!data.loras) data.loras = []; const tb = createNotebookToolbar(this, this.currentNotebook); const modelsCard = createCompanionModelsCard(this, data); const promptSec = createPromptSection(this, data); this.nbEditor.replaceChildren(tb, modelsCard, promptSec); } catch (err) { console.error('[AMB] Error rendering notebook editor:', err); if (this.nbEditor) { this.nbEditor.innerHTML = `
Render error: ${escapeHtml(err?.message || String(err))}
`; } } } /** * Fill compatible main models and Loras into horizontal galleries. */ export function fillNotebookGalleries(baseModel, mainGallery, loraGallery, data) { if (!baseModel) return; const buildThumbHtml = (m) => { if (m.preview_url) { const isVid = m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i); if (isVid) return ``; return ``; } return `
?
`; }; fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=checkpoints,unet,diffusion_models`) .then(r => r.json()).then(d => { const buildMainDOM = (models) => { mainGallery.innerHTML = ''; if (!models || !models.length) { mainGallery.innerHTML = `${window.anomalous_browser_lang === 'zh' ? '未找到兼容的主模型。' : 'No compatible main models found.'}`; } else { models.forEach(m => { const isSelected = (data.mainModel && data.mainModel.filename === m.filename); const card = document.createElement('div'); card.className = 'anomalous-nb-minicheck ' + (isSelected ? 'selected' : ''); card.innerHTML = `${buildThumbHtml(m)}
${escapeHtml(m.filename)}
`; if (m.preview_url && (m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i))) { card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(() => { }); }; card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } }; } card.onclick = () => { data.mainModel = (data.mainModel && data.mainModel.filename === m.filename) ? null : m; this.saveCurrentNotebook(); this.updateNotebookModelsSummary?.(); this.updateNotebookUnetHint?.(); buildMainDOM(models); }; mainGallery.appendChild(card); }); } }; buildMainDOM(d.models || []); }).catch(() => { }); fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=loras`) .then(r => r.json()).then(d => { const buildLoraDOM = (models) => { loraGallery.innerHTML = ''; if (!models || !models.length) { loraGallery.innerHTML = `${window.anomalous_browser_lang === 'zh' ? '未找到兼容的 LoRA。' : 'No compatible Loras found.'}`; } else { models.forEach(m => { const loraIndex = data.loras.findIndex(l => l.filename === m.filename); const isSelected = loraIndex !== -1; const card = document.createElement('div'); card.className = 'anomalous-nb-minilora ' + (isSelected ? 'selected' : ''); card.style.position = 'relative'; let badgeHtml = ''; if (isSelected) { badgeHtml = `
${loraIndex + 1}
`; } card.innerHTML = `${badgeHtml}${buildThumbHtml(m)}
${escapeHtml(m.filename)}
`; if (m.preview_url && (m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i))) { card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(() => { }); }; card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } }; } card.onclick = () => { if (isSelected) { data.loras = data.loras.filter(l => l.filename !== m.filename); } else { data.loras.push(m); } this.saveCurrentNotebook(); this.updateNotebookModelsSummary?.(); buildLoraDOM(models); }; loraGallery.appendChild(card); }); } }; buildLoraDOM(d.models || []); }).catch(() => { }); }