Add a simple view of the run map
Per sampler: the model and LoRAs feeding it, its prompts, where it starts (an empty canvas, a picture, or the part before, upscaled) and where it is saved, "same as part N" when nothing changed; every name finds its node. Simple (the default) and Full switch in place, kept on this browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
47bab22373
commit
030e955332
+3
-1
@@ -81,7 +81,9 @@ covered by the `styles.css` manifest.
|
||||
- `api/ai_run_map.py` reads a run's shape from the prompt ComfyUI queued (plumbing wired
|
||||
through; boxes with a role and facts, links with ComfyUI's types) and what changed since the
|
||||
task's run before; `ai_task_runs.py` keeps both on the run step, `show_run_map` puts it on the
|
||||
stage (a `run_map` block), `web/modules/ui_ai_run_map.js` words and draws it (top down, shrunk
|
||||
stage (a `run_map` block), `web/modules/ui_ai_run_map.js` words and draws it (Simple: told by
|
||||
`web/modules/ui_ai_run_story.js`, one part per sampler with the model and LoRAs feeding it, its
|
||||
prompts, where it starts and where it ends up; Full: top down, shrunk
|
||||
to its column). `api/ai_task_canvas.py` keeps each task's own canvas tab (default "new", set in
|
||||
the Gates window or per task): `mcp_bridge.ask_page` gives canvas actions the task's tab,
|
||||
`web/modules/mcp_task_tab.js` brings it to the front by clicking ComfyUI's tab (the store
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
- 右边这一栏改成一张单独的卡片,不再用一条线硬隔开;默认窄一些,给中间的时间线留出地方。鼠标移到它左边缘会出现小竖条,拖动调宽窄(记得住,双击恢复)。
|
||||
- 左边的任务列表用窗口左上角的按钮收起 / 展开;收起后鼠标移到按钮上,列表会临时浮出来,点任务或移开就收回。
|
||||
- 素材在右边这一栏里时,鼠标移过卡片不再自动跳进详细说明(之前很难操作),点卡片上的 ⓘ 才看。
|
||||
- **节点结构的简单看法**:除了完整的方块图,现在默认是一张一眼能看懂的说明:每一段采样用哪个模型加哪些 LoRA(带权重)、正负提示词、从空白画布还是从上一段的结果开始(用哪个放大模型、缩放多少),最后保存在哪;和上一段一样的就写“和第 1 段一样”。点任意一项定位到画布上的节点;“简单 / 完整”随时切换,AMB 记得你的选择。
|
||||
- The run map has a Simple view (now the default): per sampler, the model and LoRAs feeding it, its prompts, where it starts (an empty canvas, a picture, or the part before, upscaled) and where it is saved, "same as part N" when nothing changed; Simple / Full is kept on this browser.
|
||||
- **删掉任务里的图以后**:时间线、本次成果、展台上的那张图显示成“图片已删除”的占位(鼠标停上去说明可在回收站找回),不再是破图,也点不开;@ 列表里不再出现它。AI 那边会收到“你删除了这张图,别再用它”;它要是还去读,会被告知“可能已被用户删除”。
|
||||
- A task's pictures deleted since show as "Picture deleted" instead of a broken picture, and "@" no longer offers them; the AI hears plainly that the user deleted one, and a tool reading a gone picture says it may have been deleted.
|
||||
- **关联任务**:从一个任务新开的任务会记住它“接着”哪个任务(AI 开的新任务、你看着某个任务时点的“新任务”都会)。标题下面显示“接着:…”“后续:…”,点一下跳过去;任务列表里后续任务带 ↳。可以解开,也可以自己选“接着哪个任务…”。AI 接手后续任务时会先收到上一个任务的决策。
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
|
||||
|
||||
**AI 的专用标签页**(默认):AI 第一次改画布前,AMB 先把你正开着的工作流复制到一个以任务命名的新标签页,之后 AI 的修改、读画布和运行都只在那一页;你切去别的标签页也没关系,它下次动手前 AMB 会先切回去。你原来的工作流一笔不动。AI 用 `new_workflow` / `open_workflow` 打开的标签页就直接算这个任务的。任务标题栏有 **专用标签页 / 当前画布** 开关和这个标签页的名字(点一下切过去);**关卡** 窗口里的“AI 改画布时”设定新任务的默认做法。
|
||||
|
||||
**这次跑的节点结构**:每次运行那一步下面写着一行“底模 · 几个 LoRA · 尺寸 · 步数 · 几个节点”,点开是方块图:哪个底模、LoRA 和权重、正负提示词开头、尺寸、采样器设置、保存,方块之间的线标着类型,点方块定位到画布上的节点。这张图是 AMB 按 ComfyUI 实际收到的工作流画的,不靠 AI 描述。上面的黄色小块是 **比上次改了什么**(比如“K采样器·步数:20 → 30”“新增「加载LoRA」”)。AI 讲解时可以用 `show_run_map` 一次把同一张图放上展台,不用自己拼。
|
||||
**这次跑的节点结构**:每次运行那一步下面写着一行“底模 · 几个 LoRA · 尺寸 · 步数 · 几个节点”,点开有 **简单** 和 **完整** 两种看法(AMB 记得你上次选的)。简单:按每一段采样列出“用哪个模型加哪些 LoRA、提示词是什么、从空白画布还是从上一段的结果(放大多少)开始”,最后保存在哪;后一段和前一段一样的就写“和第 1 段一样”。完整:方块图,哪个底模、LoRA 和权重、正负提示词开头、尺寸、采样器设置、保存,方块之间的线标着类型。两种看法里点任意一项都能定位到画布上的节点。这张图是 AMB 按 ComfyUI 实际收到的工作流画的,不靠 AI 描述。上面的黄色小块是 **比上次改了什么**(比如“K采样器·步数:20 → 30”“新增「加载LoRA」”)。AI 讲解时可以用 `show_run_map` 一次把同一张图放上展台,不用自己拼。
|
||||
|
||||
**把图片直接发给 AI**:图库里右键一张图选 **发给 AI**,或者看图窗口的 **✦**:AI 正在做任务时会问你“发给当前任务”还是“开新任务”。发给当前任务时可以写一句想让它看什么;AI 收到的是图片本身,加上 AMB 从图里读出的模型、LoRA、提示词、尺寸和采样设置,不用你再描述。还没被 AI 读到之前连着发的几张会并成一步。AI 自己搜图库时也可以让结果直接带上前 6 张图(`search_images` 的 `with_pictures`)。
|
||||
|
||||
|
||||
@@ -1348,6 +1348,24 @@ export const i18n = {
|
||||
aiKind_speech: "配音",
|
||||
aiKind_show: "展示",
|
||||
aiKind_note: "留言",
|
||||
aiStorySimple: "简单",
|
||||
aiStoryFull: "完整",
|
||||
aiStoryPart: "第 {number} 段",
|
||||
aiStoryOne: "采样",
|
||||
aiStoryModel: "模型",
|
||||
aiStoryFrom: "从",
|
||||
aiStoryEmpty: "空白画布 {size}",
|
||||
aiStoryAfter: "第 {number} 段的结果",
|
||||
aiStoryEnd: "最后",
|
||||
aiStorySame: "和第 {number} 段一样",
|
||||
aiStoryPrompts: "提示词",
|
||||
aiStoryAfterPart: "第 {number} 段后",
|
||||
aiStorySaved: "保存({prefix})",
|
||||
aiStoryNoModel: "没找到模型",
|
||||
aiStoryUpscaleWith: "用 {model} 放大",
|
||||
aiStoryLocate: "在画布上找到「{title}」",
|
||||
aiStoryHint: "点任意一项,定位到画布上的那个节点",
|
||||
aiStoryNone: "这个运行里没有采样器或保存节点,看“完整”吧。",
|
||||
aiPictureGone: "图片已删除",
|
||||
aiPictureGoneHint: "{name} 已经不在输出文件夹里了(删除了或挪走了)。删除的图在回收站里还能找回。",
|
||||
aiTaskFrom: "接着:{title}",
|
||||
@@ -4569,6 +4587,24 @@ export const i18n = {
|
||||
aiKind_speech: "Voice",
|
||||
aiKind_show: "Show",
|
||||
aiKind_note: "Note",
|
||||
aiStorySimple: "Simple",
|
||||
aiStoryFull: "Full",
|
||||
aiStoryPart: "Part {number}",
|
||||
aiStoryOne: "Sampling",
|
||||
aiStoryModel: "Model",
|
||||
aiStoryFrom: "From",
|
||||
aiStoryEmpty: "Empty canvas {size}",
|
||||
aiStoryAfter: "What part {number} made",
|
||||
aiStoryEnd: "Last",
|
||||
aiStorySame: "Same as part {number}",
|
||||
aiStoryPrompts: "Prompts",
|
||||
aiStoryAfterPart: "After part {number}",
|
||||
aiStorySaved: "Saved ({prefix})",
|
||||
aiStoryNoModel: "No model found",
|
||||
aiStoryUpscaleWith: "Upscale with {model}",
|
||||
aiStoryLocate: "Find \"{title}\" on the canvas",
|
||||
aiStoryHint: "Click any of them to find its node on the canvas",
|
||||
aiStoryNone: "This run has no sampler or save node; see Full.",
|
||||
aiPictureGone: "Picture deleted",
|
||||
aiPictureGoneHint: "{name} is no longer in the output folder (deleted or moved). A deleted one is still in the Recycle Bin.",
|
||||
aiTaskFrom: "Goes on from: {title}",
|
||||
|
||||
@@ -4,15 +4,23 @@
|
||||
* 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).
|
||||
* "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 { 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) {
|
||||
@@ -122,9 +130,30 @@ export function runMapBox(step, ctx, open = false) {
|
||||
const draw = () => {
|
||||
if (drawn || !more.open) return;
|
||||
drawn = true;
|
||||
const drawing = diagram(mapBlock(step.map), ctx);
|
||||
more.append(drawing, el('div', 'anomalous-ai-run-map-note', t('aiMapNote')));
|
||||
fitDiagram(drawing);
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* A run's shape told simply (the run map's "Simple" view, ui_ai_run_map.js): what feeds what, at a
|
||||
* glance. One part per sampler, in the order they ran: the model with the LoRAs on it, the prompts
|
||||
* (said once: a part with the same ones as the part before says so),
|
||||
* where the picture starts (an empty canvas of a size, a picture, or the part before, upscaled or
|
||||
* not), and at the end what was done after the last part and where it was saved. Read from the
|
||||
* map's boxes and links (api/ai_run_map.py); every name leads to its node on the canvas.
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
import { icon } from './ui_ai_icons.js';
|
||||
|
||||
const HOPS = 8; // how far back a chain is followed
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = String(text);
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The map as lookups: boxes by id, the links into each box. */
|
||||
function graph(map) {
|
||||
const boxes = new Map((map.boxes || []).map(box => [box.id, box]));
|
||||
const into = new Map();
|
||||
for (const link of map.links || []) {
|
||||
if (!into.has(link.to)) into.set(link.to, []);
|
||||
into.get(link.to).push(link);
|
||||
}
|
||||
const from = (id, input) => {
|
||||
const link = (into.get(id) || []).find(item => item.input === input);
|
||||
return link ? boxes.get(link.from) : null;
|
||||
};
|
||||
/** Every box upstream of `id`, nearest first (breadth first, at most HOPS away). */
|
||||
const upstream = (id) => {
|
||||
const seen = new Set([id]);
|
||||
const out = [];
|
||||
let layer = [id];
|
||||
for (let hop = 0; hop < HOPS && layer.length; hop++) {
|
||||
const next = [];
|
||||
for (const at of layer) {
|
||||
for (const link of into.get(at) || []) {
|
||||
if (seen.has(link.from) || !boxes.has(link.from)) continue;
|
||||
seen.add(link.from);
|
||||
out.push(boxes.get(link.from));
|
||||
next.push(link.from);
|
||||
}
|
||||
}
|
||||
layer = next;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return { boxes, into, from, upstream };
|
||||
}
|
||||
|
||||
/** The model a sampler uses: the base model and the LoRAs on the way (the input "model", followed back). */
|
||||
function modelChain(g, sampler) {
|
||||
const loras = [];
|
||||
let at = g.from(sampler.id, 'model');
|
||||
for (let hop = 0; at && hop < HOPS; hop++) {
|
||||
if (at.role === 'model') return { base: at, loras };
|
||||
if (at.role === 'lora') loras.unshift(at);
|
||||
at = g.from(at.id, 'model') || (g.into.get(at.id) || []).map(link => g.boxes.get(link.from)).find(box => box && ['model', 'lora'].includes(box.role)) || null;
|
||||
}
|
||||
return { base: null, loras };
|
||||
}
|
||||
|
||||
/** The prompt box behind a sampler's input `input` (through conditioning helpers). */
|
||||
function promptOf(g, sampler, input) {
|
||||
const first = g.from(sampler.id, input);
|
||||
if (!first) return null;
|
||||
return first.role === 'prompt' ? first : g.upstream(first.id).find(box => box.role === 'prompt') || null;
|
||||
}
|
||||
|
||||
/** A step that makes the picture bigger: an upscale (not its model's loader) or a scale node. */
|
||||
const resizes = box => !/loader/i.test(box.type || '') && !box.facts?.file && (box.role === 'upscale' || /scale/i.test(box.type || ''));
|
||||
|
||||
/** A resize step in words: "upscale with 4x-UltraSharp", "Scale image ×1.5". */
|
||||
function stepName(g, box) {
|
||||
const loader = g.from(box.id, 'upscale_model');
|
||||
if (loader?.facts?.file) return t('aiStoryUpscaleWith', { model: loader.facts.file });
|
||||
const scale = (box.facts?.values || []).find(([key]) => key === 'scale_by' || key === 'scale');
|
||||
return scale ? `${box.title} ×${scale[1]}` : box.title;
|
||||
}
|
||||
|
||||
/** Where a sampler's picture starts: {kind: size | sampler | image, box, via: [resize boxes]}. */
|
||||
function startOf(g, sampler) {
|
||||
const first = g.from(sampler.id, 'latent_image');
|
||||
if (!first) return null;
|
||||
const chain = [first, ...g.upstream(first.id)];
|
||||
const via = [];
|
||||
for (const box of chain) {
|
||||
if (box.role === 'size' || (box.facts?.width && box.role !== 'sampler' && box.role !== 'upscale')) return { kind: 'size', box, via };
|
||||
if (box.role === 'sampler') return { kind: 'sampler', box, via };
|
||||
if (box.role === 'image') return { kind: 'image', box, via };
|
||||
if (resizes(box)) via.unshift(box);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The samplers in the order they ran (one fed by another comes after it). */
|
||||
function inOrder(g) {
|
||||
const samplers = [...g.boxes.values()].filter(box => box.role === 'sampler');
|
||||
const depth = new Map();
|
||||
const deep = (box, guard = 0) => {
|
||||
if (depth.has(box.id)) return depth.get(box.id);
|
||||
const before = guard < HOPS ? g.upstream(box.id).filter(item => item.role === 'sampler') : [];
|
||||
const value = before.length ? 1 + Math.max(...before.map(item => deep(item, guard + 1))) : 0;
|
||||
depth.set(box.id, value);
|
||||
return value;
|
||||
};
|
||||
return samplers.sort((a, b) => deep(a) - deep(b));
|
||||
}
|
||||
|
||||
function chip(box, words, role, ctx) {
|
||||
const node = el('button', `anomalous-ai-story-chip is-${role}`);
|
||||
node.type = 'button';
|
||||
node.title = t('aiStoryLocate', { title: box.title });
|
||||
node.append(el('span', '', words));
|
||||
node.onclick = () => ctx.locateNode?.(box.id);
|
||||
return node;
|
||||
}
|
||||
|
||||
function line(label, ...parts) {
|
||||
const row = el('div', 'anomalous-ai-story-line');
|
||||
const what = el('div', 'anomalous-ai-story-what');
|
||||
what.append(...parts.filter(Boolean));
|
||||
row.append(el('span', 'anomalous-ai-story-label', label), what);
|
||||
return row;
|
||||
}
|
||||
|
||||
const name = box => box.facts?.file || box.title;
|
||||
const size = facts => `${facts.width}×${facts.height}${facts.batch > 1 ? ` ×${facts.batch}` : ''}`;
|
||||
const plus = () => el('span', 'anomalous-ai-story-plus', '+');
|
||||
|
||||
/** The simple view of `map` (ctx: {locateNode(id)}), or null when it has nothing to tell. */
|
||||
export function runStory(map, ctx) {
|
||||
if (!map?.boxes?.length) return null;
|
||||
const g = graph(map);
|
||||
const samplers = inOrder(g);
|
||||
const box = el('div', 'anomalous-ai-story');
|
||||
const number = new Map(samplers.map((item, at) => [item.id, at + 1]));
|
||||
let before = null; // the part before's model and prompts, to say "same as part N" instead of again
|
||||
samplers.forEach((sampler, at) => {
|
||||
const part = el('div', 'anomalous-ai-story-part');
|
||||
const facts = sampler.facts || {};
|
||||
const how = [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(' · ');
|
||||
const head = el('div', 'anomalous-ai-story-head');
|
||||
head.append(el('span', 'anomalous-ai-story-number', samplers.length > 1 ? t('aiStoryPart', { number: at + 1 }) : t('aiStoryOne')),
|
||||
chip(sampler, how || sampler.title, 'sampler', ctx));
|
||||
part.append(head);
|
||||
const { base, loras } = modelChain(g, sampler);
|
||||
const positive = promptOf(g, sampler, 'positive');
|
||||
const negative = promptOf(g, sampler, 'negative');
|
||||
const modelKey = [base?.id, ...loras.map(item => item.id)].join(' ');
|
||||
const promptKey = `${positive?.id} ${negative?.id}`;
|
||||
const same = key => before && before[key] === (key === 'model' ? modelKey : promptKey);
|
||||
if (same('model')) {
|
||||
part.append(line(t('aiStoryModel'), el('span', 'anomalous-ai-story-none', t('aiStorySame', { number: before.number }))));
|
||||
} else if (base || loras.length) {
|
||||
const parts = [base ? chip(base, name(base), 'model', ctx) : el('span', 'anomalous-ai-story-none', t('aiStoryNoModel'))];
|
||||
for (const lora of loras) parts.push(plus(), chip(lora, `${name(lora)}${Number.isFinite(lora.facts?.strength) ? ` ${lora.facts.strength}` : ''}`, 'lora', ctx));
|
||||
part.append(line(t('aiStoryModel'), ...parts));
|
||||
}
|
||||
if (same('prompt') && (positive || negative)) {
|
||||
part.append(line(t('aiStoryPrompts'), el('span', 'anomalous-ai-story-none', t('aiStorySame', { number: before.number }))));
|
||||
} else {
|
||||
if (positive) part.append(line(t('aiMapPositive'), chip(positive, positive.facts?.text || positive.title, 'prompt', ctx)));
|
||||
if (negative) part.append(line(t('aiMapNegative'), chip(negative, negative.facts?.text || negative.title, 'negative', ctx)));
|
||||
}
|
||||
const start = startOf(g, sampler);
|
||||
if (start) {
|
||||
const parts = start.kind === 'size' ? [chip(start.box, t('aiStoryEmpty', { size: size(start.box.facts) }), 'size', ctx)]
|
||||
: start.kind === 'image' ? [chip(start.box, name(start.box), 'image', ctx)]
|
||||
: [chip(start.box, t('aiStoryAfter', { number: number.get(start.box.id) || '?' }), 'sampler', ctx)];
|
||||
for (const step of start.via) parts.push(el('span', 'anomalous-ai-story-arrow', '→'), chip(step, stepName(g, step), 'upscale', ctx));
|
||||
part.append(line(t('aiStoryFrom'), ...parts));
|
||||
}
|
||||
before = { model: modelKey, prompt: promptKey, number: at + 1 };
|
||||
box.append(part);
|
||||
if (at < samplers.length - 1) box.append(el('div', 'anomalous-ai-story-down', '↓'));
|
||||
});
|
||||
// The end: what happens after the last part, and where it is saved.
|
||||
const saves = [...g.boxes.values()].filter(item => item.role === 'save');
|
||||
if (saves.length) {
|
||||
const end = el('div', 'anomalous-ai-story-part is-end');
|
||||
for (const save of saves) {
|
||||
const after = g.upstream(save.id);
|
||||
const stop = after.findIndex(item => item.role === 'sampler');
|
||||
const of = saves.length > 1 && stop >= 0 ? number.get(after[stop].id) : null;
|
||||
const steps = (stop >= 0 ? after.slice(0, stop) : after).filter(resizes).reverse();
|
||||
const parts = [];
|
||||
for (const step of steps) parts.push(chip(step, stepName(g, step), 'upscale', ctx), el('span', 'anomalous-ai-story-arrow', '→'));
|
||||
parts.push(chip(save, save.facts?.prefix ? t('aiStorySaved', { prefix: save.facts.prefix }) : save.title, 'save', ctx));
|
||||
end.append(line(of ? t('aiStoryAfterPart', { number: of }) : t('aiStoryEnd'), ...parts));
|
||||
}
|
||||
if (samplers.length) box.append(el('div', 'anomalous-ai-story-down', '↓'));
|
||||
box.append(end);
|
||||
}
|
||||
if (!samplers.length && !saves.length) return null;
|
||||
const hint = el('div', 'anomalous-ai-story-hint');
|
||||
hint.append(icon('target', 12), el('span', '', t('aiStoryHint')));
|
||||
box.append(hint);
|
||||
return box;
|
||||
}
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
@import url("./styles/39-ai-stage.css?v=20261021-stage");
|
||||
@import url("./styles/40-ai-side.css?v=20261024-card");
|
||||
@import url("./styles/41-ai-results.css?v=20261024-gone");
|
||||
@import url("./styles/42-ai-flow.css?v=20261024-runmap");
|
||||
@import url("./styles/42-ai-flow.css?v=20261024-story2");
|
||||
@import url("./styles/43-ai-toolbox.css?v=20261024-facetmenu");
|
||||
@import url("./styles/44-ai-status.css?v=20261024-stop");
|
||||
@import url("./styles/45-ai-side-views.css?v=20261024-bars");
|
||||
|
||||
@@ -346,3 +346,45 @@
|
||||
.anomalous-ai-marks-sent-notes { margin: 0; padding-left: 20px; color: var(--amb-text-main); font-size: 12.5px; line-height: 1.7; }
|
||||
.anomalous-ai-pic-tool.is-mark { border-color: color-mix(in srgb, var(--amb-warn) 70%, transparent); }
|
||||
.anomalous-ai-marker-whole { flex: none; }
|
||||
|
||||
/* The run map's two views: told simply (ui_ai_run_story.js) or drawn in full. */
|
||||
.anomalous-ai-run-map-views { display: inline-flex; gap: 2px; margin: 0 11px 8px; padding: 2px; border: 1px solid var(--amb-border); border-radius: 8px; }
|
||||
.anomalous-ai-run-map-tab { padding: 3px 12px; border: 0; border-radius: 6px; background: none; color: var(--amb-text-soft); font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.anomalous-ai-run-map-tab.is-on { background: color-mix(in srgb, var(--amb-link) 20%, transparent); color: var(--amb-text-main); font-weight: 600; }
|
||||
.anomalous-ai-run-map-view > .anomalous-ai-diagram-wrap { margin: 0 10px; }
|
||||
.anomalous-ai-story { display: flex; flex-direction: column; gap: 4px; padding: 0 11px 4px; }
|
||||
.anomalous-ai-story-part { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid var(--amb-border); border-radius: 10px; background: rgba(var(--amb-ink-rgb), 0.025); }
|
||||
.anomalous-ai-story-part.is-end { background: none; border-style: dashed; }
|
||||
.anomalous-ai-story-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.anomalous-ai-story-number { color: var(--amb-text-main); font-size: 13px; font-weight: 700; }
|
||||
.anomalous-ai-story-line { display: grid; grid-template-columns: 62px minmax(0, 1fr); align-items: start; gap: 8px; }
|
||||
.anomalous-ai-story-label { padding-top: 3px; color: var(--amb-text-dim); font-size: 11.5px; }
|
||||
.anomalous-ai-story-what { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; min-width: 0; }
|
||||
.anomalous-ai-story-chip {
|
||||
max-width: 100%;
|
||||
padding: 2px 9px;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: rgba(var(--amb-ink-rgb), 0.06);
|
||||
color: var(--amb-text-main);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 20px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anomalous-ai-story-chip:hover { border-color: var(--amb-link); }
|
||||
.anomalous-ai-story-chip.is-model, .anomalous-ai-story-chip.is-lora { background: color-mix(in srgb, #a487f5 18%, transparent); }
|
||||
.anomalous-ai-story-chip.is-model { font-weight: 650; }
|
||||
.anomalous-ai-story-chip.is-prompt { background: color-mix(in srgb, var(--amb-ok) 14%, transparent); }
|
||||
.anomalous-ai-story-chip.is-negative { background: color-mix(in srgb, var(--amb-danger, #e0645c) 12%, transparent); }
|
||||
.anomalous-ai-story-chip.is-sampler { background: color-mix(in srgb, var(--amb-link) 16%, transparent); }
|
||||
.anomalous-ai-story-chip.is-size, .anomalous-ai-story-chip.is-image, .anomalous-ai-story-chip.is-upscale { background: color-mix(in srgb, var(--amb-warn) 16%, transparent); }
|
||||
.anomalous-ai-story-chip.is-save { background: rgba(var(--amb-ink-rgb), 0.1); }
|
||||
.anomalous-ai-story-plus, .anomalous-ai-story-arrow { color: var(--amb-text-dim); font-size: 12px; }
|
||||
.anomalous-ai-story-none { color: var(--amb-text-dim); font-size: 12px; }
|
||||
.anomalous-ai-story-down { color: var(--amb-text-dim); font-size: 13px; line-height: 1; text-align: center; }
|
||||
.anomalous-ai-story-hint { display: flex; align-items: center; gap: 5px; padding-top: 2px; color: var(--amb-text-dim); font-size: 11px; }
|
||||
|
||||
Reference in New Issue
Block a user