- ui_ai_handoff.js: "Hand to AI" on a model's page, the image inspector (✦) and combo cards. The user says what they want (prefilled), Anomalous starts a waiting task with `about` (the model_id, the output image, the combo's name; checked in ai_tasks.start_task) and shows the sentence to send the AI; begin_task hands `about` to the AI so it needs no search. - The AI windows sit above the image inspector they can open from; going to the AI page from there closes the inspector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
7.5 KiB
JavaScript
176 lines
7.5 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
|
|
* (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; ✦ hands it to an AI (ui_ai_handoff.js). 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';
|
|
import { handComboToAi } from './ui_ai_handoff.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');
|
|
const hand = button('anomalous-combo-ai', `✦ ${t('aiHand')}`, (event) => {
|
|
event.stopPropagation();
|
|
void handComboToAi(owner, note);
|
|
});
|
|
hand.title = t('aiHandHint');
|
|
const buttons = el('div', 'anomalous-combo-buttons');
|
|
buttons.append(use, hand);
|
|
body.append(buttons);
|
|
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();
|
|
}
|