Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_combo_structure.js
T
DemonGatanjieuandClaude Opus 5.5 4c6a4806d2 feat(combos): right-click "Save as combo" on the canvas
The canvas's node menu (the frontend's getNodeMenuItems hook) offers
"Save as combo" for the picked nodes, or the right-clicked node when it
is not among them, when any of them is a model or text node; it opens
the same save dialog without opening the browser first, and the browser
shows the new combo afterwards.

Values other than the slots stay as they were saved (agreed): a combo
always puts down exactly what was saved; other values mean another combo.

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

378 lines
18 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.
/**
* Combos with a node structure (combo_structure.js), on the Combos page: saving the canvas's
* picked nodes as one (a dialog listing what is kept and its slots, which can be renamed or
* dropped), its editor (a row per slot: a model chosen from that slot's models folder, filtered
* by base model; a text box), and "New" offering the default combo, the picked nodes, or the
* structure of a combo already saved. The canvas's node menu has "Save as combo" too
* (browser_entry.js). Putting one on the canvas is notebook_canvas.js.
*/
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { STRUCTURED, isModelWidget, missingTypes, structureKey, structureSummary } from './combo_slots.js';
import { typeTakesPrompt } from './prompt_boxes.js';
import { captureStructure } from './combo_structure.js';
import { freeComboName, listCombos } from './image_keep.js';
import { anomalousAlert, anomalousPrompt } from './ui_dialog.js';
import { typeLabel } from './ui_model_types.js';
const SAVE_WAIT_MS = 500;
const MAX_PATHS = 4; // models folders a type may have (extra_model_paths)
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 fileName = (value) => String(value || '').split(/[\\/]/).pop();
const shortName = (value) => fileName(value).replace(/\.[^.]+$/, '');
const isNone = (value) => /^\s*(none)?\s*$/i.test(String(value ?? ''));
const modelRel = (model) => [String(model.subfolder || '').replace(/^\/+|\/+$/g, ''), model.filename].filter(Boolean).join('/');
const sameRel = (a, b) => String(a || '').replace(/\\/g, '/') === String(b || '').replace(/\\/g, '/');
/** A slot's default name: the model type ("LoRA 2") or the prompt's role. */
export function slotLabel({ kind, folder, index, role }) {
if (kind === 'text') return t(role === 'negative' ? 'comboSlotNegative' : role === 'positive' ? 'comboSlotPositive' : 'comboSlotText');
const name = folder ? typeLabel({ type: folder }) : t('comboSlotModel');
return index ? `${name} ${index}` : name;
}
/** An overlay with `dialog` in it; Esc or a click beside it calls `onClose`. */
function overlay(dialog, onClose) {
const back = el('div', 'anomalous-dialog-overlay anomalous-structure-overlay');
back.append(dialog);
const close = () => {
back.remove();
document.removeEventListener('keydown', onKey, true);
onClose?.();
};
const onKey = (event) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
close();
};
back.onclick = (event) => { if (event.target === back) close(); };
document.addEventListener('keydown', onKey, true);
document.body.append(back);
return close;
}
/** The cover of a model chosen elsewhere (canvas), looked up by its file name; '' when unknown. */
async function coverOf(value) {
if (isNone(value)) return '';
try {
const data = await (await fetch(`/anomalous/find_model?search=${encodeURIComponent(fileName(value))}`)).json();
return data?.model && fileName(data.model.filename) === fileName(value) ? data.model.preview_url || '' : '';
} catch {
return '';
}
}
async function saveAndOpen(owner, note) {
owner.currentNotebook = note;
if (!(await owner.saveCurrentNotebook())) return;
owner.show?.(); // from the canvas's menu the browser may be closed
await owner.openCombo(note);
}
/** The nodes a canvas menu acts on: the picked ones when the node is one of them, else that node. */
export function menuNodes(node) {
const picked = Object.values(app.canvas?.selected_nodes || {});
return picked.includes(node) ? picked : [node];
}
/** Whether a combo could keep any of these nodes (a model drop-down or a prompt box). */
export function hasComboNodes(nodes) {
return nodes.some(node => (node.widgets || []).some(isModelWidget) || typeTakesPrompt(node.type));
}
/** Saves the canvas's picked nodes (or `nodes`, from the canvas menu) as a new combo, after a dialog showing what is kept. */
export async function saveSelectionAsCombo(owner, nodes = null) {
const picked = nodes || Object.values(app.canvas?.selected_nodes || {});
if (!picked.length) {
await anomalousAlert(t('comboFromSelectionNone'));
return;
}
let captured;
try {
captured = await captureStructure(picked, { labelFor: slotLabel });
} catch (error) {
console.error('[AMB] Combo: reading the picked nodes failed.', error);
await anomalousAlert(t('comboFromSelectionFailed'));
return;
}
const { structure, values, dropped } = captured;
if (!structure) {
await anomalousAlert(t('comboFromSelectionNothing', { count: dropped.length }));
return;
}
const name = freeComboName(t('comboStructureName'), await listCombos());
const dialog = el('div', 'anomalous-structure-dialog');
dialog.setAttribute('role', 'dialog');
const nameInput = el('input', 'anomalous-structure-name');
nameInput.value = name;
dialog.append(el('h3', 'anomalous-structure-title', t('comboFromSelectionTitle')), nameInput,
el('p', 'anomalous-structure-note', t('comboFromSelectionKept', { count: structure.nodes.length, nodes: structureSummary(structure) })));
if (dropped.length) {
dialog.append(el('p', 'anomalous-structure-note is-muted', t('comboFromSelectionDropped', {
count: dropped.length, nodes: dropped.map(node => node.title || node.type).join('、'),
})));
}
const rows = el('div', 'anomalous-structure-slots');
const choices = structure.slots.map((slot) => {
const row = el('label', 'anomalous-structure-slot');
const keep = el('input');
keep.type = 'checkbox';
keep.checked = true;
const label = el('input', 'anomalous-structure-slot-label');
label.value = slot.label;
const node = structure.nodes.find(item => item.key === slot.node);
const where = `${node?.title || node?.name || node?.type || ''} · ${slot.widget}`;
const now = slot.kind === 'model' ? fileName(values[slot.id]) || t('comboSlotEmpty') : String(values[slot.id] || '').slice(0, 60) || t('comboSlotEmpty');
row.append(keep, label, el('span', 'anomalous-structure-slot-where', `${where} — ${now}`));
rows.append(row);
return { slot, keep, label };
});
dialog.append(el('p', 'anomalous-structure-note', t('comboFromSelectionSlots')), rows);
if (!structure.slots.length) rows.append(el('p', 'anomalous-structure-note is-muted', t('comboFromSelectionNoSlots')));
const footer = el('div', 'anomalous-structure-footer');
const close = overlay(dialog);
footer.append(button('anomalous-scan-secondary', t('dialogCancel'), close), button('anomalous-scan-primary', t('comboFromSelectionSave'), async () => {
const kept = choices.filter(choice => choice.keep.checked);
const slots = kept.map(({ slot, label }) => ({ ...slot, label: label.value.trim() || slot.label }));
const keptValues = Object.fromEntries(slots.map(slot => [slot.id, values[slot.id]]));
const firstModel = slots.find(slot => slot.kind === 'model' && !isNone(keptValues[slot.id]));
const covers = firstModel ? { [firstModel.id]: await coverOf(keptValues[firstModel.id]) } : {};
const finalName = freeComboName(nameInput.value.trim() || name, await listCombos());
close();
await saveAndOpen(owner, {
filename: `${finalName}.json`, name: finalName,
data: { name: finalName, kind: STRUCTURED, structure: { ...structure, slots }, values: keptValues, covers },
});
}));
dialog.append(footer);
nameInput.focus();
nameInput.select();
}
/** The models of a folder type, from every models folder of that type. */
async function modelsOf(folder) {
const lists = await Promise.all(Array.from({ length: MAX_PATHS }, async (_unused, index) => {
try {
const data = await (await fetch(`/anomalous/type_models?type=${encodeURIComponent(folder)}&path_idx=${index}`)).json();
return data.models || [];
} catch {
return [];
}
}));
return lists.flat();
}
/** Picks a model for `slot`: its folder's models with their covers, by base model and name. Resolves { rel, cover } or null. */
function pickModel(slot, current) {
return new Promise((resolve) => {
const dialog = el('div', 'anomalous-structure-dialog is-picker');
const search = el('input', 'anomalous-structure-search');
search.type = 'search';
search.placeholder = t('comboPickSearch');
const bases = el('div', 'anomalous-structure-bases');
const grid = el('div', 'anomalous-structure-models');
grid.append(el('p', 'anomalous-structure-note', t('loading')));
dialog.append(el('h3', 'anomalous-structure-title', t('comboPickTitle', { slot: slot.label })), search, bases, grid);
let done = false;
const finish = (value) => {
if (done) return;
done = true;
close();
resolve(value);
};
const close = overlay(dialog, () => finish(null));
modelsOf(slot.folder).then((models) => {
const baseOf = (model) => String(model.metadata?.baseModel || '').trim();
const known = [...new Set(models.map(baseOf).filter(Boolean))].sort();
let base = baseOf(models.find(model => sameRel(modelRel(model), current)) || {}) || '';
const draw = () => {
bases.replaceChildren(...['', ...known].map(name => {
const chip = button(`anomalous-structure-base${name === base ? ' is-active' : ''}`, name || t('comboPickAllBases'), () => {
base = name;
draw();
});
return chip;
}));
const words = search.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const shown = models.filter(model => (!base || baseOf(model) === base)
&& words.every(word => `${model.filename} ${model.metadata?.custom_name || ''} ${model.metadata?.name || ''}`.toLowerCase().includes(word)));
grid.replaceChildren(...shown.map((model) => {
const card = button(`anomalous-structure-model${sameRel(modelRel(model), current) ? ' is-current' : ''}`, '', () => {
finish({ rel: modelRel(model), cover: model.preview_url || '' });
});
if (model.preview_url && !/\.(mp4|webm)(?:&|$)/i.test(model.preview_url)) {
const img = el('img');
img.loading = 'lazy';
img.alt = '';
img.src = model.preview_url;
card.append(img);
}
card.append(el('span', 'anomalous-structure-model-name', model.metadata?.custom_name || shortName(model.filename)));
// Base model and folder: two files of one name in different folders are told apart.
const folder = String(model.subfolder || '').replace(/^\/+|\/+$/g, '');
const facts = [baseOf(model), folder].filter(Boolean).join(' · ');
if (facts) card.append(el('span', 'anomalous-structure-model-base', facts));
card.title = modelRel(model);
return card;
}));
if (!shown.length) grid.append(el('p', 'anomalous-structure-note', t(models.length ? 'comboPickNoMatch' : 'comboPickNone')));
};
search.oninput = draw;
draw();
search.focus();
});
});
}
/** The editor of a combo with a structure: one row per slot, and what the structure needs. */
export function renderStructuredCombo(owner, host, note, toolbar) {
const data = note.data;
data.values ||= {};
data.covers ||= {};
const structure = data.structure || { nodes: [], links: [], slots: [] };
let timer = 0;
const save = (now = false) => {
clearTimeout(timer);
if (now) void owner.saveCurrentNotebook();
else timer = setTimeout(() => void owner.saveCurrentNotebook(), SAVE_WAIT_MS);
};
const card = el('section', 'anomalous-structure-card');
const rows = el('div', 'anomalous-structure-rows');
const drawModel = (slot) => {
const row = el('div', 'anomalous-structure-row is-model');
const value = data.values[slot.id];
const thumb = el('div', 'anomalous-structure-thumb');
if (data.covers[slot.id]) {
const img = el('img');
img.alt = '';
img.src = data.covers[slot.id];
img.onerror = () => img.remove();
thumb.append(img);
}
const copy = el('div', 'anomalous-structure-copy');
copy.append(el('span', 'anomalous-structure-label', slot.label),
el('strong', 'anomalous-structure-value', isNone(value) ? t('comboSlotEmpty') : shortName(value)));
if (!isNone(value)) copy.title = value;
const actions = el('div', 'anomalous-structure-actions');
if (slot.folder) {
actions.append(button('anomalous-scan-row-btn is-main', t('comboPick'), async () => {
const chosen = await pickModel(slot, value);
if (!chosen) return;
data.values[slot.id] = chosen.rel;
data.covers[slot.id] = chosen.cover;
save(true);
row.replaceWith(drawModel(slot));
}));
} else {
copy.append(el('span', 'anomalous-structure-note is-muted', t('comboSlotUnknownFolder')));
}
if (slot.optional && !isNone(value)) {
actions.append(button('anomalous-scan-row-btn', t('comboSlotClear'), () => {
data.values[slot.id] = 'None';
delete data.covers[slot.id];
save(true);
row.replaceWith(drawModel(slot));
}));
}
row.append(thumb, copy, actions);
return row;
};
const drawText = (slot) => {
const row = el('label', 'anomalous-structure-row is-text');
const box = el('textarea', 'anomalous-structure-text');
box.value = String(data.values[slot.id] ?? '');
box.rows = slot.role === 'negative' ? 3 : 5;
box.oninput = () => {
data.values[slot.id] = box.value;
save();
};
row.append(el('span', 'anomalous-structure-label', slot.label), box);
return row;
};
rows.append(...structure.slots.map(slot => (slot.kind === 'text' ? drawText(slot) : drawModel(slot))));
if (!structure.slots.length) rows.append(el('p', 'anomalous-structure-note', t('comboFromSelectionNoSlots')));
const about = el('div', 'anomalous-structure-about');
about.append(el('p', 'anomalous-structure-note is-muted', t('comboStructureNodes', { nodes: structureSummary(structure) })));
const packs = [...new Set(structure.nodes.map(item => item.pack).filter(Boolean))];
if (packs.length) about.append(el('p', 'anomalous-structure-note is-muted', t('comboStructurePacks', { packs: packs.join('、') })));
const missing = missingTypes(structure);
if (missing.length) {
about.append(el('p', 'anomalous-structure-note is-bad', t('comboStructureMissing', {
nodes: missing.map(item => (item.pack ? `${item.type}(${item.pack})` : item.type)).join('、'),
})));
}
card.append(el('h4', 'anomalous-structure-heading', t('comboSlots')), rows, about);
host.replaceChildren(toolbar, card);
}
/** "New": the default combo, the canvas's picked nodes, or a structure a saved combo has. */
export async function chooseNewCombo(owner, newDefault) {
const combos = await listCombos().catch(() => []);
const structures = [];
const seen = new Set();
for (const note of combos) {
if (note.data?.kind !== STRUCTURED || !note.data.structure) continue;
const key = structureKey(note.data.structure);
if (seen.has(key)) continue;
seen.add(key);
structures.push(note);
}
const picked = Object.keys(app.canvas?.selected_nodes || {}).length;
const dialog = el('div', 'anomalous-structure-dialog');
const close = overlay(dialog);
const option = (title, hint, onPick, disabled = false) => {
const item = button('anomalous-structure-option', '', () => {
close();
void onPick();
});
item.disabled = disabled;
item.append(el('strong', '', title), el('span', 'anomalous-structure-note is-muted', hint));
return item;
};
dialog.append(el('h3', 'anomalous-structure-title', t('comboNew')),
option(t('comboNewDefault'), t('comboNewDefaultHint'), newDefault),
option(t('comboNewFromSelection'), picked ? t('comboNewFromSelectionHint', { count: picked }) : t('comboNewFromSelectionEmpty'),
() => saveSelectionAsCombo(owner), !picked));
for (const note of structures) {
dialog.append(option(t('comboNewLike', { name: note.name }), structureSummary(note.data.structure), async () => {
const wanted = String(await anomalousPrompt(t('comboNewName'), '', t('comboNew')) || '').trim();
if (!wanted) return;
const name = freeComboName(wanted, await listCombos());
const copy = JSON.parse(JSON.stringify(note.data));
await saveAndOpen(owner, { filename: `${name}.json`, name, data: { ...copy, name } });
}));
}
dialog.append(el('div', 'anomalous-structure-footer'));
dialog.lastChild.append(button('anomalous-scan-secondary', t('dialogCancel'), close));
}
/** The card's line for a combo with a structure: its slots in short. */
export function structuredComboMeta(data) {
const slots = data.structure?.slots || [];
const models = slots.filter(slot => slot.kind === 'model' && !isNone(data.values?.[slot.id])).map(slot => shortName(data.values[slot.id]));
const text = slots.find(slot => slot.kind === 'text' && String(data.values?.[slot.id] || '').trim());
return {
cover: Object.values(data.covers || {}).find(Boolean) || '',
meta: models.length ? models.slice(0, 3).join(' · ') + (models.length > 3 ? ' …' : '') : t('comboNoModel'),
prompt: text ? String(data.values[text.id]).trim() : '',
};
}