Every page in both languages, all themes and at 480: - Give the window its own text colour: text with none took ComfyUI's, white on the light theme (Tidy headings and file names, the site chip). - Keep a task's link chip in its row, its x in reach, and colour the next-task chip. - Let choices in Settings wrap, the Models search shrink, and a workflow's preset row go to two lines. - Add --amb-on-link for text on the accent: pale in the dark themes, so white was hard to read. - Lists and colons follow the language (listSeparator, wordColon); tests/words_in_language.mjs keeps it so. - Right plurals in English, "AI" on the rail, one line left in Chinese translated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
8.4 KiB
JavaScript
161 lines
8.4 KiB
JavaScript
/**
|
||
* 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')}${t('wordColon')}${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;
|
||
}
|