Every answer, message, set of marks and handed picture shows a pill: sent and waiting, then the AI has it with the time it did (delivered_at). Panel questions can be skipped; the AI reads skipped: true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
483 lines
22 KiB
JavaScript
483 lines
22 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.
|
|
* toolboxButton() is the entry in a task's header, toolboxStep() 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
|
|
|
|
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 = 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();
|
|
});
|
|
}
|
|
|
|
/** The entry in a task's header: an icon, "Materials", and how many the task has. Reads the catalogue ahead. */
|
|
export function toolboxButton(task, changed) {
|
|
prefetchToolbox();
|
|
const count = total(task.toolbox);
|
|
const node = button(`anomalous-tb-open${count ? ' has-picks' : ''}`, [icon('sparkle', 15), t('aiToolbox')], () => void openToolbox(task, changed), t('aiToolboxTitle'));
|
|
if (count) node.append(el('span', 'anomalous-tb-count', String(count)));
|
|
return node;
|
|
}
|
|
|
|
/** 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;
|
|
}
|