Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_combos.js
T
DemonGatanjieuandClaude Opus 5.5 0dcc67f394 feat(combos): combos from the nodes picked on the canvas
A combo can now hold its own node structure: pick nodes on the canvas and
New -> From the nodes picked on the canvas keeps the model nodes (a
drop-down of model files) and the text nodes (a multiline text box by the
node's definition) with the links between them; other picked nodes and
links to them are left out, their ports stay open on the canvas.

Slots are per box, so a node with several model drop-downs has several; a
box fed by a link is no slot. Each model slot's folder is the one whose
files hold all its drop-down's model-file options (ComfyUI's /models
lists), never guessed from names. Every other box is stored by name and
put back untouched, in order (a count box adds its rows first).

The save dialog shows what is kept and dropped and lets slots be renamed
or unticked. The editor lists the slots: a picker of that folder's models
with covers, filtered by base model, and text boxes. Put on canvas makes
the nodes (missing node types and their packs are named before anything
is placed), fills the slots by name, links by port name and type, as one
Ctrl+Z step; a box or link that cannot be restored is reported, never
guessed. New -> Same structure as ... starts another combo from a saved
structure. The default combo is unchanged. MCP list_combos lists node
combos by slot and place_combo places them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:19:09 +08:00

138 lines
5.8 KiB
JavaScript

/**
* 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
* 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';
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}`;
/** The main model's cover, as the model cards show it (a video's still first frame). */
function cover(model) {
const box = el('div', 'anomalous-combo-cover');
const url = model?.preview_url;
if (!url) return box;
const img = el('img');
img.loading = 'lazy';
img.alt = '';
img.src = withQuery(url, /\.(mp4|webm)(?:&|$)/i.test(url) ? 'variant=poster' : 'variant=card');
img.onerror = () => img.remove();
box.append(img);
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')),
);
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), 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) {
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: [], promptEn: '', promptZh: '' },
};
owner.currentNotebook = note;
if (await owner.saveCurrentNotebook()) owner.openCombo(note);
}
/** Fills `host` with the search box, 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 || '';
bar.append(el('p', 'anomalous-combo-lead', t('comboLead')), search,
button('anomalous-btn-primary', t('comboNew'), () => void chooseNewCombo(owner, () => newCombo(owner))));
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();
} 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();
}