The stage, a run's node map at full size (pick the run; each run step has "see it large") and the materials picker share the side column; the switch sits in the window's title bar on the AI page, the shown one again hides the column, and each view keeps its state. The task header stays on top while the timeline scrolls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
496 lines
23 KiB
JavaScript
496 lines
23 KiB
JavaScript
/**
|
|
* "给 AI 的素材": what the user hands the AI to work with in a task (api/ai_toolbox.py). A side
|
|
* panel on the right of the screen that leaves the workbench and the canvas usable (the kinds as
|
|
* tabs, the cards in two columns, a card's detail over them), or, expanded, a large window: a
|
|
* search over everything, the kinds on the left (node types, combos, workflows,
|
|
* prompts, models) with how many are picked, a second column to narrow by source (each custom
|
|
* node pack with its badge, ComfyUI's own, paid API nodes), by where a prompt comes from or by
|
|
* model type, a grid of cards (ui_ai_toolbox_cards.js) drawn as it scrolls, the card in focus
|
|
* in full on the right, and the picks below with how strictly the AI keeps to them. Node types
|
|
* are the page's own (no waiting); the rest is read ahead and kept (ai_toolbox_data.js). The
|
|
* user's last choice of the two is remembered on this browser. On the AI page the side panel is a
|
|
* view of the workbench's side column (setToolboxHost, ui_ai_side_views.js); elsewhere it docks
|
|
* on the right of the screen. toolboxStep() is the timeline row of a pick.
|
|
*/
|
|
|
|
import { translate as t } from './locales.js';
|
|
import { anomalousAlert } from './ui_dialog.js';
|
|
import { aiWindow } from './ui_ai_connect.js';
|
|
import { KINDS, keptCatalog, loadCatalog, nodeItems, nodeSources, prefetchToolbox, searchItems } from './ai_toolbox_data.js';
|
|
import { card, coverUrl, detail, refreshCard } from './ui_ai_toolbox_cards.js';
|
|
import { icon, iconButton } from './ui_ai_icons.js';
|
|
import { emptyPicture, sourceBadge } from './ui_ai_toolbox_icons.js';
|
|
import { deliveryState } from './ui_ai_delivery.js';
|
|
|
|
const MODES = ['prefer', 'only'];
|
|
const CHUNK = 60;
|
|
const SEARCH_DELAY_MS = 120;
|
|
const LAYOUT_KEY = 'anomalous.aiToolboxLayout';
|
|
let openNow = null; // {task, body, close} of the picker on screen
|
|
let sideHost = null; // (body, expand) => close, or null when the AI page is not shown
|
|
|
|
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;
|
|
}
|
|
|
|
function button(className, children, onClick, title) {
|
|
const node = el('button', className);
|
|
node.type = 'button';
|
|
node.append(...[].concat(children).map(child => (typeof child === 'string' ? el('span', '', child) : child)));
|
|
node.onclick = onClick;
|
|
if (title) node.title = title;
|
|
return node;
|
|
}
|
|
|
|
async function postJson(url, body) {
|
|
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
return data;
|
|
}
|
|
|
|
const empty = () => Object.fromEntries(KINDS.map(kind => [kind, []]));
|
|
const total = box => KINDS.reduce((sum, kind) => sum + (box?.[kind]?.length || 0), 0);
|
|
const keyOf = (kind, id) => `${kind}|${id}`;
|
|
|
|
function savedLayout() {
|
|
try { return localStorage.getItem(LAYOUT_KEY) === 'full' ? 'full' : 'side'; } catch (_) { return 'side'; }
|
|
}
|
|
|
|
function keepLayout(layout) {
|
|
try { localStorage.setItem(LAYOUT_KEY, layout); } catch (_) { /* only not remembered */ }
|
|
}
|
|
|
|
/** `body` in a panel on the right of the screen (nothing else blocked; the full-screen browser makes
|
|
* room for it): its title, expand, close. Returns close. */
|
|
function dockPanel(title, body, expand) {
|
|
const panel = el('aside', 'anomalous-tb-dock');
|
|
panel.setAttribute('aria-label', title);
|
|
const head = el('div', 'anomalous-tb-dock-head');
|
|
const close = () => {
|
|
panel.remove();
|
|
document.body.classList.remove('anomalous-tb-docked');
|
|
};
|
|
document.body.classList.add('anomalous-tb-docked');
|
|
head.append(icon('sparkle', 16), el('div', 'anomalous-tb-dock-title', title),
|
|
iconButton('anomalous-tb-dock-btn', 'expand', t('aiTbExpand'), expand, { iconOnly: true }),
|
|
iconButton('anomalous-tb-dock-btn', 'close', t('close'), close, { iconOnly: true }));
|
|
panel.append(head, body);
|
|
document.body.append(panel);
|
|
return close;
|
|
}
|
|
|
|
let nodesKept = null; // the page's node types do not change while it is open
|
|
const pageNodes = () => (nodesKept ||= nodeItems(globalThis.LiteGraph?.registered_node_types));
|
|
|
|
/** What narrows a kind in the second column: a node's source, a prompt's origin, a model's type. */
|
|
function facetOf(item) {
|
|
if (item.kind === 'nodes') return { key: item.source.key, label: item.source.kind === 'pack' ? item.source.label : t(`aiTbSource_${item.source.kind}`), source: item.source };
|
|
if (item.kind === 'prompts') return { key: item.source, label: t(`aiTbPromptFrom_${item.source}`) };
|
|
if (item.kind === 'models') return { key: item.type, label: item.type };
|
|
return null;
|
|
}
|
|
|
|
function facets(items, kind) {
|
|
if (kind === 'nodes') {
|
|
return nodeSources(items).map(source => ({ key: source.key, count: source.count, source,
|
|
label: source.kind === 'pack' ? source.label : t(`aiTbSource_${source.kind}`) }));
|
|
}
|
|
const found = new Map();
|
|
for (const item of items) {
|
|
const facet = facetOf(item);
|
|
if (!facet) continue;
|
|
const entry = found.get(facet.key) || { ...facet, count: 0 };
|
|
entry.count++;
|
|
found.set(facet.key, entry);
|
|
}
|
|
return [...found.values()].sort((a, b) => b.count - a.count);
|
|
}
|
|
|
|
/** The window for task `task`; `changed()` after the picks were handed over. */
|
|
export async function openToolbox(task, changed = () => {}) {
|
|
if (openNow?.body.isConnected) {
|
|
const same = openNow.task === task.id;
|
|
openNow.close();
|
|
openNow = null;
|
|
if (same) return; // its button again: put it away
|
|
}
|
|
const fromTask = Boolean(task.toolbox);
|
|
const state = {
|
|
kind: 'nodes', query: '', filter: 'all', facet: null, focus: null, mode: task.toolbox?.mode || 'prefer',
|
|
picks: empty(), favorites: empty(), nodes: pageNodes(), catalog: keptCatalog(), error: '',
|
|
};
|
|
const byKey = new Map();
|
|
const index = () => {
|
|
byKey.clear();
|
|
for (const item of [...state.nodes, ...(state.catalog || [])]) byKey.set(keyOf(item.kind, item.id), item);
|
|
};
|
|
index();
|
|
try {
|
|
const response = await fetch('/anomalous/ai_toolbox/favorites');
|
|
if (response.ok) state.favorites = { ...empty(), ...(await response.json()).favorites };
|
|
} catch (_) { /* none yet; a star still saves */ }
|
|
for (const kind of KINDS) state.picks[kind] = [...((fromTask ? task.toolbox : state.favorites)[kind] || [])];
|
|
|
|
const body = el('div', 'anomalous-tb');
|
|
const top = el('div', 'anomalous-tb-top');
|
|
const searchBox = el('label', 'anomalous-tb-search');
|
|
const search = el('input');
|
|
search.type = 'search';
|
|
search.placeholder = t('aiTbSearch');
|
|
searchBox.append(icon('search', 18), search, el('kbd', 'anomalous-tb-kbd', '/'));
|
|
const filters = el('div', 'anomalous-tb-filters');
|
|
top.append(el('div', 'anomalous-tb-for', t('aiTbFor', { task: task.title || t('aiUntitled') })), searchBox, filters);
|
|
const rail = el('nav', 'anomalous-tb-rail');
|
|
const side = el('aside', 'anomalous-tb-facets');
|
|
const gridWrap = el('div', 'anomalous-tb-grid-wrap');
|
|
const gridHead = el('div', 'anomalous-tb-grid-head');
|
|
const scroller = el('div', 'anomalous-tb-scroller');
|
|
const grid = el('div', 'anomalous-tb-grid');
|
|
const sentinel = el('div', 'anomalous-tb-sentinel');
|
|
scroller.append(grid, sentinel);
|
|
gridWrap.append(gridHead, scroller);
|
|
const pane = el('div', 'anomalous-tb-pane');
|
|
const main = el('div', 'anomalous-tb-main');
|
|
main.append(rail, side, gridWrap, pane);
|
|
const tray = el('div', 'anomalous-tb-tray');
|
|
body.append(top, main, tray);
|
|
let closeFrame = () => {};
|
|
/** Shows the picker as a side panel or a large window, moving the same `body` (nothing is redrawn but the pane). */
|
|
function place(layout) {
|
|
const scrolled = scroller.scrollTop;
|
|
body.remove();
|
|
closeFrame();
|
|
keepLayout(layout);
|
|
body.classList.toggle('is-side', layout === 'side');
|
|
if (layout === 'side') {
|
|
closeFrame = sideHost?.(body, () => place('full')) || dockPanel(t('aiToolboxTitle'), body, () => place('full'));
|
|
} else {
|
|
closeFrame = aiWindow(t('aiToolboxTitle'), body);
|
|
const dialog = body.closest('.anomalous-ai-connect-dialog');
|
|
dialog?.classList.add('is-toolbox');
|
|
const shut = dialog?.querySelector('.anomalous-ai-connect-close');
|
|
shut?.before(iconButton('anomalous-tb-dock-btn', 'panel', t('aiTbDock'), () => place('side'), { iconOnly: true }));
|
|
}
|
|
scroller.scrollTop = scrolled;
|
|
drawPane();
|
|
}
|
|
const close = () => {
|
|
body.remove();
|
|
closeFrame();
|
|
if (openNow?.body === body) openNow = null;
|
|
};
|
|
|
|
const picked = item => state.picks[item.kind].includes(item.id);
|
|
const liked = item => state.favorites[item.kind].includes(item.id);
|
|
const cards = new Map(); // key -> drawn card
|
|
const ctx = {
|
|
picked, favorite: liked, focus: (item) => { state.focus = item; drawPane(); },
|
|
toggle: item => setPick(item.kind, item.id, !picked(item)),
|
|
star: item => setFavorite(item),
|
|
promptPicked: id => state.picks.prompts.includes(id),
|
|
pickPrompt: id => setPick('prompts', id, !state.picks.prompts.includes(id)),
|
|
};
|
|
|
|
function setPick(kind, id, on) {
|
|
state.picks[kind] = on ? [...new Set([...state.picks[kind], id])] : state.picks[kind].filter(item => item !== id);
|
|
const item = byKey.get(keyOf(kind, id));
|
|
const drawn = cards.get(keyOf(kind, id));
|
|
if (drawn && item) refreshCard(drawn, item, ctx);
|
|
drawRail();
|
|
drawFilters();
|
|
drawTray();
|
|
drawPane();
|
|
if (state.filter === 'picked') drawGrid();
|
|
}
|
|
|
|
async function setFavorite(item) {
|
|
const next = { ...state.favorites, [item.kind]: liked(item) ? state.favorites[item.kind].filter(id => id !== item.id) : [...state.favorites[item.kind], item.id] };
|
|
try {
|
|
state.favorites = { ...empty(), ...(await postJson('/anomalous/ai_toolbox/favorites', next)).favorites };
|
|
} catch (error) {
|
|
await anomalousAlert(error.message);
|
|
return;
|
|
}
|
|
const drawn = cards.get(keyOf(item.kind, item.id));
|
|
if (drawn) refreshCard(drawn, item, ctx);
|
|
drawFilters();
|
|
drawPane();
|
|
if (state.filter === 'liked') drawGrid();
|
|
}
|
|
|
|
const itemsOf = kind => (kind === 'nodes' ? state.nodes : (state.catalog || []).filter(item => item.kind === kind));
|
|
|
|
/** The cards of the current kind after the search, the filter and the second column. */
|
|
function shown() {
|
|
let items = searchItems(itemsOf(state.kind), state.query);
|
|
if (state.filter === 'picked') items = items.filter(picked);
|
|
if (state.filter === 'liked') items = items.filter(liked);
|
|
const beforeFacet = items;
|
|
if (state.facet) items = items.filter(item => facetOf(item)?.key === state.facet);
|
|
return { items, beforeFacet };
|
|
}
|
|
|
|
function drawRail() {
|
|
rail.replaceChildren(...KINDS.map((kind) => {
|
|
const count = state.picks[kind].length;
|
|
const matches = state.query ? searchItems(itemsOf(kind), state.query).length : null;
|
|
const node = button(`anomalous-tb-kind${state.kind === kind ? ' is-on' : ''}`, [icon(kind, 20), t(`aiToolboxKind_${kind}`)], () => {
|
|
state.kind = kind;
|
|
state.facet = null;
|
|
state.focus = null;
|
|
drawAll();
|
|
});
|
|
const meta = el('span', 'anomalous-tb-kind-meta');
|
|
if (matches !== null) meta.append(el('span', 'anomalous-tb-kind-matches', String(matches)));
|
|
if (count) meta.append(el('span', 'anomalous-tb-count', String(count)));
|
|
node.append(meta);
|
|
return node;
|
|
}));
|
|
}
|
|
|
|
function drawFilters() {
|
|
const picks = total(state.picks);
|
|
const likes = total(state.favorites);
|
|
filters.replaceChildren(...[['all', t('aiTbAll'), null], ['picked', t('aiTbPickedFilter'), picks], ['liked', t('aiTbLiked'), likes]].map(([key, label, count]) => {
|
|
const node = button(`anomalous-tb-filter${state.filter === key ? ' is-on' : ''}`, [key === 'liked' ? icon('star', 14) : key === 'picked' ? icon('check', 14) : icon('list', 14), label], () => {
|
|
state.filter = key;
|
|
drawAll();
|
|
});
|
|
if (count) node.append(el('span', 'anomalous-tb-count', String(count)));
|
|
return node;
|
|
}));
|
|
}
|
|
|
|
function drawFacets(beforeFacet) {
|
|
const list = facets(beforeFacet, state.kind);
|
|
side.hidden = list.length < 2;
|
|
if (side.hidden) return;
|
|
const row = (facet, label, count, badge) => {
|
|
const node = button(`anomalous-tb-facet${state.facet === facet ? ' is-on' : ''}`, [badge || icon('list', 16), label], () => {
|
|
state.facet = facet;
|
|
drawGrid();
|
|
});
|
|
node.title = label;
|
|
node.append(el('span', 'anomalous-tb-facet-count', String(count)));
|
|
return node;
|
|
};
|
|
side.replaceChildren(el('div', 'anomalous-tb-facets-title', t(state.kind === 'nodes' ? 'aiTbSources' : 'aiTbNarrow')),
|
|
row(null, t('aiTbEverything'), beforeFacet.length),
|
|
...list.map(facet => row(facet.key, facet.label, facet.count, facet.source ? sourceBadge(facet.source, 22) : null)));
|
|
}
|
|
|
|
let observer = null;
|
|
function drawGrid() {
|
|
observer?.disconnect();
|
|
cards.clear();
|
|
const { items, beforeFacet } = shown();
|
|
drawFacets(beforeFacet);
|
|
const facetLabel = state.facet ? (facets(beforeFacet, state.kind).find(facet => facet.key === state.facet)?.label || state.facet) : '';
|
|
gridHead.replaceChildren(el('span', 'anomalous-tb-grid-title', facetLabel || t(`aiToolboxKind_${state.kind}`)),
|
|
el('span', 'anomalous-tb-grid-count', t('aiTbCount', { count: items.length })));
|
|
if (state.kind !== 'nodes' && !state.catalog) {
|
|
grid.replaceChildren(...Array.from({ length: 8 }, () => el('div', 'anomalous-tb-card is-skeleton')));
|
|
if (state.error) grid.replaceChildren(el('div', 'anomalous-tb-note is-error', state.error));
|
|
return;
|
|
}
|
|
if (!items.length) {
|
|
grid.replaceChildren(el('div', 'anomalous-tb-note', state.filter === 'picked' ? t('aiToolboxNone') : t('aiToolboxEmptyList')));
|
|
return;
|
|
}
|
|
grid.replaceChildren();
|
|
let drawn = 0;
|
|
const more = () => {
|
|
const next = items.slice(drawn, drawn + CHUNK);
|
|
drawn += next.length;
|
|
grid.append(...next.map((item) => {
|
|
const node = card(item, ctx);
|
|
cards.set(keyOf(item.kind, item.id), node);
|
|
return node;
|
|
}));
|
|
if (drawn >= items.length) observer?.disconnect();
|
|
};
|
|
more();
|
|
observer = new IntersectionObserver(entries => { if (entries.some(entry => entry.isIntersecting)) more(); }, { root: scroller, rootMargin: '400px' });
|
|
observer.observe(sentinel);
|
|
scroller.scrollTop = 0;
|
|
}
|
|
|
|
function drawPane() {
|
|
const side = body.classList.contains('is-side');
|
|
pane.hidden = side && !state.focus; // beside the cards in the window; over them, while a card is open, in the side panel
|
|
if (state.focus) {
|
|
const back = button('anomalous-tb-pane-back', [icon('chevronLeft', 16), t('aiTbBack')], () => { state.focus = null; drawPane(); });
|
|
pane.replaceChildren(...(side ? [back] : []), detail(state.focus, ctx));
|
|
return;
|
|
}
|
|
const box = el('div', 'anomalous-tb-pane-empty');
|
|
box.append(emptyPicture(), el('div', 'anomalous-tb-pane-title', total(state.picks) ? t('aiTbPaneCount', { count: total(state.picks) }) : t('aiTbPaneHello')),
|
|
el('p', 'anomalous-tb-text is-soft', t('aiTbPaneTips')));
|
|
pane.replaceChildren(box);
|
|
}
|
|
|
|
function chip(kind, id) {
|
|
const item = byKey.get(keyOf(kind, id));
|
|
const inner = kind === 'prompts' && /^(combo|workflow):/.test(id);
|
|
const label = inner ? t('aiTbPromptOf', { name: id.replace(/^(combo|workflow):/, '') }) : item?.title || String(id).split(/[\\/:]/).pop();
|
|
const node = el('span', `anomalous-tb-chip is-${kind}`);
|
|
const url = item && ['combos', 'workflows', 'models'].includes(kind) ? coverUrl(item) : '';
|
|
if (url) {
|
|
const img = el('img');
|
|
img.src = url;
|
|
img.alt = '';
|
|
img.loading = 'lazy';
|
|
img.onerror = () => img.replaceWith(icon(kind, 14));
|
|
node.append(img);
|
|
} else if (kind === 'nodes' && item) {
|
|
node.append(sourceBadge(item.source, 16));
|
|
} else {
|
|
node.append(icon(kind, 14));
|
|
}
|
|
node.append(el('span', 'anomalous-tb-chip-name', label));
|
|
node.title = `${t(`aiToolboxKind_${kind}`)} · ${label}`;
|
|
node.append(button('anomalous-tb-chip-x', [icon('close', 12)], () => setPick(kind, id, false), t('aiTbRemove')));
|
|
return node;
|
|
}
|
|
|
|
function drawTray() {
|
|
const chips = el('div', 'anomalous-tb-chips');
|
|
for (const kind of KINDS) for (const id of state.picks[kind]) chips.append(chip(kind, id));
|
|
if (!chips.children.length) chips.append(el('span', 'anomalous-tb-tray-empty', t('aiTbTrayEmpty')));
|
|
const clear = button('anomalous-tb-clear', [icon('trash', 15)], () => {
|
|
state.picks = empty();
|
|
for (const [key, node] of cards) refreshCard(node, byKey.get(key), ctx);
|
|
drawRail();
|
|
drawFilters();
|
|
drawTray();
|
|
drawPane();
|
|
}, t('aiTbClear'));
|
|
clear.hidden = !total(state.picks);
|
|
const modes = el('div', 'anomalous-tb-modes');
|
|
modes.setAttribute('role', 'radiogroup');
|
|
for (const mode of MODES) {
|
|
const option = button(`anomalous-tb-mode${state.mode === mode ? ' is-on' : ''}`, [icon(mode === 'only' ? 'lock' : 'target', 16), t(`aiTbMode_${mode}`)], () => {
|
|
state.mode = mode;
|
|
drawTray();
|
|
}, t(`aiTbModeHint_${mode}`));
|
|
option.setAttribute('role', 'radio');
|
|
option.setAttribute('aria-checked', String(state.mode === mode));
|
|
modes.append(option);
|
|
}
|
|
const send = button('anomalous-tb-send', [icon('sparkle', 17), t('aiTbSend', { count: total(state.picks) })], async () => {
|
|
send.disabled = true;
|
|
try {
|
|
await postJson(`/anomalous/ai_tasks/${encodeURIComponent(task.id)}/toolbox`, { mode: state.mode, ...state.picks });
|
|
} catch (error) {
|
|
send.disabled = false;
|
|
await anomalousAlert(error.message);
|
|
return;
|
|
}
|
|
close();
|
|
changed();
|
|
});
|
|
tray.replaceChildren(chips, clear, modes, send);
|
|
}
|
|
|
|
function drawAll() {
|
|
drawRail();
|
|
drawFilters();
|
|
drawGrid();
|
|
drawPane();
|
|
}
|
|
|
|
let timer = null;
|
|
search.addEventListener('input', () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
state.query = search.value.trim();
|
|
state.facet = null;
|
|
drawRail();
|
|
drawGrid();
|
|
}, SEARCH_DELAY_MS);
|
|
});
|
|
body.addEventListener('keydown', (event) => {
|
|
if (event.key === '/' && event.target !== search) {
|
|
event.preventDefault();
|
|
search.focus();
|
|
}
|
|
});
|
|
place(savedLayout());
|
|
openNow = { task: task.id, body, close };
|
|
drawAll();
|
|
drawTray();
|
|
search.focus();
|
|
// The rest of the catalogue: what was kept is shown already; this brings it up to date.
|
|
loadCatalog({ fresh: Boolean(state.catalog) }).then((items) => {
|
|
state.catalog = items;
|
|
index();
|
|
drawRail();
|
|
drawTray();
|
|
if (state.kind !== 'nodes') drawGrid();
|
|
}).catch((error) => {
|
|
state.error = error.message;
|
|
if (state.kind !== 'nodes' && !state.catalog) drawGrid();
|
|
});
|
|
}
|
|
|
|
/** Where the side panel goes on the AI page: `host(body, expand)` puts it there and returns its close, or null. */
|
|
export function setToolboxHost(host) {
|
|
sideHost = host;
|
|
}
|
|
|
|
/** Whether the picker is open for task `taskId` (shown or behind another view of the side column). */
|
|
export const toolboxOpenFor = taskId => Boolean(openNow?.body.isConnected && openNow.task === taskId);
|
|
|
|
/** Puts the picker away (the workbench moved to another task). */
|
|
export function closeToolbox() {
|
|
openNow?.close();
|
|
openNow = null;
|
|
}
|
|
|
|
/** How many materials task `task` has; reads the catalogue ahead for the picker. */
|
|
export function toolboxCount(task) {
|
|
prefetchToolbox();
|
|
return total(task.toolbox);
|
|
}
|
|
|
|
/** A pick on the timeline: what was handed over, how strictly, and whether the AI has it. */
|
|
export function toolboxStep(step) {
|
|
const box = el('div', 'anomalous-tb-step');
|
|
const picks = step.toolbox || {};
|
|
if (!total(picks)) {
|
|
box.append(el('div', 'anomalous-ai-user-text', t('aiToolboxCleared')));
|
|
} else {
|
|
const mode = el('div', `anomalous-tb-step-mode is-${picks.mode === 'only' ? 'only' : 'prefer'}`);
|
|
mode.append(icon(picks.mode === 'only' ? 'lock' : 'target', 14), el('span', '', t(`aiToolboxStepMode_${picks.mode === 'only' ? 'only' : 'prefer'}`)));
|
|
const chips = el('div', 'anomalous-tb-chips is-static');
|
|
const known = new Map([...(picks.nodes?.length ? pageNodes() : []), ...(keptCatalog() || [])].map(item => [keyOf(item.kind, item.id), item]));
|
|
for (const kind of KINDS) {
|
|
for (const id of picks[kind] || []) {
|
|
const item = known.get(keyOf(kind, id));
|
|
const node = el('span', `anomalous-tb-chip is-${kind}`);
|
|
const url = item && kind !== 'prompts' ? coverUrl(item) : '';
|
|
if (url) {
|
|
const img = el('img');
|
|
img.src = url;
|
|
img.alt = '';
|
|
img.loading = 'lazy';
|
|
img.onerror = () => img.replaceWith(icon(kind, 14));
|
|
node.append(img);
|
|
} else {
|
|
node.append(kind === 'nodes' && item ? sourceBadge(item.source, 16) : icon(kind, 14));
|
|
}
|
|
const inner = kind === 'prompts' && /^(combo|workflow):/.test(id);
|
|
node.append(el('span', 'anomalous-tb-chip-name', inner ? t('aiTbPromptOf', { name: id.replace(/^(combo|workflow):/, '') }) : item?.title || String(id).split(/[\\/:]/).pop()));
|
|
chips.append(node);
|
|
}
|
|
}
|
|
box.append(mode, chips);
|
|
}
|
|
box.append(deliveryState(step));
|
|
return box;
|
|
}
|