Types go by their usual names, base model chips narrow the list, the version stands apart from the name, and a model's versions are one card whose version opens the list to show another; a pick goes with it. The grouping, the families and the type names are the Models page's own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
16 KiB
JavaScript
341 lines
16 KiB
JavaScript
/**
|
||
* The materials picker's cards and detail pane (ui_ai_toolbox.js). A card is one node type,
|
||
* combo, workflow, prompt or model: a node shows itself in miniature (its sockets in ComfyUI's
|
||
* colours, outputs on the right, how many settings) under its source's badge; combos,
|
||
* workflows and models show their cover; a prompt shows its text. A press picks or unpicks,
|
||
* the star keeps it among the favourites. In the side panel a card is one line instead
|
||
* (rowCard: its badge or a small cover, its name and where it is from), so a short panel still
|
||
* lists many. The detail pane shows the card in focus in full, and for a combo or workflow can
|
||
* pick just the prompt inside it. A model says its version apart from its name; one with several
|
||
* is one card, whose version opens the list to show another (ctx.showVersion).
|
||
*/
|
||
|
||
import { translate as t } from './locales.js';
|
||
import { outputThumbnail } from './ui_kept_toast.js';
|
||
import { recipeAssetUrl } from './ui_recipe_media.js';
|
||
import { icon } from './ui_ai_icons.js';
|
||
import { socketColor, sourceBadge, standInCover } from './ui_ai_toolbox_icons.js';
|
||
import { versionLabel } from './ui_model_versions.js';
|
||
import { typeLabel } from './ui_model_types.js';
|
||
import { openPopupMenu } from './ui_popup_menu.js';
|
||
|
||
const SOCKETS_SHOWN = 4;
|
||
const withQuery = (url, query) => `${url}${url.includes('?') ? '&' : '?'}${query}`;
|
||
|
||
function el(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined && text !== null) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
/** The picture of a combo, workflow or model: its cover, a kept output image, or nothing. */
|
||
export function coverUrl(item) {
|
||
if (item.kind === 'workflows') {
|
||
if (item.cover_asset) return recipeAssetUrl(item.filename, item.cover_asset);
|
||
if (item.thumbnail) return item.thumbnail;
|
||
return item.image?.filename ? outputThumbnail(item.image) : '';
|
||
}
|
||
if (item.cover) return withQuery(item.cover, /\.(mp4|webm)(?:&|$)/i.test(item.cover) ? 'variant=poster' : 'variant=card');
|
||
return item.image?.filename ? outputThumbnail(item.image) : '';
|
||
}
|
||
|
||
function picture(item, className) {
|
||
const box = el('div', className);
|
||
const url = coverUrl(item);
|
||
const fallback = () => box.replaceChildren(standInCover(item.kind));
|
||
if (!url) {
|
||
fallback();
|
||
return box;
|
||
}
|
||
const img = el('img');
|
||
img.loading = 'lazy';
|
||
img.decoding = 'async';
|
||
img.alt = '';
|
||
img.src = url;
|
||
img.onerror = fallback;
|
||
box.append(img);
|
||
return box;
|
||
}
|
||
|
||
function socketRow(socket, side) {
|
||
const row = el('div', `anomalous-tb-socket is-${side}`);
|
||
const dot = el('span', 'anomalous-tb-dot');
|
||
dot.style.setProperty('--tb-dot', socketColor(socket.type));
|
||
row.title = `${socket.name} · ${socket.type}`;
|
||
row.append(dot, el('span', 'anomalous-tb-socket-name', socket.name));
|
||
return row;
|
||
}
|
||
|
||
/** A node in miniature: title bar, inputs left, outputs right, its settings counted. */
|
||
function nodePreview(item) {
|
||
const box = el('div', 'anomalous-tb-node');
|
||
const head = el('div', 'anomalous-tb-node-head');
|
||
head.append(sourceBadge(item.source, 18), el('span', 'anomalous-tb-node-title', item.title));
|
||
const body = el('div', 'anomalous-tb-node-body');
|
||
const left = el('div', 'anomalous-tb-node-col');
|
||
const right = el('div', 'anomalous-tb-node-col is-out');
|
||
left.append(...item.inputs.slice(0, SOCKETS_SHOWN).map(socket => socketRow(socket, 'in')));
|
||
if (item.inputs.length > SOCKETS_SHOWN) left.append(el('span', 'anomalous-tb-more', `+${item.inputs.length - SOCKETS_SHOWN}`));
|
||
right.append(...item.outputs.slice(0, SOCKETS_SHOWN).map(socket => socketRow(socket, 'out')));
|
||
if (item.outputs.length > SOCKETS_SHOWN) right.append(el('span', 'anomalous-tb-more', `+${item.outputs.length - SOCKETS_SHOWN}`));
|
||
body.append(left, right);
|
||
box.append(head, body);
|
||
if (item.page) {
|
||
box.append(el('div', 'anomalous-tb-node-foot', t('aiTbPageTool')));
|
||
} else if (item.settings.length) {
|
||
const foot = el('div', 'anomalous-tb-node-foot');
|
||
foot.append(icon('gear', 12), el('span', '', t('aiTbSettings', { count: item.settings.length })));
|
||
box.append(foot);
|
||
}
|
||
return box;
|
||
}
|
||
|
||
function tags(item) {
|
||
const row = el('div', 'anomalous-tb-tags');
|
||
const add = (text, kind = '') => row.append(el('span', `anomalous-tb-tag${kind ? ` is-${kind}` : ''}`, text));
|
||
if (item.kind === 'nodes') {
|
||
if (item.paid) add(t('aiTbPaid'), 'warn');
|
||
if (item.page) add(t('aiTbPageShort'), 'soft');
|
||
if (item.deprecated) add(t('aiTbDeprecated'), 'soft');
|
||
if (item.category) add(item.category.split('/').pop());
|
||
} else if (item.kind === 'combos') {
|
||
if (item.loras?.length) add(t('aiTbLoras', { count: item.loras.length }));
|
||
if (item.base) add(item.base);
|
||
} else if (item.kind === 'workflows') {
|
||
if (item.partial) add(t('aiTbPartial'), 'soft');
|
||
for (const tag of (item.tags || []).slice(0, 3)) add(tag);
|
||
} else if (item.kind === 'prompts') {
|
||
add(t(`aiTbPromptFrom_${item.source}`), item.source);
|
||
} else if (item.kind === 'models') {
|
||
add(typeLabel({ type: item.type }));
|
||
if (item.base) add(item.base);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
/** A model's version: a word, or with several a button listing them; nothing for a model not found on Civitai. */
|
||
function versionMark(item, ctx) {
|
||
const several = item.versions?.length > 1;
|
||
if (!several && !item.metadata?.version_name) return null;
|
||
if (!several) return el('span', 'anomalous-tb-version', item.metadata.version_name);
|
||
const mark = el('button', 'anomalous-tb-version is-several');
|
||
mark.type = 'button';
|
||
mark.title = t('aiTbVersions', { count: item.versions.length });
|
||
mark.append(el('span', 'anomalous-tb-version-name', versionLabel(item)), el('span', 'anomalous-tb-version-count', String(item.versions.length)), icon('chevronDown', 12));
|
||
mark.onclick = (event) => {
|
||
event.stopPropagation();
|
||
openPopupMenu(mark, item.versions.map(version => ({ label: versionLabel(version), checked: version === item, run: () => ctx.showVersion(item, version) })));
|
||
};
|
||
return mark;
|
||
}
|
||
|
||
/**
|
||
* One card. `ctx`: { picked(item), favorite(item), toggle(item), star(item), focus(item), peek(item) }.
|
||
* A press picks or unpicks; focus or the pointer peeks at it (the large window's detail pane),
|
||
* ⓘ opens its detail (the side panel's way: there the detail covers the cards).
|
||
*/
|
||
export function card(item, ctx) {
|
||
const node = el('div', `anomalous-tb-card is-${item.kind}${ctx.picked(item) ? ' is-picked' : ''}`);
|
||
node.tabIndex = 0;
|
||
node.setAttribute('role', 'button');
|
||
node.setAttribute('aria-pressed', String(ctx.picked(item)));
|
||
node.dataset.key = `${item.kind}|${item.id}`;
|
||
if (item.kind === 'nodes') {
|
||
node.append(nodePreview(item));
|
||
} else if (item.kind === 'prompts') {
|
||
const quote = el('div', 'anomalous-tb-quote');
|
||
quote.append(el('p', 'anomalous-tb-quote-text', item.positive || item.negative || ''));
|
||
node.append(quote);
|
||
} else {
|
||
node.append(picture(item, 'anomalous-tb-cover'));
|
||
}
|
||
const body = el('div', 'anomalous-tb-card-body');
|
||
if (item.kind !== 'nodes') body.append(el('div', 'anomalous-tb-card-title', item.short || item.title));
|
||
const version = item.kind === 'models' ? versionMark(item, ctx) : null;
|
||
if (version) body.append(version);
|
||
if (item.sub) body.append(el('div', 'anomalous-tb-card-sub', item.sub));
|
||
body.append(tags(item));
|
||
node.append(body);
|
||
return controls(node, item, ctx);
|
||
}
|
||
|
||
/** A card as one line, for the side panel: badge or small cover, name, where it is from. */
|
||
export function rowCard(item, ctx) {
|
||
const node = el('div', `anomalous-tb-card is-row is-${item.kind}${ctx.picked(item) ? ' is-picked' : ''}`);
|
||
node.tabIndex = 0;
|
||
node.setAttribute('role', 'button');
|
||
node.setAttribute('aria-pressed', String(ctx.picked(item)));
|
||
node.dataset.key = `${item.kind}|${item.id}`;
|
||
const lead = el('span', 'anomalous-tb-row-lead');
|
||
if (item.kind === 'nodes') lead.append(sourceBadge(item.source, 26));
|
||
else if (item.kind === 'prompts') lead.append(icon('prompts', 17));
|
||
else lead.append(picture(item, 'anomalous-tb-row-cover'));
|
||
const text = el('div', 'anomalous-tb-row-text');
|
||
const name = item.short || item.title || item.positive || item.id;
|
||
const from = item.kind === 'nodes' ? [item.category?.split('/').pop(), item.paid ? t('aiTbPaid') : ''].filter(Boolean).join(' · ')
|
||
: item.kind === 'prompts' && item.title ? item.positive : item.sub;
|
||
text.append(el('div', 'anomalous-tb-row-title', name));
|
||
const version = item.kind === 'models' ? versionMark(item, ctx) : null;
|
||
if (version) text.append(version);
|
||
else if (from) text.append(el('div', 'anomalous-tb-row-sub', from));
|
||
text.title = [name, from].filter(Boolean).join('\n');
|
||
node.append(lead, text);
|
||
return controls(node, item, ctx);
|
||
}
|
||
|
||
/** The tick, the star and ⓘ of a card, and what a press, a key and the pointer do. */
|
||
function controls(node, item, ctx) {
|
||
node.classList.toggle('is-in-pack', Boolean(ctx.inPack?.(item))); // its whole pack is picked
|
||
const check = el('span', 'anomalous-tb-check');
|
||
check.append(icon('check', 14));
|
||
const star = el('button', `anomalous-tb-star${ctx.favorite(item) ? ' is-on' : ''}`);
|
||
star.type = 'button';
|
||
star.title = t(ctx.favorite(item) ? 'aiToolboxUnfavorite' : 'aiToolboxFavorite');
|
||
star.append(icon(ctx.favorite(item) ? 'starOn' : 'star', 15));
|
||
star.onclick = (event) => {
|
||
event.stopPropagation();
|
||
void ctx.star(item);
|
||
};
|
||
const info = el('button', 'anomalous-tb-info');
|
||
info.type = 'button';
|
||
info.title = t('aiTbDetail');
|
||
info.append(icon('info', 15));
|
||
info.onclick = (event) => {
|
||
event.stopPropagation();
|
||
ctx.focus(item);
|
||
};
|
||
node.append(info, star, check);
|
||
node.onclick = () => ctx.toggle(item);
|
||
node.onkeydown = (event) => {
|
||
if (event.target === node && (event.key === 'Enter' || event.key === ' ')) {
|
||
event.preventDefault();
|
||
ctx.toggle(item);
|
||
}
|
||
};
|
||
node.onfocus = () => ctx.peek(item);
|
||
node.onpointerenter = () => ctx.peek(item);
|
||
return node;
|
||
}
|
||
|
||
/** Updates a drawn card after a pick or a star, without drawing the grid again. */
|
||
export function refreshCard(node, item, ctx) {
|
||
const on = ctx.picked(item);
|
||
node.classList.toggle('is-picked', on);
|
||
node.classList.toggle('is-in-pack', Boolean(ctx.inPack?.(item)));
|
||
node.setAttribute('aria-pressed', String(on));
|
||
const star = node.querySelector('.anomalous-tb-star');
|
||
if (star) {
|
||
const liked = ctx.favorite(item);
|
||
star.classList.toggle('is-on', liked);
|
||
star.title = t(liked ? 'aiToolboxUnfavorite' : 'aiToolboxFavorite');
|
||
star.replaceChildren(icon(liked ? 'starOn' : 'star', 15));
|
||
}
|
||
}
|
||
|
||
function section(title, children) {
|
||
const box = el('section', 'anomalous-tb-section');
|
||
box.append(el('h4', 'anomalous-tb-section-title', title), ...children);
|
||
return box;
|
||
}
|
||
|
||
function textBlock(text, className = '') {
|
||
return el('p', `anomalous-tb-text${className ? ` ${className}` : ''}`, text);
|
||
}
|
||
|
||
function socketList(sockets, side) {
|
||
const list = el('div', 'anomalous-tb-socket-list');
|
||
for (const socket of sockets) {
|
||
const row = socketRow(socket, side);
|
||
row.append(el('span', 'anomalous-tb-socket-type', socket.type));
|
||
list.append(row);
|
||
}
|
||
return list;
|
||
}
|
||
|
||
function settingList(settings) {
|
||
const list = el('div', 'anomalous-tb-setting-list');
|
||
for (const setting of settings) {
|
||
const row = el('div', 'anomalous-tb-setting');
|
||
const value = setting.type === 'choice' ? t('aiTbChoices', { count: setting.choices }) : setting.default !== undefined ? String(setting.default).slice(0, 40) : setting.type;
|
||
row.append(el('span', 'anomalous-tb-setting-name', setting.name), el('span', 'anomalous-tb-setting-value', value));
|
||
list.append(row);
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/**
|
||
* The detail pane for `item`. `ctx` as for card(), plus pickPrompt(id) and promptPicked(id)
|
||
* for the prompt inside a combo or workflow.
|
||
*/
|
||
export function detail(item, ctx) {
|
||
const box = el('div', `anomalous-tb-detail is-${item.kind}`);
|
||
const head = el('div', 'anomalous-tb-detail-head');
|
||
if (item.kind === 'nodes') {
|
||
head.append(sourceBadge(item.source, 40));
|
||
} else if (item.kind !== 'prompts') {
|
||
box.append(picture(item, 'anomalous-tb-detail-cover'));
|
||
}
|
||
const words = el('div', 'anomalous-tb-detail-words');
|
||
words.append(el('div', 'anomalous-tb-detail-title', item.title));
|
||
if (item.kind === 'nodes') {
|
||
words.append(el('div', 'anomalous-tb-detail-sub', [item.id, item.source.kind === 'pack' ? item.source.label : t(`aiTbSource_${item.source.kind}`)].join(' · ')));
|
||
} else if (item.sub) {
|
||
words.append(el('div', 'anomalous-tb-detail-sub', item.sub));
|
||
}
|
||
head.append(words);
|
||
box.append(head, tags(item));
|
||
const actions = el('div', 'anomalous-tb-detail-actions');
|
||
const take = el('button', `anomalous-tb-take${ctx.picked(item) ? ' is-on' : ''}`);
|
||
take.type = 'button';
|
||
take.append(icon(ctx.picked(item) ? 'check' : 'plus', 15), el('span', '', t(ctx.picked(item) ? 'aiTbPicked' : 'aiTbPick')));
|
||
take.onclick = () => ctx.toggle(item);
|
||
const star = el('button', `anomalous-tb-like${ctx.favorite(item) ? ' is-on' : ''}`);
|
||
star.type = 'button';
|
||
star.append(icon(ctx.favorite(item) ? 'starOn' : 'star', 15), el('span', '', t(ctx.favorite(item) ? 'aiToolboxUnfavorite' : 'aiToolboxFavorite')));
|
||
star.onclick = () => void ctx.star(item);
|
||
actions.append(take, star);
|
||
const inner = item.kind === 'combos' ? `combo:${item.id}` : item.kind === 'workflows' ? `workflow:${item.id}` : null;
|
||
if (inner && (item.prompt || item.negative)) {
|
||
const on = ctx.promptPicked(inner);
|
||
const prompt = el('button', `anomalous-tb-like${on ? ' is-on' : ''}`);
|
||
prompt.type = 'button';
|
||
prompt.append(icon('prompts', 15), el('span', '', t(on ? 'aiTbPromptPicked' : 'aiTbPickPrompt')));
|
||
prompt.onclick = () => ctx.pickPrompt(inner);
|
||
actions.append(prompt);
|
||
}
|
||
box.append(actions);
|
||
if (item.kind === 'nodes') {
|
||
if (item.description) box.append(textBlock(item.description));
|
||
if (item.category) box.append(section(t('aiTbCategory'), [textBlock(item.category.replaceAll('/', ' › '), 'is-soft')]));
|
||
if (item.page) box.append(textBlock(t('aiTbPageExplain'), 'is-soft'));
|
||
if (item.paid) box.append(textBlock(t('aiTbPaidExplain'), 'is-warn'));
|
||
if (item.inputs.length) box.append(section(t('aiTbInputs'), [socketList(item.inputs, 'in')]));
|
||
if (item.settings.length) box.append(section(t('aiTbSettingsTitle'), [settingList(item.settings)]));
|
||
if (item.outputs.length) box.append(section(t('aiTbOutputs'), [socketList(item.outputs, 'out')]));
|
||
} else if (item.kind === 'combos') {
|
||
if (item.model) box.append(section(t('aiTbMainModel'), [textBlock(item.model)]));
|
||
if (item.loras?.length) box.append(section(t('aiTbLoraList'), [textBlock(item.loras.join('\n'), 'is-list')]));
|
||
if (item.prompt) box.append(section(t('aiTbPositive'), [textBlock(item.prompt, 'is-prompt')]));
|
||
} else if (item.kind === 'workflows') {
|
||
if (item.notes) box.append(textBlock(item.notes));
|
||
const facts = [item.base, item.nodes ? t('aiTbNodeCount', { count: item.nodes }) : ''].filter(Boolean).join(' · ');
|
||
if (facts) box.append(textBlock(facts, 'is-soft'));
|
||
if (item.prompt) box.append(section(t('aiTbPositive'), [textBlock(item.prompt, 'is-prompt')]));
|
||
if (item.negative) box.append(section(t('aiTbNegative'), [textBlock(item.negative, 'is-prompt is-negative')]));
|
||
} else if (item.kind === 'prompts') {
|
||
if (item.positive) box.append(section(t('aiTbPositive'), [textBlock(item.positive, 'is-prompt')]));
|
||
if (item.negative) box.append(section(t('aiTbNegative'), [textBlock(item.negative, 'is-prompt is-negative')]));
|
||
} else if (item.kind === 'models') {
|
||
box.append(textBlock([item.folder, item.file].filter(Boolean).join('/'), 'is-soft'));
|
||
if (item.words?.length) box.append(section(t('aiTbWords'), [tagsOf(item.words)]));
|
||
}
|
||
return box;
|
||
}
|
||
|
||
function tagsOf(words) {
|
||
const row = el('div', 'anomalous-tb-tags');
|
||
for (const word of words) row.append(el('span', 'anomalous-tb-tag', word));
|
||
return row;
|
||
}
|