Files
DemonGatanjieuandClaude Opus 5.5 2836cceb7c A combo's page redone; the gallery's Kept view
The combo editor gets a head with its picture (See the picture), a large name with a
Rename button, chips of what it holds and Put on canvas; models show only what is chosen,
Change / Add open the grid of compatible ones. No Save button: everything saves itself
(a tag changed in the bilingual view now saves too). The gallery's ★ Kept lists only the
pictures kept as a workflow, combo or prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:52:03 +08:00

209 lines
9.0 KiB
JavaScript

/**
* The models part of a combo's editor (ui_notebook_editor.js): the base model, the main model
* and the LoRAs as short lists of what is chosen (cover, name, base model), with Change / Add
* opening a grid of the compatible ones (GET /anomalous/compatible_models, asked only when a
* grid opens). Choosing saves at once and tells the head (ui_combo_hero.js) to redraw its chips.
*/
import { translate as t } from './locales.js';
import { isUnetModel, UNLABELED_BASE_MODEL } from './notebook_canvas.js';
import { createLoraNodeChoice } from './ui_lora_node_choice.js';
import { comboModelCover, comboModelName } from './ui_combo_hero.js';
const FALLBACK_BASES = ['SD 1.5', 'SDXL', 'Pony', 'Illustrious', 'Flux.1'];
const MAIN_TYPES = 'checkpoints,unet,diffusion_models';
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;
}
function cover(model, className) {
const box = el('span', className);
const src = comboModelCover(model);
if (src) {
const img = el('img');
img.alt = '';
img.loading = 'lazy';
img.src = src;
img.onerror = () => img.remove();
box.append(img);
}
return box;
}
/** One chosen model: cover, name, its base model and file; `extra` controls at the end. */
function pickedRow(model, number, ...extra) {
const row = el('div', 'anomalous-combo-pick');
if (number) row.append(el('span', 'anomalous-combo-pick-number', String(number)));
const copy = el('span', 'anomalous-combo-pick-copy');
copy.append(el('span', 'anomalous-combo-pick-name', comboModelName(model)),
el('span', 'anomalous-combo-pick-detail', [model.metadata?.baseModel, model.filename].filter(Boolean).join(' · ')));
copy.title = model.filename || '';
row.append(cover(model, 'anomalous-combo-pick-cover'), copy, ...extra);
return row;
}
/** A grid of the compatible models of `types`, read once; `isPicked(model)`, `onPick(model)`. */
function pickerGrid(baseModel, types, { isPicked, onPick, number }) {
const grid = el('div', 'anomalous-nb-gallery-wrap anomalous-combo-picker');
let models = null;
const draw = () => {
if (!models) return;
grid.replaceChildren();
if (!models.length) {
grid.append(el('span', 'anomalous-combo-picker-empty', t('comboPickerNone')));
return;
}
for (const model of models) {
const picked = isPicked(model);
const card = button(`anomalous-nb-minicheck${picked ? ' selected' : ''}`, undefined, () => {
onPick(model);
draw();
});
card.title = model.filename;
const at = picked && number ? number(model) : 0;
if (at) card.append(el('span', 'anomalous-combo-picker-number', String(at)));
const thumb = cover(model, 'anomalous-combo-picker-cover');
if (!thumb.childElementCount) thumb.append(el('span', 'anomalous-nb-mini-empty', '?'));
card.append(thumb, el('span', 'anomalous-nb-minicheck-name', comboModelName(model)));
grid.append(card);
}
};
grid.load = () => {
if (models) return draw();
grid.replaceChildren(el('span', 'anomalous-combo-picker-empty', t('loading')));
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=${types}`)
.then(response => response.json())
.then((payload) => {
models = payload.models || [];
draw();
})
.catch(() => grid.replaceChildren(el('span', 'anomalous-combo-picker-empty', t('comboPickerFailed'))));
};
grid.redraw = draw;
grid.hidden = true;
return grid;
}
/** The base model drop-down: changing it empties the models (they belong to one base). */
function baseSelect(ctx, data) {
const select = el('select', 'anomalous-nb-select anomalous-combo-base-select');
const fill = (bases) => {
const list = [...new Set([...(data.baseModel ? [data.baseModel] : []), ...bases])];
select.replaceChildren(...list.map((base) => {
const option = el('option', '', base === UNLABELED_BASE_MODEL ? t('notebookUnlabeledBase') : base);
option.value = base;
option.selected = base === data.baseModel;
return option;
}));
};
fill(ctx.baseModelsCache || FALLBACK_BASES);
if (!ctx.baseModelsCache) {
fetch('/anomalous/base_models').then(response => response.json()).then((payload) => {
const bases = [...(payload.base_models || []), ...(payload.unlabeled ? [UNLABELED_BASE_MODEL] : [])];
if (!bases.length) return;
ctx.baseModelsCache = bases;
fill(bases);
}).catch(() => {});
}
select.onchange = () => {
data.baseModel = select.value;
data.mainModel = null;
data.loras = [];
ctx.saveCurrentNotebook();
ctx.renderNotebookEditor();
};
return select;
}
/** The models part for `data` (the combo's data; changed in place). */
export function createComboModels(ctx, data) {
if (!data.baseModel) data.baseModel = 'SDXL';
data.loras ||= [];
const card = el('section', 'anomalous-nb-card anomalous-combo-models');
card.id = 'amb-nb-sec-models';
const changed = () => {
ctx.saveCurrentNotebook();
ctx.updateNotebookModelsSummary?.();
};
const head = el('div', 'anomalous-combo-section-head');
const baseLabel = el('label', 'anomalous-combo-base');
baseLabel.append(el('span', '', t('comboBaseModel')), baseSelect(ctx, data));
head.append(el('h3', 'anomalous-combo-section-title', t('comboModelsTitle')), baseLabel);
// Main model: the one chosen, Change opens the grid; a pick closes it.
const mainTitle = el('h4', 'anomalous-combo-sub-title', t('mainModel'));
const mainList = el('div', 'anomalous-combo-picks');
const unetHint = el('div', 'anomalous-nb-unet-hint', t('notebookUnetNeedsTextEncoder'));
const mainGrid = pickerGrid(data.baseModel, MAIN_TYPES, {
isPicked: model => data.mainModel?.filename === model.filename,
onPick: (model) => {
data.mainModel = data.mainModel?.filename === model.filename ? null : model;
mainGrid.hidden = Boolean(data.mainModel);
changed();
drawMain();
},
});
const drawMain = () => {
const toggle = button('anomalous-combo-pick-action', '', () => {
mainGrid.hidden = !mainGrid.hidden;
if (!mainGrid.hidden) mainGrid.load();
drawMain();
});
toggle.textContent = !mainGrid.hidden ? t('comboPickerClose') : data.mainModel ? t('comboChange') : t('comboChoose');
mainList.replaceChildren(data.mainModel
? pickedRow(data.mainModel, 0, toggle)
: Object.assign(el('div', 'anomalous-combo-pick is-empty'), { textContent: t('comboNoMainModel') }));
if (!data.mainModel) mainList.firstChild.append(toggle);
unetHint.hidden = !isUnetModel(data.mainModel);
};
// LoRAs: the chosen ones in order (✕ takes one out), Add opens the grid; picks toggle.
const loraTitle = el('h4', 'anomalous-combo-sub-title', t('loras'));
const loraList = el('div', 'anomalous-combo-picks');
const loraGrid = pickerGrid(data.baseModel, 'loras', {
isPicked: model => data.loras.some(lora => lora.filename === model.filename),
number: model => data.loras.findIndex(lora => lora.filename === model.filename) + 1,
onPick: (model) => {
const at = data.loras.findIndex(lora => lora.filename === model.filename);
if (at >= 0) data.loras.splice(at, 1);
else data.loras.push(model);
changed();
drawLoras();
},
});
const drawLoras = () => {
loraTitle.textContent = data.loras.length ? `${t('loras')} · ${data.loras.length}` : t('loras');
const rows = data.loras.map((lora, index) => pickedRow(lora, index + 1, button('anomalous-combo-pick-remove', '✕', () => {
data.loras.splice(index, 1);
changed();
drawLoras();
loraGrid.redraw();
})));
rows.forEach(row => { row.lastChild.title = t('comboRemoveLora'); row.lastChild.setAttribute('aria-label', t('comboRemoveLora')); });
const add = button('anomalous-combo-add', loraGrid.hidden ? `+ ${t('comboAddLora')}` : t('comboPickerClose'), () => {
loraGrid.hidden = !loraGrid.hidden;
if (!loraGrid.hidden) loraGrid.load();
drawLoras();
});
loraList.replaceChildren(...rows, add);
};
drawMain();
drawLoras();
card.append(head, mainTitle, mainList, mainGrid, unetHint, loraTitle,
createLoraNodeChoice(data, () => ctx.saveCurrentNotebook()), loraList, loraGrid);
return card;
}