ComfyUI imports every script under web/ on its own and at once, so any module of the big import cycle can run first. ui_ai_workbench.js wrote to a const of ui_ai_results.js as it loaded, which failed when ui_ai_results.js or ui_ai_step_body.js was entered first, and the whole plugin went with it until a reload. The value now lives in a module that imports nothing, and tests/module_cycles.mjs refuses the pattern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
7.8 KiB
JavaScript
157 lines
7.8 KiB
JavaScript
/**
|
|
* What a step of an AI task made, and where it leads (ui_ai_workbench.js draws the task): the
|
|
* pictures (the viewer) and, under a finished run's, the user's word on it (ui_ai_verdict.js),
|
|
* its models (their page), the combo, the spoken audio (▶), the canvas
|
|
* change (found on the canvas, undone while it can be), the scan; and a run's progress while it
|
|
* goes with its stop button and size warnings (ui_ai_run_live.js, kept live by
|
|
* updateRunProgress from the server's events). The task's Results view (ui_ai_outputs.js) uses
|
|
* the same model, combo and audio ways.
|
|
* `shared` is set by the workbench: the browser (`owner`), the task shown (`taskId`) and the
|
|
* activity entries of the task's canvas steps.
|
|
*/
|
|
|
|
import { app } from '../../../scripts/app.js';
|
|
import { translate as t } from './locales.js';
|
|
import { changeNodeId } from './activity_diff.js';
|
|
import { canUndo, undoEntry } from './canvas_undo.js';
|
|
import { anomalousAlert } from './ui_dialog.js';
|
|
import { openListedModel } from './ui_scan_lists.js';
|
|
import { listCombos } from './image_keep.js';
|
|
import { audioResult, outputUrl } from './ai_task_view.js';
|
|
import { imageResults } from './ui_ai_images.js';
|
|
import { canvasChanges } from './ui_canvas_changes.js';
|
|
import { estimateBox, runProgress } from './ui_ai_run_live.js';
|
|
import { runMapBox } from './ui_ai_run_map.js';
|
|
import { verdictRow } from './ui_ai_verdict.js';
|
|
import { iconButton } from './ui_ai_icons.js';
|
|
import { shared } from './ui_ai_shared.js';
|
|
|
|
/** A run step's progress with its stop button (while the task shown is the run's). */
|
|
const liveRun = progress => runProgress(progress, shared.taskId ? { task: shared.taskId, step: progress.step } : null);
|
|
|
|
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;
|
|
}
|
|
|
|
/** ▶ for an audio result: plays it, or pauses it while it plays. */
|
|
export function playButton(result) {
|
|
const play = button('anomalous-ai-play', '▶', () => {
|
|
const url = outputUrl(audioResult(result));
|
|
if (shared.player?.dataset?.url === url && !shared.player.paused) {
|
|
shared.player.pause();
|
|
return;
|
|
}
|
|
shared.player?.pause();
|
|
shared.player = new Audio(url);
|
|
shared.player.dataset.url = url;
|
|
shared.player.play().catch(() => {});
|
|
}, t('audioPlay'));
|
|
return play;
|
|
}
|
|
|
|
export async function openModel(result) {
|
|
const owner = shared.owner;
|
|
if (!owner || !result.rel) return;
|
|
await openListedModel(owner, { type: result.folder, path_idx: result.path_idx, rel: result.rel, filename: result.name || result.rel }, () => owner.goTo('ai'));
|
|
}
|
|
|
|
/** The combo named `name` on the Combos page (the page itself when it is not there any more). */
|
|
export async function openComboNamed(name) {
|
|
const owner = shared.owner;
|
|
const note = (await listCombos().catch(() => [])).find(item => item.data?.name === name || item.name === name);
|
|
if (note) await owner.openCombo(note);
|
|
else await owner.showNotebooks();
|
|
}
|
|
|
|
function locate(entry, note) {
|
|
const change = (entry?.detail?.changes || []).find(item => item.kind !== 'removed' && changeNodeId(item) !== null);
|
|
locateNode(change ? changeNodeId(change) : null, note);
|
|
}
|
|
|
|
/** Closes the browser on canvas node `id`, selected and centred (a note says when it is gone). */
|
|
export function locateNode(id, note) {
|
|
const graph = app.canvas?.graph || app.graph;
|
|
const node = id === null ? null : graph?.getNodeById?.(Number(id)) ?? graph?.getNodeById?.(id);
|
|
if (!node) {
|
|
if (note) note.textContent = t('activityLocateMissing');
|
|
else void anomalousAlert(t('activityLocateMissing'));
|
|
return;
|
|
}
|
|
shared.owner?.close?.();
|
|
app.canvas.selectNode?.(node);
|
|
app.canvas.centerOnNode?.(node);
|
|
app.canvas.setDirty?.(true, true);
|
|
}
|
|
|
|
/** A progress event for step `progress.step` drawn into `root` in place; false when it is not shown there. */
|
|
export function updateRunProgress(root, progress) {
|
|
const row = [...(root?.querySelectorAll('[data-step-ids]') || [])].find(item => item.dataset.stepIds.split(' ').includes(progress.step));
|
|
const body = row?.querySelector('.anomalous-ai-step-body');
|
|
if (!body) return false;
|
|
const fresh = liveRun(progress);
|
|
const there = body.querySelector('.anomalous-ai-run-progress');
|
|
if (there) there.replaceWith(fresh);
|
|
else body.append(fresh);
|
|
return true;
|
|
}
|
|
|
|
/** The links under a step: what it made and where that leads. */
|
|
export function resultLinks(step, changed = () => {}) {
|
|
const box = el('div', 'anomalous-ai-results');
|
|
if (step.tool === 'run_workflow' && step.state === 'running') box.append(liveRun({ state: 'submitting', ...step.progress, step: step.id, stopping: step.stop || step.progress?.stopping }));
|
|
const sizes = step.tool === 'run_workflow' ? estimateBox(step.estimate) : null;
|
|
if (sizes) box.append(sizes);
|
|
const shape = step.tool === 'run_workflow' ? runMapBox(step, { locateNode: id => locateNode(id, null), openSide: shared.openMap }) : null;
|
|
if (shape) box.append(shape);
|
|
const pictures = (step.results || []).filter(result => result.type === 'image');
|
|
if (pictures.length) box.append(imageResults(pictures, shared.owner));
|
|
if (step.tool === 'run_workflow' && step.state === 'done' && pictures.length && shared.taskId) box.append(verdictRow(step, shared.taskId));
|
|
const links = el('div', 'anomalous-ai-links');
|
|
const note = el('span', 'anomalous-ai-link-note');
|
|
for (const result of step.results || []) {
|
|
if (result.type === 'model') {
|
|
const chip = button('anomalous-ai-chip is-model', result.name || result.model_id, () => openModel(result), t('aiOpenModel'));
|
|
chip.disabled = !result.rel;
|
|
links.append(chip);
|
|
} else if (result.type === 'combo') {
|
|
links.append(button('anomalous-ai-chip is-combo', result.name, () => openComboNamed(result.name), t('aiOpenCombo')));
|
|
} else if (result.type === 'audio') {
|
|
const chip = el('span', 'anomalous-ai-chip is-audio');
|
|
chip.append(playButton(result), el('span', '', result.file.split('/').pop()));
|
|
links.append(chip);
|
|
} else if (result.type === 'canvas') {
|
|
const entry = shared.entries.get(result.activity);
|
|
box.append(canvasChanges(entry, { locate: change => locateNode(changeNodeId(change), note) }));
|
|
if (entry) {
|
|
// One node changed: its own row above already finds it.
|
|
if ((entry.detail?.changes || []).length > 1) links.append(iconButton('anomalous-ai-link', 'target', t('activityLocate'), () => locate(entry, note), { size: 14 }));
|
|
if (canUndo(app, entry)) {
|
|
const undo = iconButton('anomalous-ai-link', 'undo', t('activityUndo'), () => {
|
|
let done = t('activityUndone');
|
|
try { undoEntry(app, entry); } catch (_) { done = t('activityUndoGone'); }
|
|
undo.replaceWith(el('span', 'anomalous-ai-link-done', done));
|
|
changed();
|
|
}, { size: 14 });
|
|
links.append(undo);
|
|
}
|
|
}
|
|
} else if (result.type === 'scan') {
|
|
links.append(iconButton('anomalous-ai-link', 'search', t('aiOpenScan'), () => shared.owner?.openScanPage?.(), { size: 14 }));
|
|
}
|
|
}
|
|
if (pictures.length) links.append(iconButton('anomalous-ai-link', 'image', t('aiOpenGallery'), () => shared.owner?.goTo('gallery'), { size: 14 }));
|
|
if (links.childNodes.length) box.append(links, note);
|
|
return box.childNodes.length ? box : null;
|
|
}
|