- api/ai_gates.py: changing the canvas, using the GPU, downloading and scanning online can each go ahead, ask first or never (downloads ask by default; anomalous/ai_gates.json). A call that asks waits on its step (state "asking", what it would do listed) up to 50 s for Allow or No (with the user's reason for the AI); unanswered, the AI is told to wait and call again, the later answer reaches it as a message, and the same call after an Allow goes ahead. - ui_ai_gate.js: the card under a waiting step and the Gates window; a toast while elsewhere. - ui_ai_flow.js + ai_task_view.flowStages: the task's stages in order, each done, running, waiting or failed; a stage leads to its first step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
575 lines
27 KiB
JavaScript
575 lines
27 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.
|
||
* A panel the AI drew (show_panel) is shown on the stage, the column on the right (ui_ai_side.js),
|
||
* with a line in the timeline that brings it there; its questions are answered there, and when it
|
||
* comes while the user is elsewhere a toast says so. The task list can be folded away.
|
||
* 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, entrySummary, fetchActivity, timeLabel } from './activity_log.js';
|
||
import { changeNodeId } from './activity_diff.js';
|
||
import { canUndo, undoEntry } from './canvas_undo.js';
|
||
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
|
||
import { messageBox, updateMessageBox } from './ui_ai_message.js';
|
||
import { hidePanelToast, panelToast } from './ui_ai_panel.js';
|
||
import { focusPanel, openQuestions, panelLine, panelSteps, renderStage, stageNews, tabBar } from './ui_ai_side.js';
|
||
import { connectGuide, openConnectGuide } from './ui_ai_connect.js';
|
||
import { gateCard, openGates } from './ui_ai_gate.js';
|
||
import { flowMap } from './ui_ai_flow.js';
|
||
import { drawRecentAiTasks } from './ui_ai_recent.js';
|
||
import { locateNode, openModel, resultLinks, shared, strip } from './ui_ai_results.js';
|
||
import { compareBar } from './ui_ai_images.js';
|
||
import { canvasChanges } from './ui_canvas_changes.js';
|
||
import {
|
||
activityIds, duration, groupSteps, lookTools, stepDetail, taskCounts, taskResults, taskState, toolKey,
|
||
} from './ai_task_view.js';
|
||
|
||
const NARROW_PX = 720;
|
||
const STAGE_FROM = 760; // the room the timeline and the stage need side by side
|
||
const COLLAPSED_KEY = 'anomalous_ai_tasks_collapsed';
|
||
const STATE_ICONS = { waiting: '…', live: '●', asks: '?', error: '!', done: '✓' };
|
||
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', 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 taskRow(summary) {
|
||
const status = taskState(summary);
|
||
const row = button(`anomalous-ai-task-row is-${status}${summary.id === state.selected ? ' is-current' : ''}`, '', () => {
|
||
// Picking the newest task follows the AI again; an older one stays put.
|
||
state.pinned = summary.id !== state.tasks[0]?.id;
|
||
void select(summary.id);
|
||
});
|
||
const top = el('div', 'anomalous-ai-task-row-top');
|
||
const icon = el('span', `anomalous-ai-state-icon is-${status}`, STATE_ICONS[status]);
|
||
icon.title = t(`aiState_${status}`);
|
||
top.append(icon, el('span', 'anomalous-ai-task-row-title', summary.title || t('aiUntitled')));
|
||
if (status === 'live') top.append(el('span', 'anomalous-ai-live-dot'));
|
||
const meta = [summary.client, timeLabel(summary.updated), t('aiStepCount', { count: summary.steps })].filter(Boolean);
|
||
row.append(top, el('div', 'anomalous-ai-task-row-meta', meta.join(' · ')));
|
||
const counts = taskCounts(summary);
|
||
if (counts.length) row.append(el('div', 'anomalous-ai-task-row-counts', counts.map(say).join(' · ')));
|
||
return row;
|
||
}
|
||
|
||
function renderList(box) {
|
||
const head = el('div', 'anomalous-ai-tasks-head');
|
||
head.append(el('span', 'anomalous-ai-tasks-title', t('aiTasks')), el('span', 'anomalous-ai-tasks-count', t('aiTaskCount', { count: state.tasks.length })),
|
||
button('anomalous-ai-fold', '‹', () => foldTasks(true), t('aiTasksCollapse')));
|
||
const actions = el('div', 'anomalous-ai-tasks-actions');
|
||
actions.append(button('anomalous-ai-new-task', `+ ${t('aiNewTask')}`, newTask, t('aiNewTaskHint')),
|
||
button('anomalous-ai-link', t('aiConnect'), openConnectGuide), button('anomalous-ai-link', t('aiGates'), openGates, t('aiGatesHint')));
|
||
const list = el('div', 'anomalous-ai-tasks-list');
|
||
// Newest first, under a heading for each day (today, yesterday, a date).
|
||
let day = null;
|
||
for (const summary of state.tasks) {
|
||
const label = dayLabel(summary.updated);
|
||
if (label !== day) {
|
||
day = label;
|
||
list.append(el('div', 'anomalous-ai-tasks-day', label));
|
||
}
|
||
list.append(taskRow(summary));
|
||
}
|
||
box.replaceChildren(head, actions, 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 = 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);
|
||
if (step.tool === 'user_activity') {
|
||
body.append(userChange(step));
|
||
} else if (step.kind === 'user') {
|
||
body.append(el('div', 'anomalous-ai-user-text', step.text),
|
||
el('div', 'anomalous-ai-user-state', t(step.delivered ? 'aiMessageSeen' : 'aiMessageWaiting')));
|
||
} else if (step.text) {
|
||
body.append(el('div', 'anomalous-ai-note-text', step.text));
|
||
}
|
||
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 (running) body.append(el('div', 'anomalous-ai-step-running', t(step.kind === 'run' ? 'aiRunRunning' : '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;
|
||
}
|
||
|
||
/** A change the user made in Anomalous while the AI worked (an activity entry), and whether the AI knows. */
|
||
function userChange(step) {
|
||
const box = el('div', 'anomalous-ai-user-change');
|
||
const entry = state.entries.get(step.activity);
|
||
box.append(el('div', 'anomalous-ai-user-text', entry ? entrySummary(entry) : step.text));
|
||
if (entry?.source === 'canvas') {
|
||
const note = el('span', 'anomalous-ai-link-note');
|
||
box.append(canvasChanges(entry, { locate: change => locateNode(changeNodeId(change), note) }), note);
|
||
}
|
||
box.append(el('div', 'anomalous-ai-user-state', t(step.delivered ? 'aiChangeSeen' : 'aiChangeWaiting')));
|
||
return box;
|
||
}
|
||
|
||
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') : ''));
|
||
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) => {
|
||
await postTask(task.id, { text }, 'message');
|
||
await refresh();
|
||
}),
|
||
};
|
||
view.tabs = el('div', 'anomalous-ai-tabs');
|
||
box.replaceChildren(view.tabs, view.header, view.flow, view.made, view.timeline, view.message);
|
||
}
|
||
// A new panel that asks something brings the stage forward when the window is narrow.
|
||
const news = stageNews(task, state.stage);
|
||
if (news.asking) state.stage.tab = 'stage';
|
||
const asking = panelSteps(task).reduce((sum, step) => sum + openQuestions(step), 0);
|
||
const tabs = tabBar(state.stage, asking, pickTab);
|
||
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(); }),
|
||
button('anomalous-ai-unfold', '›', () => foldTasks(false), t('aiTasksExpand')),
|
||
el('h2', 'anomalous-ai-task-title', task.title || t('aiUntitled')),
|
||
el('span', `anomalous-ai-state is-${status}`, t(`aiState_${status}`)));
|
||
const tools = el('div', 'anomalous-ai-task-tools');
|
||
if (state.stage.hidden && panelSteps(task).length) {
|
||
tools.append(button('anomalous-ai-link', t('aiStageShow'), () => pickTab('stage')));
|
||
}
|
||
tools.append(button('anomalous-ai-link', 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();
|
||
}));
|
||
if (taskResults(task).canvas.some(result => state.entries.has(result.activity) && canUndo(app, state.entries.get(result.activity)))) {
|
||
tools.append(button('anomalous-ai-link', t('aiUndoAll'), () => undoAll(task)));
|
||
}
|
||
tools.append(button('anomalous-ai-link is-danger', 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();
|
||
}));
|
||
titleLine.append(tools);
|
||
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(' · ')));
|
||
|
||
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.
|
||
if (!items.length && task.waiting) rows.push({ key: `waiting:${task.title}`, make: () => waitingCard(task) });
|
||
else if (!items.length) rows.push({ key: 'empty', make: () => el('div', 'anomalous-ai-empty-task', t('aiNoSteps')) });
|
||
if (status === 'live') {
|
||
const running = (task.steps || []).some(step => step.state === 'running');
|
||
if (!running) {
|
||
rows.push({
|
||
key: `next:${items.length}:waiting`,
|
||
make: () => {
|
||
const next = el('div', 'anomalous-ai-step is-next');
|
||
next.append(el('span', 'anomalous-ai-step-dot', items.length + 1), el('div', 'anomalous-ai-working', t('aiWorking')));
|
||
return next;
|
||
},
|
||
});
|
||
}
|
||
}
|
||
syncRows(view.timeline, rows);
|
||
updateMessageBox(view.message, Boolean(task.ended), status);
|
||
box.scrollTop = keepBottom && status === 'live' ? box.scrollHeight : keepTop;
|
||
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);
|
||
}
|
||
|
||
/** An empty task the user started: say what to tell the AI, and how to connect one. */
|
||
function waitingCard(task) {
|
||
const card = el('div', 'anomalous-ai-waiting');
|
||
card.append(el('div', 'anomalous-ai-waiting-title', t('aiWaitingTitle')), el('p', 'anomalous-ai-waiting-body', t('aiWaitingBody')), connectGuide(task.title));
|
||
return card;
|
||
}
|
||
|
||
/** The stage beside the timeline, while the task has panels. */
|
||
function drawStage(task, arrived = null) {
|
||
const stage = state.page?.querySelector('.anomalous-ai-stage');
|
||
if (!stage) return;
|
||
const has = panelSteps(task).length > 0;
|
||
state.page.classList.toggle('has-stage', has && !state.stage.hidden);
|
||
if (!has) {
|
||
stage.replaceChildren();
|
||
layout();
|
||
return;
|
||
}
|
||
renderStage(stage, task, state.stage, {
|
||
taskId: task.id,
|
||
owner: state.owner,
|
||
openPicture: (url, video) => state.owner?.showGalleryViewer?.(url, video),
|
||
openModel,
|
||
locateNode: id => locateNode(id, null),
|
||
changed: () => void refresh(),
|
||
numberOf: id => state.numbers.get(id) || '',
|
||
tab: pickTab,
|
||
close: () => {
|
||
state.stage.hidden = true;
|
||
state.stage.tab = 'timeline';
|
||
renderTask();
|
||
},
|
||
}, arrived);
|
||
layout();
|
||
}
|
||
|
||
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 }),
|
||
});
|
||
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();
|
||
}
|
||
|
||
function foldTasks(folded) {
|
||
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;
|
||
renderTask();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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 < STAGE_FROM);
|
||
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');
|
||
box.append(el('h2', 'anomalous-ai-welcome-title', t('aiWelcomeTitle')), el('p', 'anomalous-ai-welcome-body', t('aiWelcomeBody')),
|
||
connectGuide(), button('anomalous-ai-new-task', `+ ${t('aiNewTask')}`, newTask, t('aiNewTaskHint')));
|
||
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.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].live && tasks[0].id !== state.selected;
|
||
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;
|
||
renderTask();
|
||
layout();
|
||
}
|
||
|
||
/** 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.railNews?.('ai', false);
|
||
hidePanelToast();
|
||
void refresh();
|
||
}
|
||
|
||
/** 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;
|
||
api.addEventListener('anomalous.ai_task', (event) => {
|
||
const detail = event?.detail || {};
|
||
const onPage = () => owner.modal?.classList.contains('visible') && owner.currentShellPage?.() === 'ai' && visible();
|
||
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),
|
||
});
|
||
}
|