Once an AI handed a blueprint, the flow over the task gets a second line: the steps kept with their models and picking a base, read from the card and the runs after it (bases run, pick, whole run), so the AI does nothing more for it. It shows whether the user was asked or not; a click goes to the step. A job sheet's run shows how far it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
14 KiB
JavaScript
181 lines
14 KiB
JavaScript
// The AI workbench's rules (web/modules/ai_task_view.js): folding reads, words, counts, addresses.
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
activityIds, audioResult, chainTools, duration, flowStages, planFlow, foldLooks, groupSteps, isVideo, panelGist, lookTools, newestPicture, outputCount, outputUrl, outputPicture, pictureShown,
|
|
pictureKey, picturePreview, uniquePictures, skillsBefore, stepDetail, taskCounts, taskFamilies, taskOutputs, taskResults, taskState, toolKey,
|
|
} from '../web/modules/ai_task_view.js';
|
|
|
|
const look = (tool, ok = true, state = 'done') => ({ kind: 'look', tool, ok, state, summary: tool });
|
|
const act = (tool, kind = 'canvas') => ({ kind, tool, ok: true, state: 'done', summary: `${tool}: K` });
|
|
|
|
// Reads that follow each other fold into one row; a running or failed read and what the user looks for stay rows.
|
|
const items = groupSteps([look('search_models'), look('describe_canvas'), act('report', 'note'), look('list_combos', false), look('search_images')]);
|
|
assert.deepEqual(items.map(item => item.type), ['looks', 'step', 'step', 'looks']);
|
|
assert.equal(items[0].steps.length, 2);
|
|
const inFlight = groupSteps([look('search_models'), look('describe_canvas', true, 'running')]);
|
|
assert.deepEqual(inFlight.map(item => item.type), ['looks', 'step'], 'an in-flight read stays visible until it finishes');
|
|
assert.deepEqual(groupSteps(undefined), []);
|
|
assert.deepEqual(lookTools([look('a'), look('b'), look('a')]), [{ tool: 'a', count: 2 }, { tool: 'b', count: 1 }]);
|
|
|
|
// The AI's work in a row (reads, canvas changes, decisions) folds into a chain of three or more.
|
|
const work = [look('find_nodes'), act('add_node'), act('add_node'), act('connect_nodes'), act('log_decision', 'decision'), act('run_workflow', 'run'), look('run_status')];
|
|
const chained = groupSteps(work);
|
|
assert.deepEqual(chained.map(item => item.type), ['chain', 'step', 'looks']);
|
|
assert.equal(chained[0].steps.length, 5);
|
|
assert.deepEqual(chainTools(chained[0].steps), [{ tool: 'look', kind: 'look', count: 1 }, { tool: 'add_node', kind: 'canvas', count: 2 },
|
|
{ tool: 'connect_nodes', kind: 'canvas', count: 1 }, { tool: 'log_decision', kind: 'decision', count: 1 }]);
|
|
assert.deepEqual(foldLooks(chained[0].steps).map(item => item.type), ['looks', 'step', 'step', 'step', 'step'], 'a chain opens to its steps, reads still folded');
|
|
assert.deepEqual(groupSteps([look('a'), act('set_prompt'), look('b')]).map(item => item.type), ['looks', 'step', 'looks'], 'one change among reads stays in sight');
|
|
assert.deepEqual(groupSteps([act('set_prompt'), act('set_values')]).map(item => item.type), ['step', 'step'], 'two steps stay as they are');
|
|
assert.deepEqual(groupSteps([look('a'), look('b'), look('c')]).map(item => item.type), ['looks'], 'only reads stay a row of reads');
|
|
const broken = groupSteps([act('add_node'), act('add_node'), { ...act('connect_nodes'), ok: false }, act('add_node'), act('add_node'), act('add_node')]);
|
|
assert.deepEqual(broken.map(item => item.type), ['step', 'step', 'step', 'chain'], 'a failed step stays in sight and splits the work');
|
|
assert.deepEqual(groupSteps([act('add_node'), act('add_node'), { ...act('set_values'), state: 'asking' }]).map(item => item.type), ['step', 'step', 'step'],
|
|
'a step waiting at a gate is not folded away');
|
|
|
|
// Words.
|
|
assert.equal(toolKey('place_combo'), 'aiTool_place_combo');
|
|
assert.equal(toolKey('x<y>'), 'aiTool_xy');
|
|
assert.equal(stepDetail(act('place_combo')), 'K');
|
|
assert.equal(stepDetail({ tool: 'describe_canvas', summary: 'describe_canvas' }), '');
|
|
assert.equal(stepDetail({ tool: 'report', summary: 'something else' }), 'something else');
|
|
|
|
// A task's state and counts.
|
|
assert.equal(taskState({ live: true, errors: 1 }), 'live');
|
|
assert.equal(taskState({ live: true, asks: 1 }), 'asks', 'a question waiting for the user comes first');
|
|
assert.equal(taskState({ waiting: true }), 'waiting', 'a task the user started, before an AI takes it');
|
|
assert.deepEqual(taskCounts({ asks: 2 }), [{ key: 'aiCountAsks', values: { count: 2 } }]);
|
|
assert.equal(taskState({ errors: 2 }), 'error');
|
|
assert.equal(taskState({}), 'idle', 'silence without an explicit end is not completion');
|
|
assert.equal(taskState({ ended: 100 }), 'done');
|
|
assert.deepEqual(taskCounts({ kinds: { canvas: 2, look: 5, speech: 1 }, images: 3, errors: 1 }), [
|
|
{ key: 'aiCountCanvas', values: { count: 2 } }, { key: 'aiCountImages', values: { count: 3 } },
|
|
{ key: 'aiCountSpeech', values: { count: 1 } }, { key: 'aiCountErrors', values: { count: 1 } },
|
|
]);
|
|
assert.deepEqual(taskCounts({}), []);
|
|
|
|
// What a task made, and where it is served.
|
|
const made = taskResults({ steps: [
|
|
{ results: [{ type: 'image', filename: 'a.png', subfolder: 'x' }, { type: 'canvas', activity: 'e1' }] },
|
|
{ results: [{ type: 'model', model_id: 'm' }, { type: 'weird' }] },
|
|
] });
|
|
assert.equal(made.image.length, 1);
|
|
assert.equal(made.canvas[0].activity, 'e1');
|
|
assert.equal(made.model.length, 1);
|
|
assert.equal(outputUrl({ filename: 'a b.png', subfolder: 'x/y' }), '/view?filename=a+b.png&subfolder=x%2Fy&type=output');
|
|
assert.deepEqual(audioResult({ file: 'audio/Aris/a.flac' }), { filename: 'a.flac', subfolder: 'audio/Aris', folder: 'output' });
|
|
assert.deepEqual(audioResult({ file: 'a.flac' }), { filename: 'a.flac', subfolder: '', folder: 'output' });
|
|
assert.equal(isVideo({ filename: 'clip.MP4' }), true);
|
|
assert.equal(isVideo({ filename: 'a.png' }), false);
|
|
|
|
// Output-only reuse: never compare an input/temp file with a same-named output by accident.
|
|
assert.equal(outputPicture({ filename: 'a.png', folder: 'temp' }), null);
|
|
assert.equal(outputPicture({ filename: 'a.mp4' }), null);
|
|
assert.equal(outputPicture({ filename: 'a.flac' }), null);
|
|
assert.deepEqual(outputPicture({ filename: 'a.png', subfolder: 'x' }), { type: 'output', filename: 'a.png', subfolder: 'x' });
|
|
const repeated = uniquePictures([{ filename: 'a.png' }, { filename: 'a.png', folder: 'output' }, { filename: 'a.png', subfolder: 'b' }]);
|
|
assert.equal(repeated.length, 2);
|
|
assert.notEqual(pictureKey(repeated[0]), pictureKey(repeated[1]));
|
|
assert.match(picturePreview({ filename: 'a b.png', subfolder: 'x/y' }), /^\/anomalous\/output_thumbnail\?filename=a\+b.png&subfolder=x%2Fy$/);
|
|
assert.equal(picturePreview({ filename: 'a.mp4' }), outputUrl({ filename: 'a.mp4' }));
|
|
assert.equal(pictureShown({ filename: 'a b.png', subfolder: 'x' }), '/anomalous/output_preview?filename=a+b.png&subfolder=x', 'larger views: never the huge original');
|
|
assert.equal(pictureShown({ filename: 'a.png', folder: 'input' }), outputUrl({ filename: 'a.png', folder: 'input' }));
|
|
|
|
// How long a step took.
|
|
assert.equal(duration(400), null);
|
|
assert.deepEqual(duration(48_200), { key: 'aiSeconds', values: { count: 48 } });
|
|
assert.deepEqual(duration(150_000), { key: 'aiMinutes', values: { count: 3 } });
|
|
|
|
// The flow: stages in the order they came, each with its last step's state.
|
|
const staged = (id, stage, extra = {}) => ({ id, stage, ok: true, state: 'done', results: [], ...extra });
|
|
const flow = flowStages({ steps: [
|
|
staged('a', 'model'), staged('b', 'prompt'), staged('c', 'model', { state: 'running' }),
|
|
{ id: 'd', kind: 'note', ok: true, results: [{ type: 'image', filename: 'x.png' }] }, { id: 'e', kind: 'user', ok: true },
|
|
staged('f', 'run', { state: 'asking' }), staged('g', 'canvas', { ok: false, state: 'stopped' }),
|
|
] });
|
|
assert.deepEqual(flow.map(item => [item.stage, item.state, item.first, item.count]), [
|
|
['model', 'running', 'a', 2], ['prompt', 'done', 'b', 1], ['picture', 'done', 'd', 1], ['run', 'asking', 'f', 1], ['canvas', 'error', 'g', 1],
|
|
]);
|
|
assert.deepEqual(flowStages(null), []);
|
|
|
|
// A job sheet's run says how far it is.
|
|
const sheetRun = flowStages({ steps: [staged('s', 'run', { state: 'running', sheet: 'x', sheet_total: 10, progress: { prompt: 7, prompts: 10 } })] });
|
|
assert.deepEqual(sheetRun[0].sheet, { done: 6, total: 10 });
|
|
assert.deepEqual(flowStages({ steps: [staged('s', 'run', { sheet: 'x', sheet_total: 10, prompts: 10 })] })[0].sheet, { done: 10, total: 10 });
|
|
|
|
// The blueprint's line, from what came after the card.
|
|
const card = (answers = {}) => ({ id: 'card', kind: 'panel', ok: true, answers, panel: { blocks: [{ type: 'plan', id: 'answer', steps: [
|
|
{ block: 'picture', title: 'Picture', model: 'm:a', model_name: 'Harem' },
|
|
{ block: 'refine_other_model', title: 'Redraw', model: 'm:b', model_name: 'Kirazuri', choices: [{ model_id: 'm:c', name: 'Other' }] },
|
|
{ block: 'detail_face_hands', title: 'Face and hands', model: null },
|
|
] }] } });
|
|
const lineOf = plan => plan.items.map(item => `${item.title || item.key}${item.model ? `/${item.model}` : ''}:${item.state}`);
|
|
assert.equal(planFlow({ steps: [staged('a', 'model')] }), null, 'no card, no line');
|
|
const waiting = planFlow({ steps: [card()] });
|
|
assert.equal(waiting.state, 'waiting');
|
|
assert.deepEqual(lineOf(waiting), ['Picture/Harem:ahead', 'aiFlowPick:ahead', 'Redraw/Kirazuri:ahead', 'Face and hands:ahead']);
|
|
const kept = { choice: ['build'], plan: [{ block: 'picture', model: 'm:a' }, { block: 'refine_other_model', model: 'm:c' }] };
|
|
const planRun = (id, extra) => ({ id, tool: 'run_workflow', stage: 'run', ok: true, state: 'done', results: [], ...extra });
|
|
const picking = planFlow({ steps: [card({ answer: kept }), planRun('b', { bases: { bases: [] } })] });
|
|
assert.equal(picking.state, 'built');
|
|
assert.deepEqual(lineOf(picking), ['Picture/Harem:done', 'aiFlowPick:asking', 'Redraw/Other:ahead'],
|
|
'a dropped step is gone, a swapped model shows; after the bases the pick waits for the user');
|
|
const pickStep = answers => ({ id: 'p', tool: 'pick_base', kind: 'panel', ok: true, answers, panel: { blocks: [{ type: 'picks', id: 'base' }] } });
|
|
const going = planFlow({ steps: [card({ answer: kept }), planRun('b', { bases: { bases: [] } }), pickStep({ base: { choice: ['b2'] } }), planRun('w', { state: 'running' })] });
|
|
assert.deepEqual(lineOf(going), ['Picture/Harem:done', 'aiFlowPick:done', 'Redraw/Other:running']);
|
|
assert.equal(going.items[2].first, 'w', 'a step leads to the run doing it');
|
|
const without = planFlow({ steps: [card({ answer: kept }), planRun('b', { bases: { bases: [] } }), planRun('w', { ok: false, state: 'error' })] });
|
|
assert.deepEqual(lineOf(without), ['Picture/Harem:done', 'aiFlowPick:skipped', 'Redraw/Other:error']);
|
|
assert.equal(planFlow({ steps: [card({ answer: { text: 'fix hands first' } })] }).state, 'change');
|
|
|
|
// The activity entries a task shows: the AI's canvas changes and the user's own.
|
|
assert.deepEqual(activityIds({ steps: [{ results: [{ type: 'canvas', activity: 'a1' }, { type: 'model' }] }, { tool: 'user_activity', activity: 'u1', results: [] }] }), ['a1', 'u1']);
|
|
|
|
console.log('ai_task_view: ok');
|
|
|
|
// What a panel holds, for its card in the timeline.
|
|
assert.deepEqual(panelGist({ blocks: [{ type: 'heading' }, { type: 'slider' }, { type: 'images', items: [{}, {}] }] }),
|
|
{ icon: 'image', parts: [{ key: 'aiGistCompare', values: {} }, { key: 'aiGistPictures', values: { count: 2 } }] });
|
|
assert.equal(panelGist({ blocks: [{ type: 'choices', options: [{}, {}] }, { type: 'images', items: [{}] }] }).icon, 'question', 'a question comes first');
|
|
assert.deepEqual(panelGist({ blocks: [{ type: 'run_map' }] }), { icon: 'nodes', parts: [{ key: 'aiGistMap', values: {} }] });
|
|
assert.deepEqual(panelGist({ blocks: [{ type: 'text' }] }), { icon: 'panel', parts: [] }, 'words only: no line under the title');
|
|
console.log('ai_task_view: panel gist ok');
|
|
|
|
// Results: each thing once, the steps that brought pictures newest first, the newest picture still there to keep from.
|
|
const outputs = taskOutputs({ steps: [
|
|
{ id: 'r1', results: [{ type: 'image', filename: 'a.png' }, { type: 'image', filename: 'b.png' }, { type: 'canvas', activity: 'e1' }] },
|
|
{ id: 'c1', results: [{ type: 'combo', name: 'K' }, { type: 'model', model_id: 'm1' }, { type: 'canvas', activity: 'e2' }] },
|
|
{ id: 'r2', results: [{ type: 'image', filename: 'c.png', gone: true }, { type: 'image', filename: 'a.png' }, { type: 'image', filename: 'v.mp4' }] },
|
|
{ id: 'n1', results: [{ type: 'combo', name: 'K' }, { type: 'model', model_id: 'm1' }, { type: 'download', file: 'hand.safetensors', size: 9 },
|
|
{ type: 'audio', file: 'audio/A/1.flac' }, { type: 'scan' }] },
|
|
] });
|
|
assert.deepEqual(outputs.runs.map(run => [run.step.id, run.pictures.map(image => image.filename)]), [['r2', ['c.png', 'v.mp4']], ['r1', ['a.png', 'b.png']]]);
|
|
assert.deepEqual([outputs.combos.length, outputs.models.length, outputs.downloads.map(item => item.file), outputs.audio.length, outputs.canvas], [1, 1, ['hand.safetensors'], 1, 2]);
|
|
assert.equal(outputCount(outputs), 4 + 1 + 1 + 1 + 1);
|
|
assert.deepEqual(newestPicture(outputs), { type: 'output', filename: 'a.png', subfolder: '' }, 'past a deleted picture and a video');
|
|
assert.equal(newestPicture(taskOutputs({ steps: [] })), null);
|
|
assert.equal(outputCount(taskOutputs(null)), 0);
|
|
console.log('ai_task_view: results ok');
|
|
|
|
// A run says which skills the AI fetched since the run before it.
|
|
const skill = (id, name) => ({ id, tool: 'use_skill', kind: 'skill', skill: name });
|
|
const run = id => ({ id, tool: 'run_workflow', kind: 'run' });
|
|
const timeline = [skill('a', 'old'), run('r1'), skill('b', 'prompt_pose'), look('describe_canvas'), skill('c', 'other'), run('r2'), run('r3')];
|
|
assert.deepEqual(skillsBefore(timeline, timeline[5]).map(step => step.skill), ['prompt_pose', 'other']);
|
|
assert.deepEqual(skillsBefore(timeline, timeline[1]).map(step => step.skill), ['old']);
|
|
assert.deepEqual(skillsBefore(timeline, timeline[6]), [], 'only since the run before');
|
|
assert.deepEqual(skillsBefore(undefined, run('x')), []);
|
|
console.log('ai_task_view: skills ok');
|
|
|
|
// The task list: a line of tasks stands together under its first, by its latest news.
|
|
const task = (id, updated, started, from) => ({ id, updated, started, ...(from ? { from } : {}) });
|
|
const shape = tasks => taskFamilies(tasks).map(item => [item.task.id, item.child, item.size, item.newest]);
|
|
assert.deepEqual(shape([task('c', 90, 30, 'b'), task('x', 80, 40), task('b', 70, 20, 'a'), task('a', 60, 10)]), [
|
|
['a', false, 3, 90], ['b', true, 1, 90], ['c', true, 1, 90], ['x', false, 1, 80],
|
|
], 'the first on top, however far down the news is');
|
|
assert.deepEqual(shape([task('new', 9, 9, 'old'), task('other', 8, 8)]), [['new', false, 1, 9], ['other', false, 1, 8]], 'a task whose first is gone starts a family');
|
|
assert.deepEqual(shape([task('early', 9, 1, 'late'), task('late', 8, 5)]).map(row => row[0]), ['late', 'early'], 'linked by hand, the one it goes on from still leads');
|
|
assert.deepEqual(shape([task('p', 9, 1, 'q'), task('q', 8, 2, 'p')]).map(row => row.slice(0, 3)), [['p', false, 1], ['q', false, 1]], 'a loop on disk: each stands alone, once');
|
|
assert.deepEqual(taskFamilies([]), []);
|
|
console.log('ai_task_view: families ok');
|