Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_ai_run_map.js
T
DemonGatanjieuandClaude Opus 5.5 3092e201ec Make the ways to the right column easy to see
Each panel's line in the timeline becomes a card: an icon of what it holds,
a line of what is in it, and a solid "Show on the right" button that turns
into "On the right now" while the panel is in view on the stage. A run's
"See it large on the right" uses the same button, and the blue text links
under steps become small buttons with icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:22:46 +08:00

161 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What a run really was (api/ai_run_map.py): under a run step, one line that says it (the base
* model, how many LoRAs, the size, the steps), what changed since the run before as chips, and,
* opened, its nodes as boxes and links drawn by the stage's diagram (ui_ai_stage.js), each box
* locating its node on the canvas. The words are the user's language, made here from the facts
* the server read; show_run_map puts the same on the stage (a "run_map" block), and the step's
* "see it large" shows it in the side column (ui_ai_side_views.js). Opened, it is told simply
* (ui_ai_run_story.js: which model and LoRAs feed which sampler, from where, to where) or drawn
* in full; the user's choice of the two is kept on this browser.
*/
import { translate as t } from './locales.js';
import { diagram } from './ui_ai_stage.js';
import { iconButton } from './ui_ai_icons.js';
import { runStory } from './ui_ai_run_story.js';
const LABELLED = new Set(['MODEL', 'LATENT', 'IMAGE', 'CONDITIONING', 'UPSCALE_MODEL', 'MASK', 'CONTROL_NET']);
const COLOR = { model: 'purple', lora: 'purple', prompt: 'green', sampler: 'blue', size: 'yellow', image: 'yellow', upscale: 'yellow' };
const MIN_SCALE = 0.6;
const VIEW_KEY = 'anomalous.aiRunMapView';
function keptView() {
try { return localStorage.getItem(VIEW_KEY) === 'full' ? 'full' : 'simple'; } catch (_) { return 'simple'; }
}
const KEYS = ['steps', 'cfg', 'denoise', 'sampler_name', 'scheduler', 'width', 'height', 'batch_size', 'strength_model', 'strength_clip', 'text'];
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
const fileName = value => String(value || '').replace(/\\/g, '/').split('/').pop().replace(/\.[a-z0-9]+$/i, '');
const size = facts => (facts.width ? `${facts.width}×${facts.height}${facts.batch > 1 ? ` ×${facts.batch}` : ''}` : '');
/** A box's second line: what matters about it, in the user's words. */
export function boxDetail(box) {
const facts = box.facts || {};
switch (box.role) {
case 'lora': return [facts.file, Number.isFinite(facts.strength) ? `(${facts.strength})` : ''].filter(Boolean).join(' ');
case 'prompt': return `${t(facts.polarity === 'negative' ? 'aiMapNegative' : 'aiMapPositive')}:${facts.text || '—'}`;
case 'sampler': return [size(facts), facts.steps ? t('aiRunSteps', { steps: facts.steps }) : '', facts.cfg !== undefined ? `CFG ${facts.cfg}` : '',
facts.sampler_name || '', facts.denoise !== undefined && facts.denoise < 1 ? t('aiMapDenoise', { value: facts.denoise }) : ''].filter(Boolean).join(' · ');
case 'size': return size(facts);
case 'save': return facts.prefix || '';
case 'image': case 'model': case 'upscale': return facts.file || size(facts);
default: return (facts.values || []).map(([key, value]) => `${key} ${value}`).join(' · ') || facts.file || size(facts);
}
}
/** One line for the whole run: base model, LoRAs, size, steps, how many nodes. */
export function mapLine(map) {
const boxes = map?.boxes || [];
const parts = [];
const base = boxes.find(box => box.role === 'model' && box.facts?.file);
if (base) parts.push(t('aiMapBase', { name: base.facts.file }));
const loras = boxes.filter(box => box.role === 'lora').length;
if (loras) parts.push(t('aiMapLoras', { count: loras }));
const sampler = boxes.find(box => box.role === 'sampler');
const shape = boxes.find(box => box.facts?.width);
if (shape) parts.push(size(shape.facts));
if (sampler?.facts?.steps) parts.push(t('aiRunSteps', { steps: sampler.facts.steps }));
parts.push(t('aiMapNodes', { count: boxes.length }));
return parts.join(' · ');
}
/** A change since the run before, as one short line. */
export function changeWords(change) {
if (change.change) return t(change.change === 'added' ? 'aiMapAdded' : 'aiMapRemoved', { title: change.title });
const key = KEYS.includes(change.key) ? t(`aiMapKey_${change.key}`) : change.key;
if (change.key === 'text' || String(change.from).length + String(change.to).length > 60) return t('aiMapChangedOnly', { title: change.title, key });
const show = value => (/\.(safetensors|ckpt|pt|pth|bin|gguf|sft)$/i.test(value) ? fileName(value) : value) || '—';
return t('aiMapChanged', { title: change.title, key, from: show(change.from), to: show(change.to) });
}
/** The stage diagram block of a run map: top to bottom, so a whole run fits a narrow column. */
export function mapBlock(map) {
return {
direction: 'down',
nodes: (map.boxes || []).map(box => ({ id: box.id, label: box.title, detail: boxDetail(box), node_id: box.id, color: COLOR[box.role] || '' })),
edges: (map.links || []).map(link => ({ from: link.from, to: link.to, label: LABELLED.has(link.type) ? link.type : '' })),
};
}
/** Shrinks a drawn diagram to the column it is in (down to MIN_SCALE; narrower, it scrolls sideways). */
function fitDiagram(wrap) {
const stage = wrap.querySelector('.anomalous-ai-diagram');
const width = parseFloat(stage?.style.width) || 0;
const height = parseFloat(stage?.style.height) || 0;
if (!width || typeof ResizeObserver === 'undefined') return;
const apply = () => {
const room = wrap.clientWidth - 4;
if (room <= 0) return;
const scale = Math.max(MIN_SCALE, Math.min(1, room / width));
stage.style.transformOrigin = 'top left';
stage.style.transform = scale < 1 ? `scale(${scale})` : '';
stage.style.marginBottom = scale < 1 ? `${-height * (1 - scale)}px` : '';
stage.style.marginRight = scale < 1 ? `${-width * (1 - scale)}px` : '';
};
new ResizeObserver(apply).observe(wrap);
}
/** Under a run step (or on the stage): the line, the changes, the boxes; null for a run without a map.
* `ctx`: {locateNode(id), openSide(stepId) when it can be seen large}; `open` shows the boxes at once. */
export function runMapBox(step, ctx, open = false) {
if (!step?.map?.boxes?.length) return null;
const box = el('div', 'anomalous-ai-run-map');
const changes = step.changes || [];
if (changes.length) {
const chips = el('div', 'anomalous-ai-run-map-changes');
chips.append(el('span', 'anomalous-ai-run-map-changes-title', t('aiMapSince')),
...changes.map(change => el('span', `anomalous-ai-run-map-change${change.change ? ` is-${change.change}` : ''}`, changeWords(change))));
box.append(chips);
}
const more = el('details', 'anomalous-ai-run-map-more');
more.open = open;
const head = el('summary', 'anomalous-ai-run-map-head');
head.append(el('span', 'anomalous-ai-run-map-title', t('aiMapTitle')), el('span', 'anomalous-ai-run-map-line', mapLine(step.map)));
if (ctx.openSide) {
head.append(iconButton('anomalous-ai-side-open', 'sidebar', t('aiMapOpenSide'), (event) => {
event.preventDefault(); // not the summary's open and close
ctx.openSide(step.id);
}, { size: 14 }));
}
more.append(head);
let drawn = false;
const draw = () => {
if (drawn || !more.open) return;
drawn = true;
const views = el('div', 'anomalous-ai-run-map-views');
views.setAttribute('role', 'tablist');
const holder = el('div', 'anomalous-ai-run-map-view');
const show = (view) => {
try { localStorage.setItem(VIEW_KEY, view); } catch (_) { /* only not kept */ }
for (const node of views.children) node.classList.toggle('is-on', node.dataset.view === view);
if (view === 'simple') {
holder.replaceChildren(runStory(step.map, ctx) || el('div', 'anomalous-ai-run-map-note', t('aiStoryNone')));
return;
}
const drawing = diagram(mapBlock(step.map), ctx);
holder.replaceChildren(drawing);
fitDiagram(drawing);
};
for (const view of ['simple', 'full']) {
const tab = el('button', 'anomalous-ai-run-map-tab', t(view === 'simple' ? 'aiStorySimple' : 'aiStoryFull'));
tab.type = 'button';
tab.dataset.view = view;
tab.setAttribute('role', 'tab');
tab.onclick = () => show(view);
views.append(tab);
}
more.append(views, holder, el('div', 'anomalous-ai-run-map-note', t('aiMapNote')));
show(keptView());
};
more.addEventListener('toggle', draw);
draw();
box.append(more);
return box;
}