A task the AI never names (mostly one asked for in the AI app's own chat, with no begin_task) took the summary of its first step that did something as its title, so the list read "run_workflow" or "new_workflow: anima test". ai_task_title.js shows such a title (title_from "auto", now in the list's summaries and the task links) with the tool in the page's words, "新建了工作流:anima test"; a title the user or the AI gave stays as written, and every page naming a task uses it. Renaming starts from that name. The short set's note said "drawing in the AI app's own chat", which the user read as the picture being drawn in the AI app. It now says it is for telling the AI to draw right in the app's chat, the picture still drawn by ComfyUI, and that a task started with New task cannot use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
593 lines
30 KiB
JavaScript
593 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';
|
|
import { taskTitle } from './ai_task_title.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: taskTitle(task) })), 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;
|
|
}
|