/** * "给 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, a line per item, 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 custom node pack can be picked whole there: every node type in it; in the side * panel that column is one "Source: All ▾" button opening a list to search), 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 { resolveLocale, translate as t } from './locales.js'; import { anomalousAlert } from './ui_dialog.js'; import { aiWindow } from './ui_ai_connect.js'; import { KINDS, PICK_KINDS, keptCatalog, loadCatalog, nodeItems, nodeSources, prefetchToolbox, searchItems, wayTitle } from './ai_toolbox_data.js'; import { card, coverUrl, detail, refreshCard, rowCard } 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'; import { closeFacetMenu, toggleFacetMenu } from './ui_ai_facet_menu.js'; import { baseFamily, countBases } from './model_bases.js'; import { groupByPage, pageId, versionId } from './model_versions.js'; import { chosenVersion } from './ui_model_versions.js'; import { typeLabel } from './ui_model_types.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(PICK_KINDS.map(kind => [kind, []])); const total = box => PICK_KINDS.reduce((sum, kind) => sum + (box?.[kind]?.length || 0), 0); const keyOf = (kind, id) => `${kind}|${id}`; /** The custom node packs among `nodes`, as items to pick whole (and to name with "@", ui_ai_mention.js). */ export const packItems = nodes => nodeSources(nodes).filter(source => source.kind === 'pack') .map(source => ({ kind: 'packs', id: source.key, title: t('aiTbPackName', { name: source.label || source.key }), source, count: source.count })); 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 export 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: typeLabel({ type: 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', base: null, versions: new Map(), // the base model chip on; page id -> the version a card shows picks: empty(), favorites: empty(), nodes: pageNodes(), catalog: keptCatalog(), error: '', }; const byKey = new Map(); const index = () => { byKey.clear(); for (const item of [...state.nodes, ...packItems(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 PICK_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 baseBar = el('div', 'anomalous-model-bases anomalous-tb-bases'); 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); const packBar = el('div', 'anomalous-tb-pack'); gridWrap.append(gridHead, baseBar, packBar, 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); const changed = body.classList.contains('is-side') !== (layout === 'side'); 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 })); } if (changed && grid.childElementCount) drawGrid(); // lines in the side panel, cards in the window else 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 inPack = item => item.kind === 'nodes' && state.picks.packs.includes(item.source?.key); const liked = item => state.favorites[item.kind].includes(item.id); const cards = new Map(); // key -> drawn card const ctx = { picked, inPack, favorite: liked, focus: (item) => { state.focus = item; drawPane(); }, peek: (item) => { if (!body.classList.contains('is-side')) ctx.focus(item); }, // the side panel's detail would cover the cards 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)), showVersion, }; /** A model's card shows `version` instead of `item`; the pick, if there was one, goes with it. */ function showVersion(item, version) { state.versions.set(pageId(item), versionId(version)); version.versions = item.versions; const old = cards.get(keyOf('models', item.id)); const drawn = (body.classList.contains('is-side') ? rowCard : card)(version, ctx); old?.replaceWith(drawn); cards.delete(keyOf('models', item.id)); cards.set(keyOf('models', version.id), drawn); if (picked(item)) { setPick('models', item.id, false); setPick('models', version.id, true); } if (state.focus === item) ctx.focus(version); } 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); if (kind === 'packs') { for (const [key, node] of cards) refreshCard(node, byKey.get(key), ctx); drawFacets(shown().beforeFacet); drawPackBar(); } 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); if (state.kind !== 'models') return { items, beforeFacet, beforeBase: [] }; const beforeBase = items; if (!countBases(items).some(([family]) => family === state.base)) state.base = null; if (state.base !== null) items = items.filter(item => baseFamily(item) === state.base); // The versions of a model on one card, as the Models page has them: the one picked, else the one last shown here or pinned there. const pickedVersions = new Map(state.picks.models.map(id => byKey.get(keyOf('models', id))).filter(Boolean).map(item => [pageId(item), versionId(item)])); const folded = groupByPage(items, page => pickedVersions.get(page) || state.versions.get(page) || chosenVersion(page)); return { items: folded.map(({ model, versions }) => Object.assign(model, { versions })), beforeFacet, beforeBase }; } /** The base model chips of the models listed, two or more. */ function drawBases(models) { const counts = countBases(models); baseBar.hidden = counts.length < 2; baseBar.replaceChildren(...[[null, models.length], ...counts].map(([family, count]) => { const chip = button('anomalous-model-base-chip', [family === null ? t('modelBasesAll') : family || t('modelBasesUnknown'), el('span', 'anomalous-model-type-count', String(count))], () => { state.base = family; drawGrid(); }); chip.setAttribute('aria-pressed', String(family === state.base)); return chip; })); } function drawRail() { rail.replaceChildren(...KINDS.map((kind) => { const count = state.picks[kind].length + (kind === 'nodes' ? state.picks.packs.length : 0); 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.base = 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(); }, label); // the side panel shows the icons alone 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.dataset.label = label; // The whole name in a tooltip only when the row cuts it short. node.onpointerenter = () => { node.title = node.children[1].scrollWidth > node.children[1].clientWidth ? label : ''; }; node.append(el('span', 'anomalous-tb-facet-count', String(count))); if (state.facet === facet && body.classList.contains('is-side')) node.append(icon('check', 14)); if (facet && state.picks.packs.includes(facet)) node.classList.add('has-pack'); return node; }; const title = t(state.kind === 'nodes' ? 'aiTbSources' : 'aiTbNarrow'); const rows = () => [row(null, t('aiTbEverything'), beforeFacet.length), ...list.map(facet => row(facet.key, facet.label, facet.count, facet.source ? sourceBadge(facet.source, 22) : null))]; if (!body.classList.contains('is-side')) { side.replaceChildren(el('div', 'anomalous-tb-facets-title', title), ...rows()); return; } // The side panel: one button saying what is shown, opening the list to pick from (and search). const current = list.find(facet => facet.key === state.facet); const open = button(`anomalous-tb-facet-open${current && state.picks.packs.includes(current.key) ? ' has-pack' : ''}`, [ current?.source ? sourceBadge(current.source, 20) : icon('list', 15), el('span', 'anomalous-tb-facet-open-label', `${title}${t('wordColon')}${current?.label || t('aiTbEverything')}`), el('span', 'anomalous-tb-facet-count', String(current?.count ?? beforeFacet.length)), icon('chevronDown', 14)], () => toggleFacetMenu({ host: main, top: side.offsetTop + side.offsetHeight, title, rows: rows(), button: open }), t('aiTbPickSource')); open.setAttribute('aria-haspopup', 'listbox'); side.replaceChildren(open); } /** While one custom node pack is shown: its name, how many node types, and picking it whole. */ function drawPackBar() { const pack = state.kind === 'nodes' && state.facet ? byKey.get(keyOf('packs', state.facet)) : null; packBar.hidden = !pack; if (!pack) return; const on = state.picks.packs.includes(pack.id); const text = el('div', 'anomalous-tb-pack-text'); text.append(el('div', 'anomalous-tb-pack-name', pack.source.label || pack.id), el('div', 'anomalous-tb-pack-count', t('aiTbPackCount', { count: pack.count }))); packBar.replaceChildren(sourceBadge(pack.source, 26), text, button(`anomalous-tb-pack-pick${on ? ' is-on' : ''}`, [icon(on ? 'check' : 'plus', 15), t(on ? 'aiTbPackPicked' : 'aiTbPackPick')], () => setPick('packs', pack.id, !on), t('aiTbPackHint'))); } let observer = null; function drawGrid() { closeFacetMenu(); observer?.disconnect(); cards.clear(); const { items, beforeFacet, beforeBase } = shown(); drawFacets(beforeFacet); drawBases(beforeBase); 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 }))); drawPackBar(); 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 = (body.classList.contains('is-side') ? rowCard : 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 = kind === 'ways' ? wayTitle(id, resolveLocale()) : 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' || kind === 'packs') && item) { node.append(sourceBadge(item.source, 16)); } else { node.append(icon(kind === 'ways' ? 'blocks' : 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 PICK_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(); }); send.disabled = !total(state.picks) && !toolboxCount(task); // nothing picked, nothing to take back either 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 nodes = picks.nodes?.length || picks.packs?.length ? pageNodes() : []; const known = new Map([...nodes, ...packItems(nodes), ...(keptCatalog() || [])].map(item => [keyOf(item.kind, item.id), item])); for (const kind of PICK_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' || kind === 'packs') && item ? sourceBadge(item.source, 16) : icon({ packs: 'nodes', ways: 'blocks' }[kind] || kind, 14)); } const inner = kind === 'prompts' && /^(combo|workflow):/.test(id); node.append(el('span', 'anomalous-tb-chip-name', kind === 'ways' ? wayTitle(id, resolveLocale()) : 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; }