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:
DemonGatanjieu
2026-10-23 16:34:43 +08:00
co-authored by Claude Opus 5.5
parent 47bab22373
commit 030e955332
8 changed files with 324 additions and 7 deletions
+3 -1
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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`)。
+36
View File
@@ -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}",
+33 -4
View File
@@ -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();
+206
View File
@@ -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
View File
@@ -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");
+42
View File
@@ -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; }