Files
DemonGatanjieuandClaude Opus 5.5 3f36ebee89 Fix AMB not loading: the viewer's size words pulled in a cycle
ui_gallery_viewer.js (8c044d0) imported formatSize from
ui_model_download.js, which leads through the settings page and
browser.js back to ui_ai_workbench.js; with ui_ai_images.js ->
ui_gallery_detail.js -> ui_gallery_viewer.js at the start of that chain,
ui_ai_workbench.js called setMarker before ui_ai_images.js had run, and
the whole plugin failed to load ("Cannot access 'marker' before
initialization"). formatSize now lives in file_size.js, which imports
nothing, and every module takes it from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:26:36 +08:00

273 lines
12 KiB
JavaScript

/**
* What an AI task made, as the side column's Results view (ui_ai_side_views.js), newest first:
* the pictures by the step that brought them, with the gallery's tools on each (ui_ai_images.js);
* the workflow (the task's own tab or the open canvas) and the combos it placed; the newest
* picture's prompts; the models it used and downloaded; the sounds. What made the newest picture
* (its whole workflow, its combo, its prompts) is kept from here as that picture's keep menu
* keeps it (ui_keep_menu.js). Over the timeline a line counts what the task made and leads to the
* view (drawOutputsLine); the view's button in the switch counts what came since it was last
* seen (newOutputs). The rules are ai_task_view.js taskOutputs.
*/
import { translate as t } from './locales.js';
import { timeLabel } from './activity_log.js';
import { newestPicture, outputCount, pictureKey, taskOutputs } from './ai_task_view.js';
import { imagePrompts } from './image_keep.js';
import { keepPictureAs } from './ui_keep_menu.js';
import { anomalousAlert, copyText } from './ui_dialog.js';
import { showTab } from './mcp_task_tab.js';
import { formatSize } from './file_size.js';
import { imageResults } from './ui_ai_images.js';
import { icon, iconButton } from './ui_ai_icons.js';
import { openComboNamed, openModel, playButton } from './ui_ai_results.js';
import { showStepRow } from './ui_ai_decisions.js';
import { sideHead } from './ui_ai_side.js';
const RUNS_SHOWN = 6; // steps with pictures drawn at first; the earlier ones on request
const PROMPTS_KEPT = 20;
const SAID_MS = 2000;
const prompts = new Map(); // picture key → its prompts (a promise), the latest few
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = String(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 pictureCount = outputs => outputs.runs.reduce((sum, run) => sum + run.pictures.length, 0);
/** Where the task's workflow is: `{ tab }` its own tab, `{ tab: null }` the open canvas the AI changed; null when neither. */
function workflowOf(task, outputs) {
const tab = task.canvas_mode !== 'current' ? task.canvas?.tab : null;
if (tab?.path) return { tab };
return outputs.canvas ? { tab: null } : null;
}
/** What the task made, in words ("8 pictures", "a workflow"…), only what it has. */
function countWords(task, outputs) {
const models = outputs.models.length + outputs.downloads.length;
return [
pictureCount(outputs) && t('aiOutPictures', { count: pictureCount(outputs) }),
workflowOf(task, outputs) && t('aiOutWorkflow'),
outputs.combos.length && t('aiOutCombos', { count: outputs.combos.length }),
models && t('aiOutModels', { count: models }),
outputs.audio.length && t('aiOutSounds', { count: outputs.audio.length }),
].filter(Boolean);
}
/**
* How many things the task made since the Results view was last seen (`shown`: it is on screen
* now, so everything is seen); what a task had when it was opened is not new.
*/
export function newOutputs(side, task, shown) {
const count = outputCount(taskOutputs(task));
if (shown || side.outputsSeen === null || side.outputsSeen === undefined) side.outputsSeen = count;
return Math.max(0, count - side.outputsSeen);
}
/** The line over the timeline into `holder`: what the task made, counted, and `open()` to the view; empty while it made nothing. */
export function drawOutputsLine(holder, task, open) {
const words = countWords(task, taskOutputs(task));
const key = JSON.stringify(words);
if (holder.dataset.key === key) return;
holder.dataset.key = key;
if (!words.length) {
holder.replaceChildren();
return;
}
const line = el('div', 'anomalous-ai-out-line');
line.append(icon('tray', 16, 'anomalous-ai-out-line-icon'), el('span', 'anomalous-ai-out-line-words', t('aiOutLine', { what: words.join(' · ') })),
iconButton('anomalous-ai-side-open', 'sidebar', t('aiOutSee'), open, { size: 14 }));
holder.replaceChildren(line);
}
function section(name, title, ...more) {
const box = el('section', 'anomalous-ai-out-part');
const head = el('div', 'anomalous-ai-out-head');
head.append(icon(name, 15), el('span', 'anomalous-ai-out-title', title), ...more);
box.append(head);
return box;
}
/** Keeps what made the newest picture as `kind`, as the picture's keep menu does (a toast says where it went). */
function keepButton(ctx, kind, name, picture) {
const node = iconButton('anomalous-ai-link', name, t(`aiOutKeep_${kind}`), async () => {
node.disabled = true;
try {
await keepPictureAs(ctx.owner, kind, picture);
} finally {
node.disabled = false;
}
}, { size: 14, title: t(`aiOutKeepTip_${kind}`) });
return node;
}
/** A step's pictures, headed by its number and time (a way back to it in the timeline); groups sit side by side. */
function runGroup(run, ctx) {
const group = el('div', 'anomalous-ai-out-group');
group.append(button('anomalous-ai-out-run', t('aiOutRun', { number: ctx.numberOf(run.step.id), time: timeLabel(run.step.at) }),
() => showStepRow(run.step.id), t('aiOutRunShow')), imageResults(run.pictures, ctx.owner, { compact: true }));
return group;
}
function picturesPart(outputs, ctx) {
if (!outputs.runs.length) return null;
const count = pictureCount(outputs);
const box = section('image', t('aiOutPicturesTitle'), el('span', 'anomalous-ai-out-count', count));
if (count > 1) box.firstChild.append(el('span', 'anomalous-ai-out-hint', t('aiTickHint')));
const groups = el('div', 'anomalous-ai-out-groups');
box.append(groups);
const draw = runs => groups.append(...runs.map(run => runGroup(run, ctx)));
draw(outputs.runs.slice(0, RUNS_SHOWN));
const earlier = outputs.runs.slice(RUNS_SHOWN);
if (earlier.length) {
const more = button('anomalous-ai-link anomalous-ai-out-more', t('aiOutEarlier', { count: earlier.length }), () => {
more.remove();
draw(earlier);
});
box.append(more);
}
return box;
}
function workflowPart(where, outputs, newest, ctx) {
if (!where && !newest) return null;
const box = section('workflows', t('aiOutWorkflowTitle'));
const card = el('div', 'anomalous-ai-out-card');
const words = el('div', 'anomalous-ai-out-words');
words.append(el('span', 'anomalous-ai-out-name', where?.tab ? t('aiOutTab', { name: where.tab.name }) : t(where ? 'aiOutCanvas' : 'aiOutRunWorkflow')));
if (outputs.canvas) words.append(el('span', 'anomalous-ai-out-sub', t('aiOutChanges', { count: outputs.canvas })));
card.append(icon('workflows', 18, 'anomalous-ai-out-icon'), words);
const tools = el('div', 'anomalous-ai-links');
if (where) {
tools.append(iconButton('anomalous-ai-link', 'target', t('aiOutOpenCanvas'), async () => {
if (where.tab && !await showTab(where.tab.path)) await anomalousAlert(t('aiCanvasGone', { name: where.tab.name }));
else ctx.showCanvas();
}, { size: 14 }));
}
if (newest) tools.append(keepButton(ctx, 'recipe', 'workflows', newest));
box.append(card);
if (tools.childNodes.length) box.append(tools);
if (!newest) box.append(el('div', 'anomalous-ai-out-note', t('aiOutNeedPicture')));
return box;
}
function combosPart(outputs, newest, ctx) {
if (!outputs.combos.length && !newest) return null;
const box = section('combos', t('aiOutCombosTitle'));
const chips = el('div', 'anomalous-ai-links');
for (const combo of outputs.combos) chips.append(button('anomalous-ai-chip is-combo', combo.name, () => openComboNamed(combo.name), t('aiOpenCombo')));
if (newest) chips.append(keepButton(ctx, 'combo', 'plus', newest));
box.append(chips);
return box;
}
/** A picture's prompts, read once (the latest few kept; a failed read is tried again next time). */
function promptsOf(picture) {
const key = pictureKey(picture);
if (!prompts.has(key)) {
prompts.set(key, imagePrompts(picture).catch((error) => {
prompts.delete(key);
throw error;
}));
if (prompts.size > PROMPTS_KEPT) prompts.delete(prompts.keys().next().value);
}
return prompts.get(key);
}
function promptBox(role, text) {
const box = el('div', `anomalous-ai-out-prompt is-${role}`);
const head = el('div', 'anomalous-ai-out-prompt-head');
const copy = iconButton('anomalous-ai-out-copy', 'copy', t('aiOutCopy'), async () => {
copy.lastChild.textContent = t(await copyText(text) ? 'aiOutCopied' : 'aiOutNotCopied');
setTimeout(() => { copy.lastChild.textContent = t('aiOutCopy'); }, SAID_MS);
}, { size: 13 });
head.append(el('span', 'anomalous-ai-out-role', t(role === 'positive' ? 'aiOutPositive' : 'aiOutNegative')), copy);
const words = el('div', 'anomalous-ai-out-prompt-text', text);
words.title = text;
box.append(head, words);
return box;
}
function promptsPart(picture, ctx) {
const box = section('prompts', t('aiOutPromptTitle'), el('span', 'anomalous-ai-out-hint', t('aiOutPromptOf')));
const holder = el('div', 'anomalous-ai-out-prompts', t('loading'));
box.append(holder);
promptsOf(picture).then(({ positive, negative }) => {
const parts = [['positive', positive], ['negative', negative]].filter(([, text]) => text.trim());
if (!parts.length) { // a workflow without prompts (an upscale, a copy): nothing to show
box.remove();
return;
}
holder.replaceChildren(...parts.map(([role, text]) => promptBox(role, text)));
const tools = el('div', 'anomalous-ai-links');
tools.append(keepButton(ctx, 'prompt', 'prompts', picture));
box.append(tools);
}, (error) => {
console.warn('[AMB] Could not read the newest picture\'s prompts:', error);
holder.textContent = t('aiOutPromptNone');
});
return box;
}
function modelsPart(outputs) {
if (!outputs.models.length && !outputs.downloads.length) return null;
const box = section('models', t('aiOutModelsTitle'));
const chips = el('div', 'anomalous-ai-links');
for (const item of outputs.downloads) {
const chip = el('span', 'anomalous-ai-chip is-download');
chip.title = [item.name, item.file].filter(Boolean).join('\n');
chip.append(el('span', 'anomalous-ai-out-new', t('aiOutDownloadRequested')), el('span', 'anomalous-ai-out-chip-name', item.file));
if (item.size) chip.append(el('span', 'anomalous-ai-out-size', formatSize(item.size)));
chips.append(chip);
}
for (const model of outputs.models) {
const chip = button('anomalous-ai-chip is-model', model.name || model.model_id, () => openModel(model), t('aiOpenModel'));
chip.disabled = !model.rel;
chips.append(chip);
}
box.append(chips);
return box;
}
function soundsPart(outputs) {
if (!outputs.audio.length) return null;
const box = section('pulse', t('aiOutSoundsTitle'));
const chips = el('div', 'anomalous-ai-links');
for (const result of outputs.audio) {
const chip = el('span', 'anomalous-ai-chip is-audio');
chip.append(playButton(result), el('span', '', result.file.split('/').pop()));
chips.append(chip);
}
box.append(chips);
return box;
}
/**
* What `task` made into `pane`. `ctx`: {owner, numberOf(stepId), close(), showCanvas()}. Drawn
* again only when that changed, so the view stays where it was scrolled while the AI works.
*/
export function renderOutputsView(pane, task, ctx) {
const outputs = taskOutputs(task);
const where = workflowOf(task, outputs);
const key = JSON.stringify([task.id, outputs.runs.map(run => [run.step.id, ctx.numberOf(run.step.id), run.pictures]),
outputs.combos, outputs.models, outputs.downloads, outputs.audio, outputs.canvas, where]);
if (pane.dataset.key === key) return;
pane.dataset.key = key;
const newest = newestPicture(outputs);
const parts = [picturesPart(outputs, ctx), workflowPart(where, outputs, newest, ctx), combosPart(outputs, newest, ctx),
newest && promptsPart(newest, ctx), modelsPart(outputs), soundsPart(outputs)].filter(Boolean);
const body = el('div', 'anomalous-ai-stage-body anomalous-ai-outputs');
body.append(...(parts.length ? parts : [el('div', 'anomalous-ai-stage-empty', t('aiOutNone'))]));
pane.replaceChildren(sideHead(t('aiSideOutputs'), ctx.close), body);
}