/** * The Combos page (搭配): each saved combo of a main model, LoRAs and a prompt (the files * GET /anomalous/notebooks keeps, formerly Prompt Notes), or of the model and text nodes saved * from the canvas with their links (ui_combo_structure.js), as a card with its model's cover * (a combo kept from an output image shows that image, its model's cover in a corner badge) * and Put on canvas (notebook_canvas.js: a new group of nodes that follows the pointer). A card * opens the combo in its editor (ui_notebook_editor.js), and dragged onto the canvas becomes a * new group of nodes where it is dropped. New names one and opens it. */ import { translate as t } from './locales.js'; import { anomalousPrompt } from './ui_dialog.js'; import { freeComboName, listCombos } from './image_keep.js'; import { bindMaterialDrag } from './material_drag.js'; import { STRUCTURED } from './combo_slots.js'; import { chooseNewCombo, structuredComboMeta } from './ui_combo_structure.js'; import { outputThumbnail } from './ui_kept_toast.js'; function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function button(className, label, onClick) { const node = el('button', className, label); node.type = 'button'; node.onclick = onClick; return node; } const modelName = model => String(model?.metadata?.custom_name || model?.filename || '').replace(/\.[^.]+$/, ''); const withQuery = (url, query) => `${url}${url.includes('?') ? '&' : '?'}${query}`; function picture(src, className = '') { const img = el('img', className); img.loading = 'lazy'; img.alt = ''; img.src = src; img.onerror = () => img.remove(); return img; } /** * The main model's cover, as the model cards show it (a video's still first frame); with * `image` (the output it was kept from) that image, and the model's cover as a small badge. */ function cover(model, image = null) { const box = el('div', 'anomalous-combo-cover'); const url = model?.preview_url; const modelCover = url ? withQuery(url, /\.(mp4|webm)(?:&|$)/i.test(url) ? 'variant=poster' : 'variant=card') : ''; if (image?.filename) { box.classList.add('is-kept-image'); box.title = t('comboFromImage'); box.append(picture(outputThumbnail(image))); if (modelCover) box.append(picture(modelCover, 'anomalous-combo-cover-model')); return box; } if (modelCover) box.append(picture(modelCover)); return box; } function comboCard(owner, note) { const data = note.data || {}; const card = el('article', 'anomalous-combo-card'); card.tabIndex = 0; const open = () => owner.openCombo(note); card.onclick = open; card.onkeydown = (event) => { if (event.target === card && ['Enter', ' '].includes(event.key)) { event.preventDefault(); open(); } }; const body = el('div', 'anomalous-combo-body'); const loras = (data.loras || []).length; // A combo with its own node structure (ui_combo_structure.js) says its slots instead. const structured = data.kind === STRUCTURED ? structuredComboMeta(data) : null; body.append( el('strong', 'anomalous-combo-name', note.name), el('span', 'anomalous-combo-meta', structured ? structured.meta : [modelName(data.mainModel) || t('comboNoModel'), loras ? t('comboLoraCount', { count: loras }) : ''].filter(Boolean).join(' · ')), el('p', 'anomalous-combo-prompt', (structured ? structured.prompt : String(data.promptEn || '').trim()) || t('comboNoPrompt')), ); if (structured?.hint) body.append(el('span', 'anomalous-combo-hint', structured.hint)); const use = button('anomalous-btn-primary anomalous-combo-use', t('comboUse'), (event) => { event.stopPropagation(); owner.currentNotebook = note; owner.sendNotebookToCanvas(); }); use.title = t('comboUseHint'); body.append(use); card.append(cover(structured ? { preview_url: structured.cover } : data.mainModel, data.source_image), body); bindMaterialDrag(card, owner, { payload: () => ({ type: 'combo', filename: note.filename, dragHint: t('comboDragHint'), dragTargetHint: t('comboDragCanvas') }), accepts: () => false, dropOnCanvas: async (event, dragData, graph, position) => { if (!position) throw new Error('materialTargetChanged'); owner.currentNotebook = note; owner.sendNotebookToCanvas(position); }, }); return card; } /** Asks for a name, saves an empty combo and opens it. */ export async function newCombo(owner, loraNode = 'core') { const wanted = String(await anomalousPrompt(t('comboNewName'), '', t('comboNew')) || '').trim(); if (!wanted) return; const name = freeComboName(wanted, await listCombos()); const note = { filename: `${name}.json`, name, data: { name, baseModel: '', mainModel: null, loras: [], loraNode, promptEn: '', promptZh: '' }, }; owner.currentNotebook = note; if (await owner.saveCurrentNotebook()) owner.openCombo(note); } /** * Fills `host` with the search box, ? (what a combo is; shown by itself while there are none), * New and a card per combo matching the search. */ export async function renderComboList(owner, host) { const bar = el('div', 'anomalous-combo-bar'); const search = el('input', 'anomalous-combo-search'); search.type = 'search'; search.placeholder = t('comboSearch'); search.value = owner.comboQuery || ''; const lead = el('p', 'anomalous-combo-lead', t('comboLead')); lead.hidden = true; const help = button('anomalous-combo-help', '?', () => { lead.hidden = !lead.hidden; help.setAttribute('aria-expanded', String(!lead.hidden)); }); help.title = t('comboHelp'); help.setAttribute('aria-label', t('comboHelp')); help.setAttribute('aria-expanded', 'false'); bar.append(search, help, button('anomalous-btn-primary', t('comboNew'), () => void chooseNewCombo(owner, (loraNode) => newCombo(owner, loraNode))), lead); const grid = el('div', 'anomalous-combo-grid'); host.replaceChildren(bar, grid); grid.append(el('p', 'anomalous-combo-empty', t('loading'))); let combos; try { combos = await listCombos(); if (!combos.length) lead.hidden = false; } catch (error) { console.error('[AMB] Could not list combos:', error); grid.replaceChildren(el('p', 'anomalous-combo-empty', t('comboLoadError'))); return; } const draw = () => { const query = String(owner.comboQuery || '').trim().toLowerCase(); const shown = combos.filter(note => !query || `${note.name} ${modelName(note.data?.mainModel)} ${note.data?.promptEn || ''}`.toLowerCase().includes(query)); grid.replaceChildren(...shown.map(note => comboCard(owner, note))); if (!shown.length) grid.append(el('p', 'anomalous-combo-empty', t(query ? 'comboNoMatches' : 'comboEmpty'))); }; search.oninput = () => { owner.comboQuery = search.value; draw(); }; draw(); }