- ui_ai_now.js: over the message box, a run's progress with stop, the AI's step still going, a gate to allow or refuse there, and the AI's open question with its options as buttons (own words or skip too; several picks and pictures lead to the stage). progressWords is shared with the run card; the panel's and gate's answers are exported for it. - ui_ai_drop.js: pictures dropped on the box or pasted are kept in output/ai_inbox (POST /anomalous/ai_pictures/upload, ai_pictures.save_upload: read as a picture first, 25 MB cap, never over a file) and attached; ComfyUI output pictures dragged in attach as they are. - Long AI notes are folded to their first lines with "Show all". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
588 lines
30 KiB
JavaScript
588 lines
30 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, what it
|
||
* made (pictures, models, combos, sounds) as a strip on top, then its steps as a timeline,
|
||
* reads folded into one row, 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, or the materials picker. 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).
|
||
* 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.
|
||
* 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 { translate as t } from './locales.js';
|
||
import { dayLabel, fetchActivity, timeLabel } from './activity_log.js';
|
||
import { canUndo, undoEntry } from './canvas_undo.js';
|
||
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
|
||
import { messageBox, newStepsPill, updateMessageBox } from './ui_ai_message.js';
|
||
import { nowStrip } from './ui_ai_now.js';
|
||
import { taskCommands } from './ui_ai_commands.js';
|
||
import { hidePanelToast, panelToast } from './ui_ai_panel.js';
|
||
import { focusPanel, openQuestions, panelLine, panelSteps, stageNews, tabBar } from './ui_ai_side.js';
|
||
import { connectHint } from './ui_ai_connect.js';
|
||
import { waitingView } 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, shared, strip, updateRunProgress } from './ui_ai_results.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 { taskLinks } from './ui_ai_task_links.js';
|
||
import { TIMELINE_MIN, connectSide, drawSide, pickView, sideShown, sideSwitch, sideWidth, viewLabel } from './ui_ai_side_views.js';
|
||
import { executionStatus } from './ui_ai_status.js';
|
||
import { canvasControls } from './ui_ai_task_canvas.js';
|
||
import {
|
||
activityIds, duration, groupSteps, lookTools, stepDetail, taskCounts, taskResults, taskState, toolKey,
|
||
} from './ai_task_view.js';
|
||
|
||
const NARROW_PX = 720;
|
||
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 }, // 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 || []);
|
||
const fetchTask = id => getJson(`/anomalous/ai_tasks/${encodeURIComponent(id)}`).then(data => data.task || null);
|
||
|
||
// ---------------------------------------------------------------- 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); },
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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 = step.kind === 'panel' || step.tool === 'user_activity' ? '' : stepDetail(step);
|
||
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) }));
|
||
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;
|
||
}
|
||
|
||
function looksRow(steps, number) {
|
||
const row = el('div', 'anomalous-ai-step is-look is-folded');
|
||
row.dataset.stepIds = steps.map(step => step.id).join(' ');
|
||
row.append(el('span', 'anomalous-ai-step-dot', number));
|
||
const body = el('div', 'anomalous-ai-step-body');
|
||
const tools = lookTools(steps).map(({ tool, count }) => (count > 1 ? `${t(toolKey(tool))} ×${count}` : t(toolKey(tool))));
|
||
const head = button('anomalous-ai-looks', `${t('aiLooks', { count: steps.length })}:${tools.join('、')}`, () => {
|
||
details.hidden = !details.hidden;
|
||
head.setAttribute('aria-expanded', String(!details.hidden));
|
||
});
|
||
head.setAttribute('aria-expanded', 'false');
|
||
const details = el('div', 'anomalous-ai-looks-details');
|
||
details.hidden = true;
|
||
for (const step of steps) {
|
||
const detail = stepDetail(step);
|
||
const found = Number.isFinite(step.count) ? t('aiFound', { count: step.count }) : '';
|
||
details.append(el('div', 'anomalous-ai-look', [timeLabel(step.at), t(toolKey(step.tool)), detail, found].filter(Boolean).join(' · ')));
|
||
}
|
||
body.append(head, details);
|
||
row.append(body, el('div', 'anomalous-ai-step-when', timeLabel(steps[0].at)));
|
||
return row;
|
||
}
|
||
|
||
async function undoAll(task) {
|
||
const entries = taskResults(task).canvas.map(result => state.entries.get(result.activity)).filter(Boolean).reverse();
|
||
const undoable = entries.filter(entry => canUndo(app, entry));
|
||
if (!undoable.length || !await anomalousConfirm(t('aiUndoAllConfirm', { count: undoable.length }), t('aiUndoAll'))) return;
|
||
for (const entry of entries) {
|
||
if (!canUndo(app, entry)) break; // a later change of the user's is in the way: stop there
|
||
try { undoEntry(app, entry); } catch (_) { break; }
|
||
}
|
||
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 = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
|
||
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;
|
||
if (!view || view.taskId !== task.id || !box.contains(view.timeline)) {
|
||
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'),
|
||
timeline: el('div', 'anomalous-ai-timeline'), madeKey: null,
|
||
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, pill: newStepsPill(box), now: nowStrip(), dock: el('div', 'anomalous-ai-dock'),
|
||
};
|
||
view.tabs = el('div', 'anomalous-ai-tabs');
|
||
view.dock.append(view.pill, view.now, view.message); // at the foot of the column, in view while the timeline scrolls
|
||
box.replaceChildren(view.tabs, view.header, view.flow, view.made, view.timeline, view.dock);
|
||
}
|
||
// A new panel that asks something brings the stage forward when the window is narrow.
|
||
const news = stageNews(task, state.stage);
|
||
if (news.asking) Object.assign(state.stage, { tab: 'stage', view: 'stage' });
|
||
const asking = panelSteps(task).reduce((sum, step) => sum + openQuestions(step), 0);
|
||
const tabs = tabBar(state.stage, asking, pickTab, viewLabel(state.stage.view));
|
||
view.tabs.replaceWith(tabs);
|
||
view.tabs = tabs;
|
||
const header = view.header;
|
||
const titleLine = el('div', 'anomalous-ai-task-title-line');
|
||
titleLine.append(button('anomalous-ai-back', `‹ ${t('aiAllTasks')}`, () => { state.narrowView = 'list'; layout(); }),
|
||
el('h2', 'anomalous-ai-task-title', task.title || t('aiUntitled')), stateChip(status));
|
||
const tools = el('div', 'anomalous-ai-task-tools');
|
||
tools.append(executionStatus(task, refresh), canvasControls(task, () => void refresh(), () => state.owner?.close?.()));
|
||
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(iconButton('anomalous-ai-tool is-icon', 'pencil', t('aiRename'), async () => {
|
||
const title = await anomalousPrompt(t('aiRenamePrompt'), task.title || '', t('aiRename'));
|
||
if (title === null || title === undefined) return;
|
||
await postTask(task.id, { title }).catch(error => anomalousAlert(error.message));
|
||
await refresh();
|
||
}, { iconOnly: true }), iconButton('anomalous-ai-tool is-icon is-danger', 'trash', t('aiRemove'), async () => {
|
||
if (!await anomalousConfirm(t('aiRemoveConfirm'), t('aiRemove'))) return;
|
||
await postTask(task.id, { remove: true }).catch(error => anomalousAlert(error.message));
|
||
state.selected = null;
|
||
state.task = null;
|
||
state.narrowView = 'list';
|
||
await refresh();
|
||
}, { iconOnly: true }));
|
||
const shown = sideShown(state.stage, task) && !(state.page.classList.contains('is-tabbed') && state.stage.tab !== 'stage');
|
||
titleLine.append(tools);
|
||
state.owner?.headerTools?.replaceChildren(sideSwitch(state.stage, shown, { stage: asking, toolbox: toolboxCount(task) },
|
||
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);
|
||
header.replaceChildren(titleLine, el('div', 'anomalous-ai-task-meta', meta.join(' · ')),
|
||
taskLinks(task, state.tasks, { open: (id) => { state.pinned = true; void select(id); }, changed: refresh }));
|
||
|
||
const flow = flowMap(task, stepId => showStep(view.timeline, stepId));
|
||
view.flow.replaceChildren(...(flow ? [flow] : []));
|
||
|
||
const madeKey = JSON.stringify(taskResults(task));
|
||
if (madeKey !== view.madeKey) {
|
||
view.madeKey = madeKey;
|
||
const made = strip(task);
|
||
view.made.replaceChildren(...(made ? [made] : []));
|
||
}
|
||
|
||
// 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) => (item.type === 'looks'
|
||
? { key: JSON.stringify(['looks', at, item.steps]), make: () => looksRow(item.steps, at + 1) }
|
||
: { key: JSON.stringify(['step', at, item.step, undoable(item.step)]), make: () => stepRow(item.step, at + 1) }));
|
||
state.numbers = new Map(items.flatMap((item, at) => (item.type === 'looks' ? item.steps : [item.step]).map(step => [step.id, at + 1])));
|
||
// A task the user started waits for an AI: how to get one on it, and a page to put things
|
||
// ready for it meanwhile (their own steps are drawn there; its gallery feeds the message box).
|
||
const preparing = Boolean(task.waiting) && items.every(item => item.type === 'step' && item.step.kind === 'user');
|
||
if (preparing) {
|
||
const materials = toolboxCount(task);
|
||
rows.splice(0, rows.length, { key: JSON.stringify(['waiting', task.title, task.steps, materials]), make: () => waitingView(task, {
|
||
rename: async (title) => {
|
||
await postTask(task.id, { title }).catch(error => anomalousAlert(error.message));
|
||
await refresh();
|
||
},
|
||
message: view.message, materials, 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);
|
||
box.scrollTop = keepBottom && status === 'live' ? box.scrollHeight : keepTop;
|
||
if ((task.steps || []).slice(view.seen).some(step => step.kind !== 'user') && !keepBottom) view.pill.show(); // the AI's, while the user read further up
|
||
view.seen = (task.steps || []).length;
|
||
drawStage(task, news.arrived);
|
||
}
|
||
|
||
/** Brings a step's row into view (from the flow), marked for a moment. */
|
||
function showStep(timeline, stepId) {
|
||
const row = [...timeline.querySelectorAll('[data-step-ids]')].find(item => item.dataset.stepIds.split(' ').includes(stepId));
|
||
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) || '',
|
||
close: () => {
|
||
state.stage.hidden = true;
|
||
state.stage.tab = 'timeline';
|
||
renderTask();
|
||
},
|
||
}, pickTab, 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. */
|
||
async function newTask() {
|
||
const title = await anomalousPrompt(t('aiNewTaskPrompt'), '', t('aiNewTask'));
|
||
if (title === null || title === undefined) return;
|
||
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 pickTab(id) {
|
||
if (id === 'stage') state.stage.hidden = false;
|
||
state.stage.tab = id;
|
||
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))]);
|
||
}
|
||
|
||
/** Rows into `parent` in this order, keeping those whose key is the same (they are not touched). */
|
||
function syncRows(parent, rows) {
|
||
rows.forEach((row, at) => {
|
||
const there = parent.children[at];
|
||
if (there?.dataset.rowKey === row.key) return;
|
||
const made = row.make();
|
||
made.dataset.rowKey = row.key;
|
||
if (there) there.replaceWith(made);
|
||
else parent.append(made);
|
||
});
|
||
while (parent.children.length > rows.length) parent.lastElementChild.remove();
|
||
}
|
||
|
||
async function loadEntries(task) {
|
||
const wanted = new Set(activityIds(task));
|
||
const kept = new Map([...state.entries].filter(([id]) => wanted.has(id)));
|
||
if (wanted.size && [...wanted].some(id => !kept.has(id))) {
|
||
const entries = await fetchActivity({ limit: 500 });
|
||
for (const entry of entries) if (wanted.has(entry.id)) kept.set(entry.id, entry);
|
||
}
|
||
return kept;
|
||
}
|
||
|
||
async function select(id) {
|
||
const request = ++state.request;
|
||
state.selected = id;
|
||
state.narrowView = 'task';
|
||
state.page?.querySelectorAll('.anomalous-ai-task-row').forEach(row => row.classList.remove('is-current'));
|
||
renderList(state.page.querySelector('.anomalous-ai-tasks'));
|
||
layout();
|
||
const task = await fetchTask(id).catch(() => null);
|
||
if (state.selected !== id || request !== state.request) return;
|
||
const entries = task ? await loadEntries(task).catch(() => new Map()) : new Map();
|
||
if (state.selected !== id || request !== state.request) return;
|
||
state.task = task;
|
||
shared.entries = state.entries = entries;
|
||
shared.taskId = task?.id || null;
|
||
renderTask();
|
||
}
|
||
|
||
/** 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. */
|
||
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('shows-task', narrow && state.narrowView === 'task' && Boolean(state.selected));
|
||
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 tabbed = page.classList.contains('has-stage') && (narrow || room < sideWidth() + TIMELINE_MIN);
|
||
page.classList.toggle('is-tabbed', tabbed);
|
||
page.classList.toggle('shows-stage', tabbed && state.stage.tab === 'stage');
|
||
}
|
||
|
||
function emptyPage() {
|
||
const box = el('div', 'anomalous-ai-welcome');
|
||
const picture = el('img', 'anomalous-ai-welcome-art');
|
||
picture.src = new URL('../assets/ai_tasks_empty.webp', import.meta.url).href;
|
||
picture.alt = '';
|
||
box.append(picture, el('h2', 'anomalous-ai-welcome-title', t('aiWelcomeTitle')), el('p', 'anomalous-ai-welcome-body', t('aiWelcomeBody')),
|
||
iconButton('anomalous-ai-new-task', 'plus', t('aiNewTask'), newTask, { title: t('aiNewTaskHint') }), connectHint());
|
||
return box;
|
||
}
|
||
|
||
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(emptyPage(), 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');
|
||
state.page.replaceChildren(el('aside', 'anomalous-ai-tasks'), el('section', 'anomalous-ai-task'), el('section', 'anomalous-ai-stage'), 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'));
|
||
const task = await fetchTask(state.selected).catch(() => null);
|
||
if (request !== state.request) return;
|
||
const entries = task ? await loadEntries(task).catch(() => new Map()) : new Map();
|
||
if (request !== state.request) return;
|
||
state.task = task;
|
||
shared.entries = state.entries = entries;
|
||
shared.taskId = task?.id || null;
|
||
renderTask();
|
||
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) {
|
||
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));
|
||
}
|
||
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),
|
||
});
|
||
}
|