From 030e955332e2cd2e86edb9673cde2ba4372ee9c4 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:34:43 +0800 Subject: [PATCH] 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 --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 2 + docs/guides/mcp.md | 2 +- web/modules/locales.js | 36 ++++++ web/modules/ui_ai_run_map.js | 37 +++++- web/modules/ui_ai_run_story.js | 206 +++++++++++++++++++++++++++++++++ web/styles.css | 2 +- web/styles/42-ai-flow.css | 42 +++++++ 8 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 web/modules/ui_ai_run_story.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6b94354..dee2849 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index cbce014..47093e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 接手后续任务时会先收到上一个任务的决策。 diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 54744ef..d375ba9 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -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`)。 diff --git a/web/modules/locales.js b/web/modules/locales.js index 1e71598..f84fd9b 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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}", diff --git a/web/modules/ui_ai_run_map.js b/web/modules/ui_ai_run_map.js index 0435a4d..129ebed 100644 --- a/web/modules/ui_ai_run_map.js +++ b/web/modules/ui_ai_run_map.js @@ -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(); diff --git a/web/modules/ui_ai_run_story.js b/web/modules/ui_ai_run_story.js new file mode 100644 index 0000000..c1baccc --- /dev/null +++ b/web/modules/ui_ai_run_story.js @@ -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; +} diff --git a/web/styles.css b/web/styles.css index f75b009..f01f4b2 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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"); diff --git a/web/styles/42-ai-flow.css b/web/styles/42-ai-flow.css index 50b25d7..e27494d 100644 --- a/web/styles/42-ai-flow.css +++ b/web/styles/42-ai-flow.css @@ -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; }