Files
DemonGatanjieuandClaude Opus 5.5 d778aec946 Fix what a walk through every page found before 1.63
Every page in both languages, all themes and at 480:
- Give the window its own text colour: text with none took ComfyUI's, white on the light theme (Tidy headings and file names, the site chip).
- Keep a task's link chip in its row, its x in reach, and colour the next-task chip.
- Let choices in Settings wrap, the Models search shrink, and a workflow's preset row go to two lines.
- Add --amb-on-link for text on the accent: pale in the dark themes, so white was hard to read.
- Lists and colons follow the language (listSeparator, wordColon); tests/words_in_language.mjs keeps it so.
- Right plurals in English, "AI" on the rail, one line left in Chinese translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:18:07 +08:00

592 lines
30 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, 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;
}