The gist of the prompt box and a message of a private task went to the AI whole or not at all. The Hide button marks the selected words; seal_marked turns the marked words into tokens when the text is handed in, and the page shows them again as the words they stand for. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
593 lines
31 KiB
JavaScript
593 lines
31 KiB
JavaScript
/**
|
||
* The AI workbench page: what AI apps did through Anomalous's MCP endpoint (api/ai_tasks.py).
|
||
* The tasks on the left, newest first; on the right one task: its title and counts, a line
|
||
* counting what it made that opens the Results view (ui_ai_outputs.js), then its steps as a timeline,
|
||
* the AI's work in a row folded into one (ui_ai_chain.js), each step with what it made and where that leads (the picture
|
||
* in the viewer, the model's page, the combo, the canvas change found or undone). A task the
|
||
* AI is still working on grows by itself ("anomalous.ai_task" events) and says so at the foot.
|
||
* The column on the right shows one view, picked by the switch at the top right
|
||
* in the window's title bar (ui_ai_side_views.js): the stage with the panels the AI drew (ui_ai_side.js; a line in the
|
||
* timeline brings it there, its questions are answered there, a toast says so while the user is
|
||
* elsewhere), a run's shape at full size, the materials picker, or what the task made. The header stays on top while
|
||
* the timeline scrolls. The task list folds from the title bar's button and peeks from it.
|
||
* Under a task's title, the task it goes on from and those going on from it (ui_ai_task_links.js).
|
||
* A step's words are ui_ai_step_body.js's; a decision the AI records shows as a card and the
|
||
* side column's "Decisions" view lists all of the task's (ui_ai_decisions.js).
|
||
* A task opens at its newest step and stays there as steps come in while the user reads at the
|
||
* foot; read further up, it stays put and a button over the message box leads down (ui_ai_newest.js).
|
||
* Under the timeline the user can write to the AI on that task; the message waits until the AI
|
||
* asks (get_answers) or runs its next step, and says whether it has. The list groups the tasks
|
||
* by day, each with an icon for where it stands.
|
||
* Picking a task draws it at once from what is in hand (a copy read before or read ahead while the
|
||
* pointer rested on it, ai_task_cache.js; the activity entries known) and again, only what
|
||
* changed, once the server has answered.
|
||
* In a narrow window the list and the task take turns. The rules are in ai_task_view.js.
|
||
* Also the home page's "What the AI did lately" and the rail entry's dot for news.
|
||
*/
|
||
|
||
import { app } from '../../../scripts/app.js';
|
||
import { api } from '../../../scripts/api.js';
|
||
import { resolveLocale, translate as t } from './locales.js';
|
||
import { dayLabel, fetchActivity, timeLabel } from './activity_log.js';
|
||
import { canUndo } from './canvas_undo.js';
|
||
import { anomalousAlert } from './ui_dialog.js';
|
||
import { messageBox, updateMessageBox } from './ui_ai_message.js';
|
||
import { atFoot, newestButton } from './ui_ai_newest.js';
|
||
import { nowStrip } from './ui_ai_now.js';
|
||
import { taskCommands } from './ui_ai_commands.js';
|
||
import { hidePanelToast, panelToast } from './ui_ai_panel.js';
|
||
import { bringStage, focusPanel, openQuestions, panelLine, panelSteps, stageBadge, stageNews } from './ui_ai_side.js';
|
||
import { waitingView, welcomePage } from './ui_ai_ready.js';
|
||
import { gateCard } from './ui_ai_gate.js';
|
||
import { iconButton } from './ui_ai_icons.js';
|
||
import { listToggle, renderTaskList, stateIcon } from './ui_ai_task_list.js';
|
||
import { flowMap } from './ui_ai_flow.js';
|
||
import { drawRecentAiTasks } from './ui_ai_recent.js';
|
||
import { locateNode, openModel, resultLinks, updateRunProgress } from './ui_ai_results.js';
|
||
import { shared } from './ui_ai_shared.js';
|
||
import { drawOutputsLine, newOutputs } from './ui_ai_outputs.js';
|
||
import { compareBar, setMarker } from './ui_ai_images.js';
|
||
import { openMarker } from './ui_ai_marker.js';
|
||
import { toolboxCount } from './ui_ai_toolbox.js';
|
||
import { stepBody } from './ui_ai_step_body.js';
|
||
import { chainRow, looksRow, undoChanges } from './ui_ai_chain.js';
|
||
import { taskLinks } from './ui_ai_task_links.js';
|
||
import { TIMELINE_MIN, connectSide, drawSide, pickView, sideShown, sideSwitch, sideWidth, viewTabs } from './ui_ai_side_views.js';
|
||
import { executionStatus } from './ui_ai_status.js';
|
||
import { canvasControls } from './ui_ai_task_canvas.js';
|
||
import { privacyChip, privacyReach } from './ui_ai_privacy.js';
|
||
import { menuActions, openTaskMenu, taskMenu } from './ui_ai_task_menu.js';
|
||
import { syncRows } from './sync_rows.js';
|
||
import { keptTask, readAhead, readTask, stopReadAhead } from './ai_task_cache.js';
|
||
import {
|
||
activityIds, duration, groupSteps, stepDetail, taskCounts, taskResults, taskState, toolKey,
|
||
} from './ai_task_view.js';
|
||
|
||
const NARROW_PX = 720;
|
||
const SHORT_PX = 880; // a window this low (not maximised) keeps less room around the task's head
|
||
const TIGHT_PX = 640; // a task's column this narrow (the side column open) shows its head's chips as icons
|
||
const COLLAPSED_KEY = 'anomalous_ai_tasks_collapsed';
|
||
const RECENT_COUNT = 3;
|
||
|
||
const state = {
|
||
owner: null,
|
||
panel: null,
|
||
page: null,
|
||
tasks: [],
|
||
selected: null, // task id shown
|
||
pinned: false, // the user picked it: a new task of the AI does not take its place
|
||
view: null, // the shown task's parts kept between redraws (renderTask)
|
||
stage: { taskId: null, latestKey: null, atBottom: true, focus: null, tab: 'timeline', view: 'stage', mapStep: null, unseen: false, hidden: false, outputsSeen: null }, // ui_ai_side.js
|
||
numbers: new Map(), // step id -> its number in the timeline (the stage shows it)
|
||
tasksCollapsed: (() => { try { return localStorage.getItem(COLLAPSED_KEY) === '1'; } catch (_) { return false; } })(),
|
||
task: null, // its full record
|
||
entries: shared.entries, // activity entries of its canvas steps, for undo and locate (ui_ai_results.js)
|
||
narrowView: 'list',
|
||
refreshTimer: null,
|
||
request: 0, // newer navigation/refresh invalidates older responses
|
||
observer: null,
|
||
bar: null, // the ticked pictures' compare bar (ui_ai_images.js), made once
|
||
};
|
||
|
||
function el(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
function button(className, label, onClick, title) {
|
||
const node = el('button', className, label);
|
||
node.type = 'button';
|
||
node.onclick = onClick;
|
||
if (title) node.title = title;
|
||
return node;
|
||
}
|
||
|
||
const say = item => (item ? t(item.key, item.values) : '');
|
||
const visible = () => Boolean(state.panel?.isConnected && state.panel.style.display !== 'none');
|
||
|
||
async function getJson(url) {
|
||
const response = await fetch(url);
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
return response.json();
|
||
}
|
||
|
||
async function postTask(id, body, action = '') {
|
||
const response = await fetch(`/anomalous/ai_tasks/${encodeURIComponent(id)}${action ? `/${action}` : ''}`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
|
||
return data;
|
||
}
|
||
|
||
const fetchTasks = (limit = 100) => getJson(`/anomalous/ai_tasks?limit=${limit}`).then(data => data.tasks || []);
|
||
|
||
// ---------------------------------------------------------------- the list
|
||
|
||
function renderList(box) {
|
||
renderTaskList(box, {
|
||
tasks: state.tasks, selected: state.selected, onNew: newTask,
|
||
// Picking the newest task follows the AI again; an older one stays put.
|
||
onPick: (summary) => { state.pinned = summary.id !== state.tasks[0]?.id; void select(summary.id); },
|
||
onHover: (summary) => { if (summary.id !== state.selected) readAhead(summary.id); },
|
||
onLeave: stopReadAhead,
|
||
onMenu: async (anchor, summary) => {
|
||
const task = await readTask(summary.id).catch(error => void anomalousAlert(error.message));
|
||
if (task && anchor.isConnected) openTaskMenu(anchor, task, taskActions(task));
|
||
},
|
||
});
|
||
}
|
||
|
||
const taskActions = task => menuActions(task, { tasks: state.tasks, post: postTask, refresh, removed: () => {
|
||
if (state.selected !== task.id) return;
|
||
state.selected = null;
|
||
state.task = null;
|
||
state.narrowView = 'list';
|
||
} });
|
||
|
||
// ---------------------------------------------------------------- one task
|
||
|
||
/** One step of the timeline; `number` is its place in the task (the dot shows it). */
|
||
function stepRow(step, number) {
|
||
const running = step.state === 'running';
|
||
const row = el('div', `anomalous-ai-step is-${step.kind}${step.ok ? '' : ' is-failed'}${running ? ' is-running' : ''}${step.state === 'asking' ? ' is-asking' : ''}`);
|
||
row.dataset.stepIds = step.id;
|
||
row.append(el('span', 'anomalous-ai-step-dot', number));
|
||
const body = el('div', 'anomalous-ai-step-body');
|
||
const line = el('div', 'anomalous-ai-step-line');
|
||
line.append(el('span', 'anomalous-ai-kind', t(`aiKind_${step.kind}`)), el('span', 'anomalous-ai-step-title', t(toolKey(step.tool))));
|
||
const detail = ['panel', 'outside'].includes(step.kind) || step.tool === 'user_activity' ? '' : stepDetail(step); // their body says it
|
||
if (detail) {
|
||
const text = el('span', 'anomalous-ai-step-detail', detail);
|
||
text.title = detail;
|
||
line.append(text);
|
||
}
|
||
body.append(line, ...stepBody(step, { taskId: state.task?.id, owner: state.owner, entries: state.entries, numberOf: id => state.numbers.get(id), steps: state.task?.steps, sealed: state.task?.sealed }));
|
||
const other = step.reply_to || step.replied;
|
||
if (other) {
|
||
body.append(iconButton(`anomalous-ai-reply-link${step.replied ? ' is-replied' : ''}`, step.replied ? 'chat' : 'undo',
|
||
t(step.replied ? 'aiReplied' : 'aiReplyTo', { number: state.numbers.get(other) ?? '' }), () => showStep(state.view.timeline, other)));
|
||
}
|
||
if (step.kind === 'panel' && state.task) {
|
||
body.append(panelLine(step, () => showOnStage(step.panel.id)));
|
||
}
|
||
const gate = state.task && gateCard(step, state.task.id, () => void refresh());
|
||
if (gate) body.append(gate);
|
||
if (step.error && !gate) body.append(el('div', 'anomalous-ai-step-error', step.error));
|
||
if (step.state === 'cancelled') body.append(el('div', 'anomalous-ai-step-cancelled', t(step.stop === 'user' ? 'aiRunCancelledByYou' : 'aiRunCancelledByAi')));
|
||
if (running && step.kind !== 'run') body.append(el('div', 'anomalous-ai-step-running', t('aiStepRunning')));
|
||
const links = resultLinks(step, renderTask);
|
||
if (links) body.append(links);
|
||
const when = el('div', 'anomalous-ai-step-when');
|
||
when.append(el('span', '', timeLabel(step.at)));
|
||
const took = duration(step.ms);
|
||
if (took) when.append(el('span', '', say(took)));
|
||
row.append(body, when);
|
||
return row;
|
||
}
|
||
|
||
async function undoAll(task) {
|
||
if (await undoChanges(task.steps, state.entries, t('aiUndoAll'), 'aiUndoAllConfirm')) renderTask();
|
||
}
|
||
|
||
function renderTask() {
|
||
const box = state.page?.querySelector('.anomalous-ai-task');
|
||
if (!box) return;
|
||
const task = state.task;
|
||
if (!task) {
|
||
box.replaceChildren(el('div', 'anomalous-ai-empty-task', state.tasks.length ? t('aiPickTask') : ''));
|
||
state.owner?.headerTools?.replaceChildren();
|
||
return;
|
||
}
|
||
const keepBottom = atFoot(box);
|
||
const keepTop = box.scrollTop;
|
||
const summary = state.tasks.find(item => item.id === task.id) || {};
|
||
const status = taskState(summary);
|
||
// The task's parts stay in the page between redraws (the AI's steps come in all the time):
|
||
// only what changed is drawn again, so a scrolled strip or diagram and half-typed text stay.
|
||
let view = state.view;
|
||
const fresh = !view || view.taskId !== task.id || !box.contains(view.timeline);
|
||
if (fresh) {
|
||
view = state.view = {
|
||
taskId: task.id, header: el('div', 'anomalous-ai-task-head'), flow: el('div', 'anomalous-ai-flow-holder'),
|
||
made: el('div', 'anomalous-ai-made-holder'), summary: el('div', 'anomalous-ai-summary'),
|
||
timeline: el('div', 'anomalous-ai-timeline'),
|
||
message: messageBox(async (text, refs) => {
|
||
await postTask(task.id, { text, refs }, 'message');
|
||
await refresh();
|
||
box.scrollTo({ top: box.scrollHeight, behavior: 'smooth' }); // what one sent, in view
|
||
}, { task: () => state.task, commands: () => taskCommands({ task: () => state.task, undo: () => undoAll(state.task), newTask,
|
||
side: view => pickView(state.stage, view, false, state.task, renderTask, refresh), changed: refresh }),
|
||
mark: (image, sent) => openMarker(image, task.id, () => { sent(); void refresh(); }),
|
||
openMaterials: () => pickView(state.stage, 'toolbox', false, state.task, renderTask, () => void refresh()) }),
|
||
seen: (task.steps || []).length, newest: newestButton(box), now: nowStrip(), dock: el('div', 'anomalous-ai-dock'),
|
||
};
|
||
view.dock.append(view.newest, view.now, view.message); // at the foot of the column, in view while the timeline scrolls
|
||
view.summary.append(view.flow, view.made);
|
||
box.replaceChildren(view.header, view.summary, view.timeline, view.dock);
|
||
new ResizeObserver(() => {
|
||
for (const part of [view.header, view.dock]) part.classList.toggle('is-tight', box.clientWidth < TIGHT_PX);
|
||
}).observe(view.header);
|
||
}
|
||
// A new panel opens the stage; in a narrow window only one that asks something takes its tab, and not from under the user's typing.
|
||
const news = stageNews(task, state.stage);
|
||
const typing = state.page.contains(document.activeElement) && document.activeElement.matches('textarea, input:not([type]), input[type=text]');
|
||
bringStage(state.stage, news, state.page.classList.contains('is-tabbed') || state.page.classList.contains('is-narrow'), typing);
|
||
const asking = panelSteps(task).reduce((sum, step) => sum + openQuestions(step), 0);
|
||
const header = view.header;
|
||
const titleLine = el('div', 'anomalous-ai-task-title-line');
|
||
const name = el('div', 'anomalous-ai-task-name');
|
||
const title = el('h2', 'anomalous-ai-task-title', task.title || t('aiUntitled'));
|
||
title.title = title.textContent;
|
||
name.append(button('anomalous-ai-back', `‹ ${t('aiAllTasks')}`, () => { state.narrowView = 'list'; layout(); }), title, stateChip(status));
|
||
const tools = el('div', 'anomalous-ai-task-tools');
|
||
tools.append(executionStatus(task, refresh), canvasControls(task, () => void refresh(), () => state.owner?.close?.()));
|
||
if (task.private === true) tools.append(privacyChip(task, () => void refresh()));
|
||
if (taskResults(task).canvas.some(result => state.entries.has(result.activity) && canUndo(app, state.entries.get(result.activity)))) {
|
||
tools.append(iconButton('anomalous-ai-tool', 'undo', t('aiUndoAll'), () => undoAll(task)));
|
||
}
|
||
tools.append(taskMenu(task, taskActions(task)));
|
||
const shown = sideShown(state.stage, task) && !(state.page.classList.contains('is-tabbed') && state.stage.tab !== 'stage');
|
||
titleLine.append(name, tools);
|
||
const unseen = newOutputs(state.stage, task, shown && state.stage.view === 'outputs');
|
||
const badges = { stage: stageBadge(task, state.stage, shown && state.stage.view === 'stage'), asking, toolbox: toolboxCount(task), outputs: unseen };
|
||
state.tabs = () => viewTabs(state.stage, shown, badges, view => pickView(state.stage, view, false, task, renderTask, () => void refresh()), showTimeline);
|
||
state.owner?.headerTools?.replaceChildren(...(state.page.classList.contains('uses-tabs') ? [] : [sideSwitch(state.stage, shown, badges,
|
||
view => pickView(state.stage, view, shown, task, renderTask, () => void refresh()))]));
|
||
const meta = [task.client, `${dayLabel(task.started)} ${timeLabel(task.started)}`, t('aiStepCount', { count: (task.steps || []).length }),
|
||
...taskCounts(summary).map(say)].filter(Boolean);
|
||
const said = el('div', 'anomalous-ai-task-meta', meta.join(' · '));
|
||
said.title = said.textContent;
|
||
const sub = el('div', 'anomalous-ai-task-sub');
|
||
sub.append(said, taskLinks(task, { open: (id) => { state.pinned = true; void select(id); }, changed: refresh }));
|
||
header.replaceChildren(state.tabs(), titleLine, sub,
|
||
...(task.stale_tools ? [el('div', 'anomalous-ai-task-stale', t('aiStaleTools'))] : []), ...privacyReach(task)); // mcp_directory.stale
|
||
|
||
const flow = flowMap(task, stepId => showStep(view.timeline, stepId));
|
||
view.flow.replaceChildren(...(flow ? [flow] : []));
|
||
|
||
drawOutputsLine(view.made, task, () => pickView(state.stage, 'outputs', false, state.task, renderTask, () => void refresh()));
|
||
|
||
// Numbered in the order they happened, so the timeline reads 1, 2, 3 from the top.
|
||
const items = groupSteps(task.steps);
|
||
const rows = items.map((item, at) => {
|
||
if (item.type === 'looks') return { key: JSON.stringify(['looks', at, item.steps]), make: () => looksRow(item.steps, at + 1) };
|
||
if (item.type === 'step') return { key: JSON.stringify(['step', at, item.step, undoable(item.step)]), make: () => stepRow(item.step, at + 1) };
|
||
const live = at === items.length - 1 && !task.ended; // the AI may still add to it
|
||
return { key: JSON.stringify(['chain', at, item.steps, item.steps.map(undoable), live]),
|
||
make: () => chainRow(item.steps, at + 1, { entries: state.entries, stepRow, changed: renderTask, live }) };
|
||
});
|
||
state.numbers = new Map(items.flatMap((item, at) => (item.type === 'step' ? [item.step] : item.steps).map(step => [step.id, at + 1])));
|
||
// A task the user started waits for an AI: how to get one on it (what they sent meanwhile is drawn there).
|
||
const preparing = Boolean(task.waiting) && items.every(item => item.type === 'step' && item.step.kind === 'user');
|
||
if (preparing) {
|
||
const connected = state.tasks.some(item => item.client);
|
||
rows.splice(0, rows.length, { key: JSON.stringify(['waiting', task.title, task.steps, connected]), make: () => waitingView(task, {
|
||
connected, ready: items.map((item, at) => stepRow(item.step, at + 1)),
|
||
}) });
|
||
} else if (!items.length) rows.push({ key: 'empty', make: () => el('div', 'anomalous-ai-empty-task', t('aiNoSteps')) });
|
||
syncRows(view.timeline, rows);
|
||
updateMessageBox(view.message, Boolean(task.ended), preparing ? 'waiting' : status, toolboxCount(task));
|
||
view.now.update(task, { refresh: () => void refresh(), showStep: id => showStep(view.timeline, id),
|
||
openStage: () => pickView(state.stage, 'stage', false, task, renderTask, () => void refresh()) });
|
||
view.timeline.classList.toggle('is-waiting', preparing);
|
||
// Opened, at its newest step (a page being prepared, at its top); read at the foot, kept there.
|
||
if (!view.newest.jumping) box.scrollTop = fresh && preparing ? 0 : fresh || keepBottom ? box.scrollHeight : keepTop;
|
||
view.newest.arrived((task.steps || []).slice(view.seen).filter(step => step.kind !== 'user').length);
|
||
view.seen = (task.steps || []).length;
|
||
drawStage(task, news.arrived);
|
||
}
|
||
|
||
/** Brings a step's row into view (from the flow), marked for a moment; a folded chain opens to it. */
|
||
function showStep(timeline, stepId) {
|
||
const has = item => item.dataset.stepIds.split(' ').includes(stepId);
|
||
const chain = [...timeline.children].find(has);
|
||
chain?.reveal?.();
|
||
const row = [...timeline.querySelectorAll('[data-step-ids]')].findLast(has);
|
||
if (!row) return;
|
||
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
row.classList.add('is-flash');
|
||
setTimeout(() => row.classList.remove('is-flash'), 1600);
|
||
}
|
||
|
||
/** The side column beside the timeline: the view picked (ui_ai_side_views.js). */
|
||
function drawStage(task, arrived = null) {
|
||
const box = state.page?.querySelector('.anomalous-ai-stage');
|
||
if (!box) return;
|
||
state.page.classList.toggle('has-stage', sideShown(state.stage, task));
|
||
drawSide(box, task, state.stage, {
|
||
taskId: task.id,
|
||
owner: state.owner,
|
||
openPicture: (url, video) => state.owner?.showGalleryViewer?.(url, video),
|
||
openModel,
|
||
locateNode: id => locateNode(id, null),
|
||
stepOf: id => task.steps.find(item => item.id === id),
|
||
changed: () => void refresh(),
|
||
numberOf: id => state.numbers.get(id) || '',
|
||
showCanvas: () => state.owner?.close?.(),
|
||
close: () => {
|
||
state.stage.hidden = true;
|
||
state.stage.tab = 'timeline';
|
||
renderTask();
|
||
},
|
||
tabs: state.tabs,
|
||
}, arrived);
|
||
layout();
|
||
}
|
||
|
||
function stateChip(status) {
|
||
const chip = el('span', `anomalous-ai-state is-${status}`);
|
||
chip.append(stateIcon(status, 12), el('span', '', t(`aiState_${status}`)));
|
||
return chip;
|
||
}
|
||
|
||
function showOnStage(panelId) {
|
||
if (!state.task) return;
|
||
focusPanel(state.stage, panelId);
|
||
renderTask();
|
||
}
|
||
|
||
/** New task: the user starts one; the next AI app that calls takes it over. What it is for is said on its page. */
|
||
async function newTask() {
|
||
try {
|
||
const response = await fetch('/anomalous/ai_tasks', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '', from: state.task?.id }), // goes on from the task shown
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
|
||
state.selected = data.task.id;
|
||
state.pinned = false;
|
||
state.narrowView = 'task';
|
||
await refresh();
|
||
} catch (error) {
|
||
await anomalousAlert(error.message);
|
||
}
|
||
}
|
||
|
||
function showTimeline() {
|
||
state.stage.tab = 'timeline';
|
||
renderTask();
|
||
}
|
||
|
||
/** The title bar's list button: folds or shows the task list (in a narrow window, goes to it and back). */
|
||
function toggleTasks() {
|
||
if (state.page?.classList.contains('is-narrow')) {
|
||
state.narrowView = state.narrowView === 'list' && state.selected ? 'task' : 'list';
|
||
return layout();
|
||
}
|
||
const folded = !state.tasksCollapsed;
|
||
state.tasksCollapsed = folded;
|
||
try { localStorage.setItem(COLLAPSED_KEY, folded ? '1' : '0'); } catch (_) { /* this viewer's convenience */ }
|
||
layout();
|
||
}
|
||
|
||
/** Which canvas changes of a step can be undone now (part of when its row is drawn again). */
|
||
function undoable(step) {
|
||
return (step.results || []).filter(result => result.type === 'canvas')
|
||
.map(result => [state.entries.get(result.activity) || null, state.entries.has(result.activity) && canUndo(app, state.entries.get(result.activity))]);
|
||
}
|
||
|
||
const unknownEntries = new Set(); // activity ids the log no longer has: not asked for again
|
||
|
||
/** The activity entries of `task`'s canvas steps already in hand. */
|
||
function keptEntries(task) {
|
||
const wanted = new Set(activityIds(task));
|
||
return new Map([...state.entries].filter(([id]) => wanted.has(id)));
|
||
}
|
||
|
||
/** Those and the missing ones from the activity log; null when none was missing. */
|
||
async function missingEntries(task) {
|
||
const kept = keptEntries(task);
|
||
const missing = activityIds(task).filter(id => !kept.has(id) && !unknownEntries.has(id));
|
||
if (!missing.length) return null;
|
||
const entries = await fetchActivity({ limit: 500 });
|
||
for (const entry of entries) if (missing.includes(entry.id)) kept.set(entry.id, entry);
|
||
for (const id of missing) if (!kept.has(id)) unknownEntries.add(id);
|
||
return kept;
|
||
}
|
||
|
||
function showTask(task, entries) {
|
||
state.task = task;
|
||
shared.entries = state.entries = entries;
|
||
shared.taskId = task?.id || null;
|
||
renderTask();
|
||
}
|
||
|
||
/** Task `id` as the server has it now, drawn; then again once the activity entries it lacked came. */
|
||
async function showRead(id, request, otherwise) {
|
||
const current = () => state.selected === id && request === state.request;
|
||
const task = await readTask(id).catch(() => otherwise);
|
||
if (!current()) return;
|
||
showTask(task, task ? keptEntries(task) : new Map());
|
||
const entries = task ? await missingEntries(task).catch(() => null) : null;
|
||
if (entries && current()) showTask(task, entries);
|
||
}
|
||
|
||
async function select(id) {
|
||
const request = ++state.request;
|
||
state.selected = id;
|
||
state.narrowView = 'task';
|
||
renderList(state.page.querySelector('.anomalous-ai-tasks'));
|
||
layout();
|
||
// A task read before shows at once; what changed since follows.
|
||
const known = keptTask(id);
|
||
if (known) showTask(known, keptEntries(known));
|
||
await showRead(id, request, known);
|
||
}
|
||
|
||
/** Pictures on the AI page can be marked for the task shown (ui_ai_marker.js). */
|
||
setMarker(image => state.task && openMarker(image, state.task.id, () => void refresh()));
|
||
|
||
/**
|
||
* The materials picker docks in the side column while this page is shown; a run's step opens its shape there.
|
||
* Connected when the page is first drawn, not when this file loads: ui_ai_toolbox.js is in an import cycle with
|
||
* this file and ComfyUI loads every file of the plugin as an extension, so either may run first.
|
||
*/
|
||
function connectSideOnce() {
|
||
shared.openMap ||= connectSide({
|
||
side: state.stage, task: () => state.task, redraw: () => renderTask(),
|
||
box: () => (visible() && state.page?.querySelector('.anomalous-ai-stage')) || null,
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- the page
|
||
|
||
function layout() {
|
||
const page = state.page;
|
||
if (!page) return;
|
||
const width = page.clientWidth;
|
||
const narrow = width > 0 && width < NARROW_PX;
|
||
const folded = state.tasksCollapsed && !narrow;
|
||
page.classList.toggle('is-narrow', narrow);
|
||
page.classList.toggle('is-short', page.clientHeight > 0 && page.clientHeight < SHORT_PX);
|
||
page.classList.toggle('shows-task', narrow && state.narrowView === 'task' && Boolean(state.selected));
|
||
page.classList.toggle('peeks', narrow && state.narrowView !== 'task' && Boolean(state.selected)); // the list over the task
|
||
page.classList.toggle('tasks-collapsed', folded);
|
||
// Three columns when there is room; else the timeline and the stage take turns behind tabs.
|
||
const room = width - (narrow || folded ? 0 : 260);
|
||
const noRoom = narrow || room < sideWidth() + TIMELINE_MIN;
|
||
const tabbed = page.classList.contains('has-stage') && noRoom;
|
||
const changed = page.classList.contains('uses-tabs') !== noRoom;
|
||
page.classList.toggle('uses-tabs', noRoom);
|
||
page.classList.toggle('is-tabbed', tabbed);
|
||
page.classList.toggle('shows-stage', tabbed && state.stage.tab === 'stage');
|
||
if (changed && state.task) renderTask(); // the title bar's switch and the tabs change places
|
||
}
|
||
|
||
async function refresh() {
|
||
if (!state.page) return;
|
||
const request = ++state.request;
|
||
const tasks = await fetchTasks().catch(() => null);
|
||
if (!tasks || !state.page || request !== state.request) return;
|
||
state.tasks = tasks;
|
||
if (!tasks.length) {
|
||
state.page.replaceChildren(welcomePage(newTask), state.bar);
|
||
state.owner?.headerTools?.replaceChildren();
|
||
state.page.classList.add('is-empty');
|
||
return;
|
||
}
|
||
if (state.page.classList.contains('is-empty') || !state.page.querySelector('.anomalous-ai-tasks')) {
|
||
state.page.classList.remove('is-empty');
|
||
const scrim = button('anomalous-ai-scrim', '', () => { state.narrowView = 'task'; layout(); });
|
||
scrim.title = t('aiBackToTask');
|
||
state.page.replaceChildren(el('aside', 'anomalous-ai-tasks'), el('section', 'anomalous-ai-task'), el('section', 'anomalous-ai-stage'), scrim, state.bar);
|
||
}
|
||
// The newest task while the AI works on it, unless the user picked another one.
|
||
const follow = !state.pinned && !tasks[0].ended && tasks[0].id !== state.selected
|
||
&& (tasks[0].live || tasks[0].started > (state.task?.started || 0));
|
||
if (follow || !tasks.some(task => task.id === state.selected)) {
|
||
state.selected = tasks[0].id;
|
||
state.pinned = false;
|
||
state.task = null;
|
||
}
|
||
renderList(state.page.querySelector('.anomalous-ai-tasks'));
|
||
await showRead(state.selected, request, state.task?.id === state.selected ? state.task : null); // a failed read keeps what shows
|
||
layout();
|
||
}
|
||
|
||
// Poll only while this view is visible: reconnects and missed websocket events must not freeze its status.
|
||
let pollTimer = null;
|
||
function pollVisible() {
|
||
clearTimeout(pollTimer);
|
||
pollTimer = setTimeout(async () => {
|
||
if (document.hidden || !state.page?.isConnected || !visible()
|
||
|| !state.owner?.modal?.classList.contains('visible') || state.owner.currentShellPage?.() !== 'ai') return;
|
||
try { await refresh(); } finally { pollVisible(); }
|
||
}, 5000);
|
||
}
|
||
|
||
/** The whole page into `panel`. */
|
||
export function renderAiWorkbench(owner, panel) {
|
||
connectSideOnce();
|
||
if (state.language !== resolveLocale()) { // shown again in another language: the parts kept between redraws go
|
||
state.language = resolveLocale();
|
||
state.view = null;
|
||
state.page?.querySelectorAll('[data-key], [data-panel-key]').forEach(node => { delete node.dataset.key; delete node.dataset.panelKey; });
|
||
}
|
||
state.owner = owner;
|
||
shared.owner = owner;
|
||
state.panel = panel;
|
||
state.bar ||= compareBar();
|
||
if (!state.page || state.page.parentNode !== panel) {
|
||
state.observer?.disconnect();
|
||
state.page = el('div', 'anomalous-ai');
|
||
panel.replaceChildren(state.page);
|
||
if (typeof ResizeObserver === 'function') {
|
||
state.observer = new ResizeObserver(layout);
|
||
state.observer.observe(state.page);
|
||
}
|
||
}
|
||
owner.headerLeadTools?.replaceChildren(listToggle(state.page, toggleTasks));
|
||
owner.railNews?.('ai', false);
|
||
hidePanelToast();
|
||
void refresh();
|
||
pollVisible();
|
||
}
|
||
|
||
/** Once: the server's "anomalous.ai_task" event refreshes the page, the home list and the rail's dot. */
|
||
export function watchAiTasks(owner) {
|
||
state.owner = owner;
|
||
shared.owner = owner;
|
||
document.addEventListener('visibilitychange', () => { if (!document.hidden) pollVisible(); });
|
||
api.addEventListener('anomalous.ai_task', (event) => {
|
||
const detail = event?.detail || {};
|
||
const onPage = () => owner.modal?.classList.contains('visible') && owner.currentShellPage?.() === 'ai' && visible();
|
||
// A run's progress only moves its bar (every couple of seconds while it runs): no refetch.
|
||
if (detail.progress) {
|
||
const step = state.task?.id === detail.id && state.task.steps.find(item => item.id === detail.progress.step);
|
||
if (step) step.progress = detail.progress;
|
||
if (onPage() && step) {
|
||
updateRunProgress(state.page, detail.progress);
|
||
state.view?.header.querySelector('.anomalous-ai-execution')?.replaceWith(executionStatus(state.task, refresh));
|
||
state.view?.now.update(state.task); // the strip over the message box says the same
|
||
}
|
||
return;
|
||
}
|
||
if (detail.panel && !onPage()) panelToast(owner, String(detail.panel), Boolean(detail.asks));
|
||
else if (detail.gate && !onPage()) panelToast(owner, t(`aiGateQ_${detail.gate}`), true);
|
||
clearTimeout(state.refreshTimer);
|
||
state.refreshTimer = setTimeout(() => {
|
||
if (onPage()) void refresh();
|
||
else owner.railNews?.('ai', true);
|
||
if (owner.currentShellPage?.() === 'home') owner.onActivityRecorded?.();
|
||
}, 300);
|
||
});
|
||
}
|
||
|
||
/** Opens task `id` on the AI page (from the activity log, the home page). */
|
||
export function openAiTask(owner, id, newest = null) {
|
||
state.selected = id;
|
||
state.pinned = id !== newest;
|
||
owner.goTo('ai');
|
||
}
|
||
|
||
/** The home page's short list (ui_ai_recent.js); a row opens its task here. */
|
||
export function renderRecentAiTasks(owner, container) {
|
||
drawRecentAiTasks(owner, container, {
|
||
fetchTasks: () => fetchTasks(RECENT_COUNT),
|
||
open: (summary, newest) => openAiTask(owner, summary.id, newest),
|
||
});
|
||
}
|