Files
DemonGatanjieuandClaude Opus 5.5 b368a4fb35 AI task list: lines of tasks stand together, a more button on each row
Follow-ups hang one step in under the first task of their line, so the
list shows what goes on from what; a filter's tasks still show alone. Each
row has the head's menu, without opening the task. Removing a task joins
those after it to the one before. The pick list now stands over everything
at the place the menu opened: the head is drawn anew under an open menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:42:50 +08:00

320 lines
15 KiB
JavaScript

/**
* The AI workbench's rules without DOM (ui_ai_workbench.js and ui_ai_chain.js draw them): which
* steps fold together, what each tool is called, what a task counts, and where a result leads. A task
* and its steps are api/ai_tasks.py's shapes.
*/
/**
* The task list by family: a task comes with all that go on from it (`from`, however far), those
* one step in under it, by when they started. Families stand by their latest news, as `tasks`
* (newest first) has them; a task whose `from` is not in `tasks` starts a family.
* Items: { task, child, size (the family's, on its first task), newest (its latest news) }.
*/
export function taskFamilies(tasks) {
const byId = new Map(tasks.map(task => [task.id, task]));
const headOf = (task) => {
const seen = new Set();
let head = task;
while (byId.has(head.from)) {
if (seen.has(head.id)) return task; // going round: set_from refuses that, a hand-edited file need not
seen.add(head.id);
head = byId.get(head.from);
}
return head;
};
const families = new Map(); // first task -> the family, newest first; in the order first met
for (const task of tasks) {
const head = headOf(task);
families.set(head, [...(families.get(head) || []), task]);
}
return [...families].flatMap(([head, members]) => {
const rest = members.filter(task => task !== head).sort((a, b) => a.started - b.started);
return [head, ...rest].map(task => ({ task, child: task !== head, size: task === head ? members.length : 1, newest: members[0].updated }));
});
}
/** The kinds of step, in the order a task's counts name them. */
export const KINDS = ['panel', 'user', 'decision', 'canvas', 'run', 'speech', 'download', 'scan', 'show', 'note', 'act', 'outside', 'skill', 'look'];
const WORK = new Set(['look', 'canvas', 'decision']); // the AI going about the work, not what the user looks for
const CHAIN_MIN = 3;
const settled = step => step.ok && !['running', 'asking', 'allowed'].includes(step.state);
/**
* The timeline. Reads, canvas changes and decisions that follow each other, finished and fine,
* fold into one row (`chain`) when there are three or more and two are not reads, so the work does
* not push what the user looks for (their words, the AI's notes and questions, panels, runs,
* errors) out of sight; else the reads among them fold alone (`looks`). A running, waiting or
* failed step stays a row of its own.
*/
export function groupSteps(steps) {
const items = [];
let work = [];
const close = () => {
if (work.length >= CHAIN_MIN && work.filter(step => step.kind !== 'look').length >= 2) items.push({ type: 'chain', steps: work });
else items.push(...foldLooks(work));
work = [];
};
for (const step of steps || []) {
if (WORK.has(step.kind) && settled(step)) {
work.push(step);
} else {
close();
items.push({ type: 'step', step });
}
}
close();
return items;
}
/** `steps` with the finished reads that follow each other as one `looks` item (a chain opens to these). */
export function foldLooks(steps) {
const items = [];
for (const step of steps) {
const last = items.at(-1);
if (step.kind === 'look' && settled(step)) {
if (last?.type === 'looks') last.steps.push(step);
else items.push({ type: 'looks', steps: [step] });
} else {
items.push({ type: 'step', step });
}
}
return items;
}
/** What a chain did, in the order first done: each tool with its kind and how often, the reads counted together as `look`. */
export function chainTools(steps) {
const counts = new Map();
for (const step of steps) {
const tool = step.kind === 'look' ? 'look' : step.tool;
const item = counts.get(tool) || { tool, kind: step.kind, count: 0 };
item.count += 1;
counts.set(tool, item);
}
return [...counts.values()];
}
/**
* The stages a task's steps went through, in the order they first came, with the last step's
* state for each: 'asking' (waits for the user's OK), 'running', 'error' or 'done'. Notes that
* hand in pictures count as the picture stage. `first`: the stage's first step, `count` its steps.
*/
export function flowStages(task) {
const stages = new Map();
for (const step of task?.steps || []) {
const stage = step.stage || ((step.results || []).some(result => result.type === 'image') ? 'picture' : null);
if (!stage) continue;
const item = stages.get(stage) || { stage, first: step.id, count: 0 };
item.count += 1;
item.state = step.state === 'asking' ? 'asking'
: step.state === 'running' || step.state === 'allowed' ? 'running'
: step.ok === false ? 'error' : 'done';
stages.set(stage, item);
}
return [...stages.values()];
}
/**
* What a panel holds, for its card in the timeline: `icon` (question, image, nodes, models or
* panel) and `parts` `[{ key, values }]`: what it asks, pictures, a before-and-after, models, a
* node structure; none for words only.
*/
export function panelGist(panel) {
const blocks = (panel?.blocks || []).filter(Boolean);
const has = type => blocks.some(block => block.type === type);
const count = type => blocks.filter(block => block.type === type).reduce((sum, block) => sum + (block.items?.length || 0), 0);
const parts = [];
const choices = blocks.find(block => block.type === 'choices');
if (choices) parts.push({ key: 'aiGistAsk', values: { count: choices.options?.length || 0 } });
else if (has('input')) parts.push({ key: 'aiGistAskFree', values: {} });
if (has('picks')) parts.push({ key: 'aiGistPick', values: { count: count('picks') } });
if (has('slider')) parts.push({ key: 'aiGistCompare', values: {} });
if (has('images')) parts.push({ key: 'aiGistPictures', values: { count: count('images') } });
if (has('models')) parts.push({ key: 'aiGistModels', values: { count: count('models') } });
if (has('run_map') || has('diagram')) parts.push({ key: 'aiGistMap', values: {} });
const icon = choices || has('input') ? 'question' : has('picks') || has('slider') || has('images') ? 'image'
: has('run_map') || has('diagram') ? 'nodes' : has('models') ? 'models' : 'panel';
return { icon, parts };
}
/** The locale key naming a step's tool. */
export function toolKey(tool) {
return `aiTool_${String(tool || '').replace(/[^a-z_]/g, '')}`;
}
/** The skills the AI fetched (use_skill steps, api/ai_skills.py) after the run before `step` and before it. */
export function skillsBefore(steps, step) {
const list = steps || [];
const found = [];
for (let at = list.findIndex(item => item.id === step?.id) - 1; at >= 0 && list[at].tool !== 'run_workflow'; at--) {
if (list[at].tool === 'use_skill') found.unshift(list[at]);
}
return found;
}
/** What a step was about: its summary without the tool's name ("place_combo: K" → "K"). */
export function stepDetail(step) {
const summary = String(step?.summary || '');
const tool = String(step?.tool || '');
return summary.startsWith(`${tool}: `) ? summary.slice(tool.length + 2) : (summary === tool ? '' : summary);
}
/** The different tools of folded reads, in the order first used, with how often. */
export function lookTools(steps) {
const counts = new Map();
for (const step of steps) counts.set(step.tool, (counts.get(step.tool) || 0) + 1);
return [...counts].map(([tool, count]) => ({ tool, count }));
}
/**
* 'waiting' for a task the user started that no AI took yet, 'asks' while a question of the
* AI's waits for the user, 'live' while the AI works (or a run
* it started goes on), 'error' when a step failed, else 'done'.
*/
export function taskState(summary) {
if (summary?.waiting) return 'waiting';
if (summary?.asks) return 'asks';
if (summary?.live) return 'live';
return summary?.errors ? 'error' : summary?.ended ? 'done' : 'idle';
}
/** A task's counts for its row: changes, pictures, sounds… as `[{ key, values }]` (only those it has). */
export function taskCounts(summary) {
const kinds = summary?.kinds || {};
const counts = [
['aiCountAsks', summary?.asks], ['aiCountCanvas', kinds.canvas], ['aiCountImages', summary?.images], ['aiCountSpeech', kinds.speech],
['aiCountDownload', kinds.download], ['aiCountScan', kinds.scan], ['aiCountOutside', kinds.outside], ['aiCountErrors', summary?.errors],
];
return counts.filter(([, count]) => count > 0).map(([key, count]) => ({ key, values: { count } }));
}
/** Everything a task made, by kind of result, for its header. */
export function taskResults(task) {
const out = { image: [], model: [], combo: [], audio: [], canvas: [] };
for (const step of task?.steps || []) {
for (const result of step.results || []) if (out[result.type]) out[result.type].push(result);
}
return out;
}
/**
* What a task made, for the side column's Results view, each thing once: `runs` (the steps that
* brought pictures, newest first, with their pictures), the combos placed, the models used, the
* downloads, the sounds, and `canvas`, how many canvas changes.
*/
export function taskOutputs(task) {
const out = { runs: [], combos: [], models: [], downloads: [], audio: [], canvas: 0 };
const seen = new Set();
const first = key => !seen.has(key) && Boolean(seen.add(key));
for (const step of task?.steps || []) {
const pictures = [];
for (const result of step.results || []) {
if (result.type === 'image') {
if (result.filename && first(`image ${pictureKey(result)}`)) pictures.push(result);
} else if (result.type === 'combo') {
if (first(`combo ${result.name}`)) out.combos.push(result);
} else if (result.type === 'model') {
if (first(`model ${result.model_id}`)) out.models.push(result);
} else if (result.type === 'download') {
if (result.file && first(`download ${result.file}`)) out.downloads.push(result);
} else if (result.type === 'audio') {
if (first(`audio ${result.file}`)) out.audio.push(result);
} else if (result.type === 'canvas') out.canvas += 1;
}
if (pictures.length) out.runs.unshift({ step, pictures });
}
return out;
}
/** How many things `outputs` (taskOutputs) holds: what the task made, not its canvas changes. */
export function outputCount(outputs) {
return outputs.runs.reduce((sum, run) => sum + run.pictures.length, 0)
+ outputs.combos.length + outputs.models.length + outputs.downloads.length + outputs.audio.length;
}
/** The newest output picture of `outputs` still there, as the keep menu takes it: what Results keeps from. */
export function newestPicture(outputs) {
for (const run of outputs.runs) {
const image = run.pictures.find(result => !result.gone && outputPicture(result));
if (image) return outputPicture(image);
}
return null;
}
/** The activity log entries a task's steps show: the AI's canvas changes and the user's own. */
export function activityIds(task) {
return (task?.steps || []).flatMap(step => [
...(step.results || []).filter(result => result.type === 'canvas').map(result => result.activity),
...(step.tool === 'user_activity' && step.activity ? [step.activity] : []),
]);
}
/** The address ComfyUI's /view serves an output picture or sound at. */
export function outputUrl(result) {
const params = new URLSearchParams({ filename: result.filename, subfolder: result.subfolder || '', type: result.folder || 'output' });
return `/view?${params}`;
}
/** An audio result's file ("audio/Aris/a.flac") as an output-folder result. */
export function audioResult(result) {
const path = String(result.file || '').replace(/\\/g, '/');
const at = path.lastIndexOf('/');
return { filename: path.slice(at + 1), subfolder: at > 0 ? path.slice(0, at) : '', folder: 'output' };
}
export function isVideo(result) {
return /\.(mp4|webm)$/i.test(String(result?.filename || ''));
}
/** Only output pictures can enter the gallery's parameter and keep workflows. */
export function outputPicture(result) {
if (!result?.filename || isVideo(result) || (result.folder && result.folder !== 'output')) return null;
if (!/\.(png|jpe?g|webp|gif)$/i.test(result.filename)) return null;
return { type: 'output', filename: result.filename, subfolder: result.subfolder || '' };
}
export const pictureKey = image => JSON.stringify([image.folder || 'output', image.subfolder || '', image.filename]);
/** One file may be mentioned by both a run and a report; keep its first position. */
export function uniquePictures(images) {
const seen = new Set();
return (images || []).filter(image => {
if (!image?.filename) return false;
const key = pictureKey(image);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** What a view larger than a thumbnail shows of an output picture: itself, or a 4096 px copy of a very large one (gallery_routes.py output_preview). */
export function pictureShown(result) {
const source = outputPicture(result);
return source ? `/anomalous/output_preview?${new URLSearchParams({ filename: source.filename, subfolder: source.subfolder })}` : outputUrl(result);
}
/** Thumbnails for output pictures; other media retain their actual ComfyUI address. */
export function picturePreview(result) {
const source = outputPicture(result);
return source ? `/anomalous/output_thumbnail?${new URLSearchParams({ filename: source.filename, subfolder: source.subfolder })}` : outputUrl(result);
}
/**
* When the AI last wrote the numbers of panel step `step` (its progress bars), if the task went on
* past them since: a run ended, the AI drew another panel or left a note, or the task ended. Else 0.
*/
export function panelPassed(task, step) {
const written = step.panel?.changed || step.at;
const later = (task.steps || []).some(other => other !== step && (
(['panel', 'note'].includes(other.kind) && other.at > written)
|| (other.kind === 'run' && !['running', 'asking', 'allowed'].includes(other.state) && other.at + (other.ms || 0) / 1000 > written)));
return later || task.ended > written ? written : 0;
}
/** How long a step took, as `{ key, values }`, or null for quick ones. */
export function duration(ms) {
if (!(ms >= 1000)) return null;
const seconds = Math.round(ms / 1000);
return seconds < 60 ? { key: 'aiSeconds', values: { count: seconds } } : { key: 'aiMinutes', values: { count: Math.round(seconds / 60) } };
}