Show what the task needs now over the message box; take dropped and pasted pictures

- ui_ai_now.js: over the message box, a run's progress with stop, the AI's step still going, a
  gate to allow or refuse there, and the AI's open question with its options as buttons (own
  words or skip too; several picks and pictures lead to the stage). progressWords is shared
  with the run card; the panel's and gate's answers are exported for it.
- ui_ai_drop.js: pictures dropped on the box or pasted are kept in output/ai_inbox
  (POST /anomalous/ai_pictures/upload, ai_pictures.save_upload: read as a picture first,
  25 MB cap, never over a file) and attached; ComfyUI output pictures dragged in attach as they are.
- Long AI notes are folded to their first lines with "Show all".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-23 21:24:48 +08:00
co-authored by Claude Opus 5.5
parent ecbebc049d
commit a74f885c53
19 changed files with 538 additions and 39 deletions
+4 -1
View File
@@ -274,7 +274,10 @@ covered by the `styles.css` manifest.
once per task and docked at the foot of its column (in view while the timeline scrolls, with a
"new steps below" button): a growing input, the gallery and materials buttons beside it, and
over it what the message carries (`ui_ai_tray.js`: pictures to mark up with
`ui_ai_marker.js` or leave out, chips of what "@" names). `ui_ai_mention.js` holds those refs
`ui_ai_marker.js` or leave out, chips of what "@" names), and over that what the task needs or
does now (`ui_ai_now.js`: a gate to allow, a run's progress with stop, the AI's open question
to answer there). `ui_ai_drop.js` takes pictures dropped on the box or pasted, kept in the
output folder's ai_inbox (`POST /anomalous/ai_pictures/upload`, `ai_pictures.save_upload`). `ui_ai_mention.js` holds those refs
and gives it "@" (the task's pictures, the gallery's newest and those its search finds,
combos, models, workflows, prompts, node packs and node types from the materials picker's
data) and "/" (`ui_ai_commands.js`: stop, undo, side views, new task, canvas mode, words to
+6
View File
@@ -26,6 +26,12 @@
- Gallery pictures in the AI workbench: the message box's picture button picks them from the gallery (newest first, searchable), and "@" offers the gallery's newest pictures and searches it as you type; the AI gets each picture with what made it.
- **等 AI 接手时先备好材料**:“等 AI 接手”的任务顶上有 **开始 / 备料** 两页切换。**备料** 是单独的一页:大的图库(可搜索,点一张就放到最下面的输入框上,再点一下拿掉)和“已经备好”的列表;切换按钮上显示备好了几样。AI 接手时一起收到。
- A task waiting for an AI has two pages, Get started / Put ready: Put ready shows the gallery as a big searchable grid feeding the message box (click to add or drop a picture) and what is ready; the AI gets it all when it takes the task.
- **最下面就能看到 AI 在做什么、要你做什么**:输入框上方多了一条“现在”栏:AI 出图时显示进度(节点、步数、百分比、进度条)和停止按钮;AI 某一步还在进行时说在做什么;AI 想做要你点头的事(比如挪模型、下载)时,直接在这里 **允许 / 不要**;AI 问你问题时,问题和选项就在这里,点一下就回答(有推荐的带 ☆,也能自己写一句或跳过),多选和挑图会带你去展台。
- A "now" strip over the message box: a run's progress with stop, the AI's step still going, a gate to allow or refuse right there, and the AI's open question with its options as buttons (one's own words or skip too; several picks and pictures to pick lead to the stage).
- **拖进来、粘贴进来的图片也能发给 AI**:把电脑里的图片拖到输入框上,或者直接 Ctrl+V 粘贴截图,它会存进 ComfyUI 输出文件夹里的 `ai_inbox`(图库里也看得到,从不覆盖文件,只收 PNG、JPEG、WebP、GIF,最大 25 MB),并加到这条消息上。从 ComfyUI 页面里拖来的输出图片直接加上,不另存。
- Pictures dropped on the message box or pasted are kept in the output folder's `ai_inbox` (seen in the gallery; never over a file; PNG, JPEG, WebP or GIF up to 25 MB) and attached; an output picture dragged from ComfyUI's page is attached as it is.
- **很长的 AI 留言先折叠**:超过八行左右的留言只显示开头,点 **展开全文** 看全部。
- Long AI notes are folded to their first lines with "Show all".
- **输入框固定在最下面,像聊天软件一样**:不管时间线多长、翻到哪里,留言框都停在任务这一栏的最下方。它上面是这条消息要带的东西:图片显示成小图,每张有 **✎ 标注**(画框、圈、写要改哪里,标好后单独发给 AI)和 **×**;@ 提到的模型、搭配显示成小标签。从图库挑的图不再往文字里塞“@文件名”。左边是 **图库** 和 **素材** 两个按钮,给了东西就亮起并显示数量。输入框会随内容变高:Enter 发送,Shift+Enter 换行;只发图片不写字也可以。往上翻着看时 AI 有了新步骤,输入框上方会出现 **下面有新的步骤**,点一下回到最新;自己发出的消息会自动滚到眼前。时间线里你发的图片和 AI 出的图一样,可以查看、对比、收藏、标注。
- The message box is docked at the foot of the task column like a chat app's: over the input what the message carries (pictures with ✎ to mark up and ×, chips for what "@" names; gallery pictures are attached, not typed in), beside it the gallery and materials buttons with their counts, an input that grows (Enter sends, Shift+Enter breaks the line; pictures alone are fine), "New steps below" when the AI adds steps while you read further up, and your own message scrolled into view. Pictures you sent carry the same tools as the AI's.
- **点得到、看得见**:选中的图片有粗框、发光和大对勾,图片罩上一层颜色,和鼠标停留一眼分得开;留言框左边的图片按钮在图库面板打开时亮起,留言里有图时显示张数。素材列表、AI“看了资料”那一行(带 › 可展开)、关卡选项、优先用 / 只能用、来源下拉、发送和复制按钮、模型页的标签、首页的“一键准备”、记录里可展开的一行,鼠标移上去都有明显的变化。点 **展台** 时即使还没有内容,也会打开并说明会放什么,不再没反应。“交给 AI · 0”在什么都没选时不能点。挪模型那一步的标题写文件名和挪到哪,不再是一串编号;只改了一个节点时不再出现两个“在画布上找到”;“接着哪个任务…”的字号和旁边一致。
+38 -1
View File
@@ -4,19 +4,28 @@ a message (kind "pictures", ai_panels._collect) with each picture attached as a
what made it in a few words (brief(): models, LoRAs, prompts, size, sampler; read from the
picture's own workflow by ai_run_map.py), so nothing needs describing. Pictures handed one after
another before the AI looked join one step. mark_gone() tells the page which of a task's
pictures were deleted since.
pictures were deleted since. save_upload() keeps a picture the user dropped or pasted into a
message in the output folder's ai_inbox, so the gallery has it and the message carries it like
any other: read as a picture first, size-capped, never over a file.
"""
import io
import json
import os
import re
import time
import uuid
from PIL import Image
from . import ai_run_map, ai_tasks
from .image_search import read_png_text
from .mcp_tools import ToolError, _resolve_image
MAX_PICTURES = 8
INBOX = "ai_inbox" # under the output folder
MAX_UPLOAD = 25 * 1024 * 1024
UPLOAD_TYPES = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp", "GIF": ".gif"}
class PicturesError(ValueError):
@@ -68,6 +77,34 @@ def mark_gone(task):
return task
def save_upload(data, name="", now=None):
"""Keeps picture bytes `data` (the file's own name `name`, for its stem) in output/ai_inbox.
Returns its "ai_inbox/name.ext"."""
if not data or len(data) > MAX_UPLOAD:
raise PicturesError(f"A picture up to {MAX_UPLOAD // (1024 * 1024)} MB")
try:
with Image.open(io.BytesIO(data)) as image:
kind = image.format
image.verify()
except (OSError, ValueError, SyntaxError, Image.DecompressionBombError):
raise PicturesError("Not a picture Anomalous can read (PNG, JPEG, WebP or GIF)")
if kind not in UPLOAD_TYPES:
raise PicturesError("Not a picture Anomalous can read (PNG, JPEG, WebP or GIF)")
stem = re.sub(r"[^\w\- ]+", "", os.path.splitext(os.path.basename(str(name or "")))[0]).strip()[:60] or "pasted"
folder = os.path.join(ai_tasks.folder_paths.get_output_directory(), INBOX)
os.makedirs(folder, exist_ok=True)
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(time.time() if now is None else now))
for count in range(1, 100):
filename = f"{stem}_{stamp}{'' if count == 1 else f' ({count})'}{UPLOAD_TYPES[kind]}"
try:
with open(os.path.join(folder, filename), "xb") as out: # never over a file
out.write(data)
except FileExistsError:
continue
return f"{INBOX}/{filename}"
raise PicturesError("No free name for the picture")
def hand(task_id, images, text="", now=None):
"""Hands output pictures `images` ("subfolder/name.png") to task `task_id`'s AI. Returns the step."""
if not isinstance(images, list) or not 1 <= len(images) <= MAX_PICTURES:
+17
View File
@@ -194,6 +194,22 @@ async def api_pictures(request):
return web.json_response({"step": step})
async def api_upload(request):
"""POST /anomalous/ai_pictures/upload?name= with a picture's bytes - one the user dropped or pasted
into a message, kept in the output folder (ai_pictures.save_upload): {image, filename, subfolder}."""
from . import ai_pictures
data = await request.read()
try:
image = await asyncio.to_thread(ai_pictures.save_upload, data, request.query.get("name", ""))
except ai_pictures.PicturesError as error:
return web.json_response({"error": str(error)}, status=400)
except OSError as error:
return web.json_response({"error": f"Could not keep the picture: {error}"}, status=500)
subfolder, _, filename = image.rpartition("/")
return web.json_response({"image": image, "filename": filename, "subfolder": subfolder})
async def api_stop(request):
"""POST /anomalous/ai_tasks/{id}/runs/{step}/stop - the user stops a run the AI started."""
from . import ai_panels
@@ -238,6 +254,7 @@ def register_routes(app):
app.router.add_post("/anomalous/ai_tasks/{task_id}/toolbox", api_toolbox_pick)
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/stop", api_stop)
app.router.add_post("/anomalous/ai_tasks/{task_id}/pictures", api_pictures)
app.router.add_post("/anomalous/ai_pictures/upload", api_upload)
app.router.add_get("/anomalous/ai_toolbox", api_toolbox)
app.router.add_get("/anomalous/ai_toolbox/favorites", api_toolbox)
app.router.add_post("/anomalous/ai_toolbox/favorites", api_toolbox)
+1 -1
View File
@@ -176,7 +176,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
**决策记录**:讨论在哪里都行,在 AI 自己的对话框里聊也可以,但商量出的结论会留在 AMB。AI 用 `log_decision` 把“定了什么、为什么、没选哪些、谁定的、跟哪几步有关”记成时间线上一张带 📌 的卡片;你在 AMB 里自己做的选择(面板上的回答、允许或不允许、交出去的素材、AI 改画布的地方、停止运行)会自动算进去。窗口右上角的 **决策** 按时间列出这个任务的所有决策,每条都能跳回时间线上的那一步;AI 跑了图或改了画布却很久没记录决策时,这里会轻轻提一句。AI 下次接手这个任务时,也会先收到这些决策。
**留言框**一直停在任务这一栏的最下面。它上面显示这条消息要带的图(每张可以 **✎ 标注** 或 **×** 拿掉)和 @ 提到的东西;Enter 发送,Shift+Enter 换行,只发图片也行。往上翻着看时 AI 有了新步骤,会出现 **下面有新的步骤**。**留言框里的 @ 和 /**:在留言框里打 **@**,会弹出一张能搜索的列表:这个任务出过的图、图库里最新的图(打字时也按提示词、模型、seed 在图库里搜)、搭配、模型、工作流、提示词、节点包、节点。留言框左边的 **图库** 按钮打开图库:点选几张(一次最多 8 张),点 **加进消息**,它们显示在输入框上方。用 @ 选中的会写成“@名字”,并在输入框上方显示成小标签;发出去时 AI 收到的是你指的那个东西本身(模型的准确 ID、搭配的参数、图片和它的生成参数),不用它去猜。在开头打 **/** 是快捷指令:/停止、/撤销、/展台、/结构、/素材、/专用标签页、/当前画布、/新任务 马上生效,不用等 AI;/重跑、/解释、/继续 帮你写好一句话,改一改再发。↑↓ 选,回车或 Tab 确认,Esc 关掉。
**留言框**一直停在任务这一栏的最下面,上方的“现在”栏显示 AI 出图的进度(可以停止)、要你允许的事(直接点 **允许 / 不要**)和 AI 问你的问题(点选项就回答)。图片可以拖到留言框上,或者 Ctrl+V 粘贴截图:存进输出文件夹的 `ai_inbox` 后加到消息上。它上面显示这条消息要带的图(每张可以 **✎ 标注** 或 **×** 拿掉)和 @ 提到的东西;Enter 发送,Shift+Enter 换行,只发图片也行。往上翻着看时 AI 有了新步骤,会出现 **下面有新的步骤**。**留言框里的 @ 和 /**:在留言框里打 **@**,会弹出一张能搜索的列表:这个任务出过的图、图库里最新的图(打字时也按提示词、模型、seed 在图库里搜)、搭配、模型、工作流、提示词、节点包、节点。留言框左边的 **图库** 按钮打开图库:点选几张(一次最多 8 张),点 **加进消息**,它们显示在输入框上方。用 @ 选中的会写成“@名字”,并在输入框上方显示成小标签;发出去时 AI 收到的是你指的那个东西本身(模型的准确 ID、搭配的参数、图片和它的生成参数),不用它去猜。在开头打 **/** 是快捷指令:/停止、/撤销、/展台、/结构、/素材、/专用标签页、/当前画布、/新任务 马上生效,不用等 AI;/重跑、/解释、/继续 帮你写好一句话,改一改再发。↑↓ 选,回车或 Tab 确认,Esc 关掉。
**AI 画的面板**(`show_panel`)显示在工作台右边的**展台**上,后面再多步骤也不会被顶走。右边这一栏一次只显示一样东西,用窗口右上角的三个按钮切换:**展台**(有问题等你回答时带个小黄点)、**节点结构**(AI 跑过的节点结构图,大图;上面可以选看哪一次运行,时间线里运行那一步的“在右侧看大图”也会打开它)、**素材**。再点一下正亮着的那个就收起这一栏;切走再切回来,展台的位置和挑了一半的素材都还在。任务标题和那一排按钮在时间线往下翻时一直停在顶上。时间线上只留一行“在展台打开 ▸”。展台默认跟着最新的面板,你翻回旧面板就停在你看的那张;窗口窄的时候,时间线和展台变成两个标签页切换。右边这一栏是一张单独的卡片,它左边的整条空隙都能拖着调宽窄(鼠标靠近时整条边会亮起;点一下后也能按 ← → 调;AMB 记得你调的宽度,双击恢复默认),时间线总会留够地方。窗口左上角的按钮收起 / 展开左边的任务列表;收起以后,鼠标移到这个按钮上,任务列表会临时浮出来,点一个任务或者鼠标移开就自动收回。素材在右边这一栏里时,鼠标移过卡片不会再跳进详细说明,点右边的 ⓘ 才看。小窗里素材是一行一个的列表(图标、名字、来源、勾选),搜索框旁边三个小图标是“全部 / 已选 / 常用”;展开成大窗口就是带预览的卡片。这些地方的滚动条平时是隐藏的,鼠标移到能滚动的那一块上才显出来。AI 可以用插件提供的“积木”拼面板:标题和文字(也可以是提示、警告)、图片(一排、网格,或者两张并排对比)、你的模型(带封面的卡片,点了打开)、表格、列表、参数、进度条。它还能**问你**:几个按钮(单选或多选)、一行输入框。你的回答会交出去,等 AI 用 `get_answers` 来取(它可以等你最多一分钟);每个问题下面有一个状态标签:琥珀色的 **已发送 · 等 AI 来取**,AI 拿到后变成绿色的 **AI 已收到 · 时间**。你的留言、批注、发给它的图片也是一样。不想答的题点 **这题跳过**,AI 会知道你跳过了,自己拿主意。AMB 没法主动叫醒 AI,所以回答之后要等它下一次来问。
+14
View File
@@ -1,5 +1,6 @@
"""A run's shape and what changed (ai_run_map.py), the task's own canvas tab (ai_task_canvas.py),
pictures handed to the AI (ai_pictures.py) and show_run_map."""
import io
import json
import os
import time
@@ -138,6 +139,19 @@ class PictureTests(unittest.TestCase):
with self.assertRaises(ai_pictures.PicturesError):
ai_pictures.hand("nope", [picture("y.png")])
def test_a_dropped_picture_is_kept_in_the_inbox_never_over_a_file(self):
data = io.BytesIO()
Image.new("RGB", (8, 8), "red").save(data, "JPEG")
first = ai_pictures.save_upload(data.getvalue(), "../../草稿 参考.png", now=1000)
second = ai_pictures.save_upload(data.getvalue(), "../../草稿 参考.png", now=1000)
self.addCleanup(lambda: [os.remove(os.path.join(OUTPUT, rel)) for rel in (first, second)])
self.assertTrue(first.startswith("ai_inbox/草稿 参考_") and first.endswith(".jpg"), first) # its stem, its real type
self.assertNotEqual(first, second)
ai_pictures.hand(self.task, [first]) # an output picture like any other
for bad in (b"", b"not a picture", b"x" * (ai_pictures.MAX_UPLOAD + 1)):
with self.assertRaises(ai_pictures.PicturesError):
ai_pictures.save_upload(bad, "x.png")
class ShowRunMapTests(unittest.TestCase):
def setUp(self):
+28
View File
@@ -1646,6 +1646,20 @@ export const i18n = {
aiCmdHint_go: "写好:接着做下去",
aiCmdFill_go: "继续。",
aiMessageSend: "发送",
aiNowGate: "AI 想「{what}」,等你允许",
aiNowSee: "看这一步",
aiNowRun: "正在出图",
aiNowTool: "AI 这一步还在进行:{what}",
aiNowAsk: "AI 在问你",
aiNowMore: "还有 {count} 个问题,去展台",
aiNowAnswerHere: "在这里回答…",
aiNowOther: "或者自己写…",
aiNowPick: "去展台挑",
aiNowOpen: "去展台回答",
aiDropHere: "松开,把图片加进这条消息",
aiDropNotPicture: "只能放 PNG、JPEG、WebP 或 GIF 图片。",
aiFoldMore: "展开全文",
aiFoldLess: "收起",
aiMessageMaterials: "选模型和节点给 AI(素材)",
aiTrayRemove: "不发这张",
aiTrayMark: "标注",
@@ -4925,6 +4939,20 @@ export const i18n = {
aiCmdHint_go: "Writes: carry on",
aiCmdFill_go: "Go on.",
aiMessageSend: "Send",
aiNowGate: "The AI wants to \"{what}\" and waits for your OK",
aiNowSee: "See the step",
aiNowRun: "Making pictures",
aiNowTool: "The AI's step is still going: {what}",
aiNowAsk: "The AI asks you",
aiNowMore: "{count} more on the stage",
aiNowAnswerHere: "Answer here…",
aiNowOther: "Or say it yourself…",
aiNowPick: "Pick on the stage",
aiNowOpen: "Answer on the stage",
aiDropHere: "Drop to add the picture to this message",
aiDropNotPicture: "Only PNG, JPEG, WebP or GIF pictures.",
aiFoldMore: "Show all",
aiFoldLess: "Show less",
aiMessageMaterials: "Pick models and nodes for the AI (materials)",
aiTrayRemove: "Leave this one out",
aiTrayMark: "Mark up",
+95
View File
@@ -0,0 +1,95 @@
/**
* Pictures dropped on, or pasted into, the AI workbench's message box (ui_ai_message.js): a file
* from the computer, or a screenshot, is kept in the output folder's ai_inbox (POST
* /anomalous/ai_pictures/upload, api/ai_pictures.py save_upload) and attached to the message; a
* ComfyUI output picture dragged from the page (its /view address) is attached as it is. While
* something is dragged over the box it says it takes it.
*/
import { translate as t } from './locales.js';
import { pictureItem } from './ui_ai_gallery_pick.js';
const TYPES = /^image\/(png|jpeg|webp|gif)$/;
async function upload(file) {
const response = await fetch(`/anomalous/ai_pictures/upload?${new URLSearchParams({ name: file.name || '' })}`, {
method: 'POST', headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
/** An output picture of this ComfyUI named by a dropped address ("/view?filename=…&type=output"), or null. */
function outputOf(text) {
try {
const url = new URL(String(text || '').split('\n')[0].trim(), location.href);
if (url.origin !== location.origin || !url.pathname.endsWith('/view') || (url.searchParams.get('type') || 'output') !== 'output') return null;
const filename = url.searchParams.get('filename');
return filename ? { filename, subfolder: url.searchParams.get('subfolder') || '' } : null;
} catch (_) {
return null;
}
}
/**
* Lets `box` take pictures: `add(item)` attaches one, `room()` says how many more fit, `say(text)`
* tells the user what went wrong.
*/
export function takePictures(box, field, { add, room, say }) {
box.dataset.drop = t('aiDropHere'); // the words over the box while something is dragged over it
const take = async (files, address) => {
const found = outputOf(address);
if (found && !files.length) {
add(pictureItem({ type: 'image', folder: 'output', ...found }, true));
return;
}
const pictures = files.filter(file => TYPES.test(file.type));
if (!pictures.length) {
if (files.length) say(t('aiDropNotPicture'));
return;
}
if (pictures.length > room()) say(t('aiPicsFull'));
box.classList.add('is-uploading');
try {
for (const file of pictures.slice(0, Math.max(0, room()))) {
const kept = await upload(file);
add(pictureItem({ type: 'image', folder: 'output', filename: kept.filename, subfolder: kept.subfolder }, true));
}
} catch (error) {
say(error.message);
} finally {
box.classList.remove('is-uploading');
}
};
let depth = 0; // dragenter / dragleave also come from the box's children
const carries = event => [...(event.dataTransfer?.types || [])].some(type => type === 'Files' || type === 'text/uri-list');
box.addEventListener('dragenter', (event) => {
if (!carries(event)) return;
event.preventDefault();
depth += 1;
box.classList.add('is-dropping');
});
box.addEventListener('dragover', (event) => {
if (!carries(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
});
box.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (!depth) box.classList.remove('is-dropping');
});
box.addEventListener('drop', (event) => {
if (!carries(event)) return;
event.preventDefault();
depth = 0;
box.classList.remove('is-dropping');
void take([...(event.dataTransfer.files || [])], event.dataTransfer.getData('text/uri-list'));
});
field.addEventListener('paste', (event) => {
const files = [...(event.clipboardData?.files || [])];
if (!files.some(file => TYPES.test(file.type))) return; // words paste as words
event.preventDefault();
void take(files, '');
});
}
+6 -2
View File
@@ -35,6 +35,11 @@ async function post(url, body) {
return data;
}
/** The user's answer to a step waiting at a gate: {decision: allow | deny, text?, remember?}. */
export function decideGate(taskId, stepId, answer) {
return post(`/anomalous/ai_tasks/${encodeURIComponent(taskId)}/gate`, { step: stepId, ...answer });
}
/** What the user answered, or the card to answer with, under a step that has a gate; null otherwise. */
export function gateCard(step, taskId, changed) {
const asked = step.gate;
@@ -67,8 +72,7 @@ export function gateCard(step, taskId, changed) {
const answer = async (decision) => {
card.querySelectorAll('button').forEach((item) => { item.disabled = true; });
try {
await post(`/anomalous/ai_tasks/${encodeURIComponent(taskId)}/gate`,
{ step: step.id, decision, text: reason.value.trim(), remember: box.checked });
await decideGate(taskId, step.id, { decision, text: reason.value.trim(), remember: box.checked });
} catch (error) {
await anomalousAlert(error.message);
}
+5 -2
View File
@@ -3,8 +3,9 @@
* view as the timeline scrolls): where the user writes to the AI on that task. Over the input
* what the message carries (ui_ai_tray.js: pictures to mark up or leave out, what "@" names);
* beside it the gallery (ui_ai_gallery_pick.js) and the materials picker, each lit with how many
* it gave; the input grows with what is typed (Enter sends, Shift+Enter breaks the line), with
* "@" to point at things and "/" for commands (ui_ai_mention.js). The words and what is attached
* it gave; pictures dropped on it or pasted (ui_ai_drop.js); the input grows with what is typed
* (Enter sends, Shift+Enter breaks the line), with "@" to point at things and "/" for commands
* (ui_ai_mention.js). The words and what is attached
* go to `onSend(text, refs)`, words optional when something is attached; the AI gets them the
* next time it asks (get_answers), which the hint says, and says to ask in the AI app once the
* task ended. Made once per task, so half-typed text survives the task's redraws. newStepsPill
@@ -14,6 +15,7 @@
import { translate as t } from './locales.js';
import { mentions } from './ui_ai_mention.js';
import { galleryPicker } from './ui_ai_gallery_pick.js';
import { takePictures } from './ui_ai_drop.js';
import { icon, iconButton } from './ui_ai_icons.js';
const MAX_REFS = 12; // api/ai_message_refs.py
@@ -103,6 +105,7 @@ export function messageBox(onSend, source) {
box.addEventListener('anomalous-refs', counted);
field.addEventListener('input', counted);
line.prepend(pics, materials);
takePictures(box, field, { add: item => at.add(item), room, say: (text) => { note.textContent = text; } });
box.refs = { add: item => at.add(item), remove: id => at.remove(id), has: id => at.has(id), room }; // for ui_ai_ready.js's gallery
box.setMaterials = count => materials.setCount(count);
}
+155
View File
@@ -0,0 +1,155 @@
/**
* What the task needs or does now, over its message box (ui_ai_message.js, in the workbench's dock),
* so the user follows it from the foot of the page: a step waiting at a gate (Allow, No, see the
* step), a run going (where it stands, a thin bar, stop), the AI busy with a tool; and the first
* question the AI asked and nobody answered yet, to answer there: a pick of one as buttons (and
* a line of one's own when the AI allows it), a line of text, or the way to the stage for the
* rest (several picks, pictures to pick). Drawn again as
* the task changes; a question's card only when another question comes, so typing in it survives.
*/
import { translate as t } from './locales.js';
import { taskStatus } from './ai_task_status.js';
import { toolKey } from './ai_task_view.js';
import { icon } from './ui_ai_icons.js';
import { progressWords, stopButton } from './ui_ai_run_live.js';
import { panelSteps, QUESTIONS } from './ui_ai_side.js';
import { sendAnswer } from './ui_ai_panel.js';
import { decideGate } from './ui_ai_gate.js';
const RUNNING = ['submitting', 'queued', 'running'];
const MAX_OPTIONS = 6;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = String(text);
return node;
}
function button(className, label, run, title) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = run;
if (title) node.title = title;
return node;
}
/** The questions nobody answered yet, the newest panel's first: [{step, block}]. */
function openAsks(task) {
const out = [];
for (const step of [...panelSteps(task)].reverse()) {
if (step.panel.closed) continue;
for (const block of step.panel.blocks || []) {
if (QUESTIONS.includes(block?.type) && !(step.answers || {})[block.id]) out.push({ step, block });
}
}
return out;
}
/** The line of what goes on: a gate's question, a run, a tool; null when nothing does. */
function statusLine(task, ctx) {
const gate = (task.steps || []).findLast(step => step.state === 'asking' && step.gate);
if (gate) {
const line = el('div', 'anomalous-ai-now-line is-gate');
const answer = decision => async () => {
line.querySelectorAll('button').forEach((node) => { node.disabled = true; });
try {
await decideGate(task.id, gate.id, { decision });
} finally {
ctx.refresh();
}
};
line.append(icon('shield', 15), el('span', 'anomalous-ai-now-words', t('aiNowGate', { what: t(toolKey(gate.tool)) })),
button('anomalous-ai-now-allow', t('aiGateAllow'), answer('allow')), button('anomalous-ai-now-deny', t('aiGateDeny'), answer('deny')),
button('anomalous-ai-link', t('aiNowSee'), () => ctx.showStep(gate.id)));
return line;
}
const status = taskStatus(task);
if (status.run && RUNNING.includes(status.state)) {
const { words, share } = progressWords({ state: 'submitting', ...status.run.progress });
const line = el('div', 'anomalous-ai-now-line is-run');
const bar = el('span', `anomalous-ai-now-bar${share === null ? ' is-waiting' : ''}`);
const fill = el('span', 'anomalous-ai-now-fill');
if (share !== null) fill.style.width = `${Math.round(share * 100)}%`;
bar.append(fill);
line.append(el('span', 'anomalous-ai-now-pulse'), el('span', 'anomalous-ai-now-words', `${t('aiNowRun')} · ${words}`),
...(share !== null ? [el('b', 'anomalous-ai-now-percent', `${Math.round(share * 100)}%`)] : []), bar);
if (status.run.prompt_ids?.length) line.append(stopButton(task.id, status.run.id, ctx.refresh, true, Boolean(status.run.stop || status.run.progress?.stopping)));
return line;
}
if (status.state === 'tool') {
const line = el('div', 'anomalous-ai-now-line is-tool');
line.append(el('span', 'anomalous-ai-now-pulse'), el('span', 'anomalous-ai-now-words', t('aiNowTool', { what: t(toolKey(status.active.tool)) })));
return line;
}
return null;
}
/** The card answering the first open question; `more`: how many others wait. */
function askCard(task, { step, block }, more, ctx) {
const card = el('div', 'anomalous-ai-now-ask');
const head = el('div', 'anomalous-ai-now-ask-head');
head.append(icon('question', 15), el('span', 'anomalous-ai-now-question', block.question || step.panel.title || t('aiNowAsk')));
if (more) head.append(button('anomalous-ai-link', t('aiNowMore', { count: more }), ctx.openStage));
const note = el('div', 'anomalous-ai-now-error');
const send = async (body, nodes) => {
nodes.forEach((node) => { node.disabled = true; });
try {
await sendAnswer(task.id, { step: step.id, block: block.id, ...body });
ctx.refresh();
} catch (error) {
note.textContent = error.message;
nodes.forEach((node) => { node.disabled = false; });
}
};
const answers = el('div', 'anomalous-ai-now-answers');
const options = Array.isArray(block.options) ? block.options : [];
const ownWords = (placeholder) => {
const field = el('input', 'anomalous-ai-now-field');
field.placeholder = placeholder;
const go = button('anomalous-ai-panel-send', t('aiPanelSend'), () => {
if (field.value.trim()) void send({ text: field.value.trim() }, [field, go, ...answers.querySelectorAll('button')]);
});
field.addEventListener('keydown', (event) => {
event.stopPropagation(); // typing here is not the browser's shortcuts
if (event.key === 'Enter' && !event.isComposing) go.click();
});
answers.append(field, go);
};
if (block.type === 'choices' && !block.multiple && options.length <= MAX_OPTIONS) {
for (const option of options) {
const chip = button(`anomalous-ai-now-option${option.recommended ? ' is-recommended' : ''}`, '', () => send({ choice: [option.id] }, [...answers.querySelectorAll('button')]), option.detail || '');
if (option.recommended) chip.append(icon('star', 12));
chip.append(el('span', '', option.label));
answers.append(chip);
}
if (block.other) ownWords(t('aiNowOther'));
} else if (block.type === 'input') {
ownWords(block.placeholder || t('aiNowAnswerHere'));
} else {
answers.append(button('anomalous-ai-now-option', t(block.type === 'picks' ? 'aiNowPick' : 'aiNowOpen'), ctx.openStage));
}
answers.append(button('anomalous-ai-now-skip', t('aiPanelSkip'), () => send({ skip: true }, [...answers.querySelectorAll('button')])));
card.append(head, answers, note);
return card;
}
/** The strip, made once per task view; `update(task, ctx)` with ctx {refresh(), openStage(), showStep(id)}. */
export function nowStrip() {
const strip = el('div', 'anomalous-ai-now');
const line = el('div', 'anomalous-ai-now-status');
const ask = el('div', 'anomalous-ai-now-asks');
strip.append(ask, line);
strip.update = (task, ctx) => {
line.replaceChildren(...[statusLine(task, ctx)].filter(Boolean));
const asks = task.ended ? [] : openAsks(task);
const key = asks.length ? JSON.stringify([asks[0].step.id, asks[0].block.id, asks.length]) : '';
if (key !== ask.dataset.key) {
ask.dataset.key = key;
ask.replaceChildren(...(asks.length ? [askCard(task, asks[0], asks.length - 1, ctx)] : []));
}
strip.hidden = !line.childNodes.length && !ask.childNodes.length;
};
return strip;
}
+7 -6
View File
@@ -34,7 +34,8 @@ function button(className, label, onClick, title) {
const list = value => (Array.isArray(value) ? value : []);
async function send(taskId, body) {
/** An answer to one of a panel's questions ({step, block, choice | text | skip}), as the AI will read it. */
export async function sendAnswer(taskId, body) {
const response = await fetch(`/anomalous/ai_tasks/${encodeURIComponent(taskId)}/answer`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
@@ -56,7 +57,7 @@ function skipButton(ctx, block, note) {
const node = button('anomalous-ai-panel-skip', t('aiPanelSkip'), async () => {
node.disabled = true;
try {
await send(ctx.taskId, { step: ctx.stepId, block: block.id, skip: true });
await sendAnswer(ctx.taskId, { step: ctx.stepId, block: block.id, skip: true });
ctx.changed();
} catch (error) {
note.textContent = error.message;
@@ -176,7 +177,7 @@ function otherLine(ctx, block, options) {
go.disabled = true;
options.querySelectorAll('button').forEach(node => { node.disabled = true; });
try {
await send(ctx.taskId, { step: ctx.stepId, block: block.id, text: field.value });
await sendAnswer(ctx.taskId, { step: ctx.stepId, block: block.id, text: field.value });
ctx.changed();
} catch (error) {
go.disabled = false;
@@ -204,7 +205,7 @@ function choices(block, ctx) {
const submit = async (ids) => {
options.querySelectorAll('button').forEach(node => { node.disabled = true; });
try {
await send(ctx.taskId, { step: ctx.stepId, block: block.id, choice: ids });
await sendAnswer(ctx.taskId, { step: ctx.stepId, block: block.id, choice: ids });
} catch (error) {
note.textContent = error.message;
options.querySelectorAll('button').forEach(node => { node.disabled = false; });
@@ -262,7 +263,7 @@ function input(block, ctx) {
if (!field.value.trim()) return;
go.disabled = true;
try {
await send(ctx.taskId, { step: ctx.stepId, block: block.id, text: field.value });
await sendAnswer(ctx.taskId, { step: ctx.stepId, block: block.id, text: field.value });
ctx.changed();
} catch (error) {
note.textContent = error.message;
@@ -304,7 +305,7 @@ const DRAW = {
...ctx,
send: async (ids, note) => {
try {
await send(ctx.taskId, { step: ctx.stepId, block: block.id, choice: ids });
await sendAnswer(ctx.taskId, { step: ctx.stepId, block: block.id, choice: ids });
ctx.changed();
} catch (error) {
note.textContent = error.message;
+20 -19
View File
@@ -4,7 +4,8 @@
* step, the graphics card's memory, the console's own note and last lines (what the console
* window shows), and a stop button (api/ai_run_control.py); estimateBox the size each sampler
* works at and the warnings for a run too big for the card. Used on the run's step in the
* timeline (ui_ai_results.js) and in the run-status card (ui_ai_status.js).
* timeline (ui_ai_results.js) and in the run-status card (ui_ai_status.js); progressWords is the
* line the message box's strip shows too (ui_ai_now.js).
*/
import { translate as t } from './locales.js';
@@ -62,31 +63,31 @@ export function stopButton(taskId, stepId, stopped = () => {}, compact = false,
return node;
}
/** Where a run stands in words, and how far it is (0…1, or null while that is not known). */
export function progressWords(progress) {
if (progress.state === 'submitting') return { words: t('aiExecution_submitting'), share: null };
if (progress.state === 'queued') return { words: t('aiRunQueued', { ahead: progress.ahead }), share: null };
const parts = [];
if (progress.prompts > 1) parts.push(t('aiRunPrompt', { at: progress.prompt, count: progress.prompts }));
if (progress.node) parts.push(progress.max > 1 ? `${progress.node} ${progress.value}/${progress.max}` : progress.node);
if (progress.nodes) parts.push(t('aiRunNodes', { done: progress.nodes_done, count: progress.nodes }));
let share = null;
if (Number.isFinite(progress.percent)) share = progress.percent / 100;
else if (progress.max > 1) share = progress.value / progress.max;
else if (progress.nodes) share = progress.nodes_done / progress.nodes;
return { words: parts.join(' · ') || t('aiRunRunning'), share: share === null ? null : Math.min(1, Math.max(0, share)) };
}
/** Where a run stands; with `stop` ({task, step, stopped}) a stop button beside the bar. */
export function runProgress(progress, stop = null) {
const box = el('div', 'anomalous-ai-run-progress');
let words;
let share = null;
if (progress.state === 'submitting') {
words = t('aiExecution_submitting');
} else if (progress.state === 'queued') {
words = t('aiRunQueued', { ahead: progress.ahead });
} else {
const parts = [];
if (progress.prompts > 1) parts.push(t('aiRunPrompt', { at: progress.prompt, count: progress.prompts }));
if (progress.node) parts.push(progress.max > 1 ? `${progress.node} ${progress.value}/${progress.max}` : progress.node);
if (progress.nodes) parts.push(t('aiRunNodes', { done: progress.nodes_done, count: progress.nodes }));
words = parts.join(' · ') || t('aiRunRunning');
if (Number.isFinite(progress.percent)) share = progress.percent / 100;
else if (progress.max > 1) share = progress.value / progress.max;
else if (progress.nodes) share = progress.nodes_done / progress.nodes;
}
const { words, share } = progressWords(progress);
const line = el('div', 'anomalous-ai-run-line');
line.append(el('span', 'anomalous-ai-run-words', words));
if (share !== null) line.append(el('b', 'anomalous-ai-run-percent', `${Math.round(Math.min(1, Math.max(0, share)) * 100)}%`));
if (share !== null) line.append(el('b', 'anomalous-ai-run-percent', `${Math.round(share * 100)}%`));
const bar = el('div', `anomalous-ai-run-bar${share === null ? ' is-waiting' : ''}`);
const fill = el('span', 'anomalous-ai-run-fill');
if (share !== null) fill.style.width = `${Math.round(Math.min(1, Math.max(0, share)) * 100)}%`;
if (share !== null) fill.style.width = `${Math.round(share * 100)}%`;
bar.append(fill);
const row = el('div', 'anomalous-ai-run-row');
row.append(bar);
+1 -1
View File
@@ -14,7 +14,7 @@
import { translate as t } from './locales.js';
import { renderPanel } from './ui_ai_panel.js';
const QUESTIONS = ['choices', 'input', 'picks'];
export const QUESTIONS = ['choices', 'input', 'picks'];
const BOTTOM_PX = 60;
function el(tag, className, text) {
+19 -2
View File
@@ -3,7 +3,7 @@
* draws the row around it): the user's own steps (marks on a picture, materials, a change made in
* Anomalous, pictures handed over, a stop, the canvas mode, a message with the pictures it
* carries) each with whether the AI has it (ui_ai_delivery.js), a decision (ui_ai_decisions.js), or
* the AI's note laid out.
* the AI's note laid out (a long one folded until asked).
*/
import { translate as t } from './locales.js';
@@ -26,6 +26,23 @@ function el(tag, className, text) {
}
const said = text => el('div', 'anomalous-ai-user-text', text);
const FOLD_CHARS = 420;
const FOLD_LINES = 8;
/** The AI's note; a long one folded to its first lines, with "show all" / "show less". */
function note(text) {
const node = noteNode(text);
if (text.length <= FOLD_CHARS && text.split('\n').length <= FOLD_LINES) return node;
const box = el('div', 'anomalous-ai-fold is-folded');
const toggle = el('button', 'anomalous-ai-fold-toggle', t('aiFoldMore'));
toggle.type = 'button';
toggle.onclick = () => {
const folded = box.classList.toggle('is-folded');
toggle.textContent = t(folded ? 'aiFoldMore' : 'aiFoldLess');
};
box.append(node, toggle);
return box;
}
/** The pictures a message carries ("subfolder/name.png"), as the AI's own are shown: to open, compare, keep or mark up. */
function namedPictures(step, owner) {
@@ -64,6 +81,6 @@ export function stepBody(step, ctx) {
default:
if (step.kind === 'decision') return [decisionCard(step.decision || {}, ctx.numberOf)];
if (step.kind === 'user') return [...(step.text ? [said(step.text)] : []), ...namedPictures(step, ctx.owner), deliveryState(step)];
return step.text ? [noteNode(step.text)] : [];
return step.text ? [note(step.text)] : [];
}
}
+5 -2
View File
@@ -27,6 +27,7 @@ import { dayLabel, fetchActivity, timeLabel } from './activity_log.js';
import { canUndo, undoEntry } from './canvas_undo.js';
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
import { messageBox, newStepsPill, updateMessageBox } from './ui_ai_message.js';
import { nowStrip } from './ui_ai_now.js';
import { taskCommands } from './ui_ai_commands.js';
import { hidePanelToast, panelToast } from './ui_ai_panel.js';
import { focusPanel, openQuestions, panelLine, panelSteps, stageNews, tabBar } from './ui_ai_side.js';
@@ -224,10 +225,10 @@ function renderTask() {
side: view => pickView(state.stage, view, false, state.task, renderTask, refresh), changed: refresh }),
mark: (image, sent) => openMarker(image, task.id, () => { sent(); void refresh(); }),
openMaterials: () => pickView(state.stage, 'toolbox', false, state.task, renderTask, () => void refresh()) }),
seen: (task.steps || []).length, pill: newStepsPill(box), dock: el('div', 'anomalous-ai-dock'),
seen: (task.steps || []).length, pill: newStepsPill(box), now: nowStrip(), dock: el('div', 'anomalous-ai-dock'),
};
view.tabs = el('div', 'anomalous-ai-tabs');
view.dock.append(view.pill, view.message); // at the foot of the column, in view while the timeline scrolls
view.dock.append(view.pill, view.now, view.message); // at the foot of the column, in view while the timeline scrolls
box.replaceChildren(view.tabs, view.header, view.flow, view.made, view.timeline, view.dock);
}
// A new panel that asks something brings the stage forward when the window is narrow.
@@ -299,6 +300,8 @@ function renderTask() {
} else if (!items.length) rows.push({ key: 'empty', make: () => el('div', 'anomalous-ai-empty-task', t('aiNoSteps')) });
syncRows(view.timeline, rows);
updateMessageBox(view.message, Boolean(task.ended), preparing ? 'waiting' : status, toolboxCount(task));
view.now.update(task, { refresh: () => void refresh(), showStep: id => showStep(view.timeline, id),
openStage: () => pickView(state.stage, 'stage', false, task, renderTask, () => void refresh()) });
view.timeline.classList.toggle('is-waiting', preparing);
box.scrollTop = keepBottom && status === 'live' ? box.scrollHeight : keepTop;
if ((task.steps || []).slice(view.seen).some(step => step.kind !== 'user') && !keepBottom) view.pill.show(); // the AI's, while the user read further up
+2 -2
View File
@@ -35,7 +35,7 @@
@import url("./styles/34-voice-over-panel.css?v=20261021-sample");
@import url("./styles/35-voice-over-drafts.css?v=20261020-drafts");
@import url("./styles/36-reference-audio.css?v=20261020-remove");
@import url("./styles/37-ai-workbench.css?v=20261024-dock");
@import url("./styles/37-ai-workbench.css?v=20261024-now");
@import url("./styles/38-ai-panels.css?v=20261024-feel");
@import url("./styles/39-ai-stage.css?v=20261021-stage");
@import url("./styles/40-ai-side.css?v=20261024-dock");
@@ -44,5 +44,5 @@
@import url("./styles/43-ai-toolbox.css?v=20261024-feel");
@import url("./styles/44-ai-status.css?v=20261024-stop");
@import url("./styles/45-ai-side-views.css?v=20261024-grip");
@import url("./styles/46-ai-mention.css?v=20261024-dock");
@import url("./styles/46-ai-mention.css?v=20261024-now");
@import url("./styles/47-ai-decisions.css?v=20261024-feel");
+7
View File
@@ -875,3 +875,10 @@
background: var(--amb-ok);
box-shadow: 0 0 0 2px var(--amb-bg-panel, #16161a);
}
/* A long note of the AI's, folded to its first lines (ui_ai_step_body.js). */
.anomalous-ai-fold { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.anomalous-ai-fold > :first-child { max-width: 100%; }
.anomalous-ai-fold.is-folded > :first-child { max-height: 10.5em; overflow: hidden; -webkit-mask-image: linear-gradient(black 55%, transparent); mask-image: linear-gradient(black 55%, transparent); }
.anomalous-ai-fold-toggle { padding: 2px 0; border: 0; background: none; color: var(--amb-link); font: inherit; font-size: 12px; cursor: pointer; }
.anomalous-ai-fold-toggle:hover { text-decoration: underline; }
+108
View File
@@ -244,3 +244,111 @@
}
.anomalous-ai-new-steps[hidden] { display: none; }
.anomalous-ai-new-steps:hover { background: color-mix(in srgb, var(--amb-link) 22%, var(--amb-bg-panel)); }
/* What the task needs or does now, over the message box (ui_ai_now.js). */
.anomalous-ai-now { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
.anomalous-ai-now[hidden], .anomalous-ai-now-status:empty, .anomalous-ai-now-asks:empty { display: none; }
.anomalous-ai-now-line {
display: flex;
align-items: center;
gap: 10px;
min-height: 34px;
padding: 4px 6px 4px 12px;
border: 1px solid var(--amb-border);
border-radius: 11px;
background: color-mix(in srgb, var(--amb-bg-page), rgb(var(--amb-ink-rgb)) 5%);
color: var(--amb-text-soft);
font-size: 12.5px;
}
.anomalous-ai-now-line.is-run { border-color: color-mix(in srgb, var(--amb-link) 45%, transparent); }
.anomalous-ai-now-line.is-gate { border-color: color-mix(in srgb, var(--amb-warn) 60%, transparent); background: color-mix(in srgb, var(--amb-warn) 10%, var(--amb-bg-page)); color: var(--amb-text-main); }
.anomalous-ai-now-words { flex: 0 1 auto; min-width: 0; overflow: hidden; color: var(--amb-text-main); text-overflow: ellipsis; white-space: nowrap; }
.anomalous-ai-now-line.is-gate .anomalous-ai-now-words { flex: 1; }
.anomalous-ai-now-percent { flex: none; color: var(--amb-text-main); font-variant-numeric: tabular-nums; }
.anomalous-ai-now-pulse { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--amb-link); animation: anomalous-ai-now-pulse 1.4s ease-in-out infinite; }
@keyframes anomalous-ai-now-pulse { 50% { opacity: 0.3; transform: scale(0.75); } }
.anomalous-ai-now-bar { position: relative; flex: 1 1 80px; height: 4px; min-width: 60px; overflow: hidden; border-radius: 2px; background: rgba(var(--amb-ink-rgb), 0.1); }
.anomalous-ai-now-fill { position: absolute; inset: 0 auto 0 0; border-radius: 2px; background: var(--amb-link); transition: width 0.4s; }
.anomalous-ai-now-bar.is-waiting .anomalous-ai-now-fill { width: 30%; animation: anomalous-ai-now-wait 1.6s ease-in-out infinite; }
@keyframes anomalous-ai-now-wait { from { left: -30%; } to { left: 100%; } }
.anomalous-ai-now-allow, .anomalous-ai-now-deny {
flex: none;
height: 26px;
padding: 0 12px;
border: 1px solid var(--amb-border);
border-radius: 7px;
background: none;
color: var(--amb-text-main);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.anomalous-ai-now-allow { border-color: var(--amb-ok); background: color-mix(in srgb, var(--amb-ok) 22%, transparent); font-weight: 650; }
.anomalous-ai-now-allow:hover { background: color-mix(in srgb, var(--amb-ok) 34%, transparent); }
.anomalous-ai-now-deny:hover { border-color: var(--amb-danger, #d9534f); }
.anomalous-ai-now-ask {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--amb-link) 55%, transparent);
border-radius: 12px;
background: color-mix(in srgb, var(--amb-link) 9%, var(--amb-bg-page));
}
.anomalous-ai-now-ask-head { display: flex; align-items: center; gap: 8px; color: var(--amb-link); }
.anomalous-ai-now-question { flex: 1; min-width: 0; color: var(--amb-text-main); font-size: 13px; font-weight: 650; }
.anomalous-ai-now-answers { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.anomalous-ai-now-option {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border: 1px solid color-mix(in srgb, var(--amb-link) 45%, transparent);
border-radius: 999px;
background: var(--amb-bg-panel);
color: var(--amb-text-main);
font: inherit;
font-size: 12.5px;
cursor: pointer;
}
.anomalous-ai-now-option:hover { border-color: var(--amb-link); background: color-mix(in srgb, var(--amb-link) 20%, var(--amb-bg-panel)); }
.anomalous-ai-now-option.is-recommended { border-color: var(--amb-link); }
.anomalous-ai-now-option:disabled, .anomalous-ai-now-skip:disabled { opacity: 0.5; cursor: default; }
.anomalous-ai-now-field {
flex: 1 1 220px;
min-width: 0;
height: 32px;
box-sizing: border-box;
padding: 0 10px;
border: 1px solid var(--amb-border);
border-radius: 8px;
background: var(--amb-lt-sunken, rgba(0, 0, 0, 0.25));
color: var(--amb-text-main);
font: inherit;
font-size: 12.5px;
}
.anomalous-ai-now-field:focus { border-color: color-mix(in srgb, var(--amb-link) 60%, transparent); outline: none; }
.anomalous-ai-now-skip { padding: 4px 8px; border: 0; background: none; color: var(--amb-text-dim); font: inherit; font-size: 12px; cursor: pointer; }
.anomalous-ai-now-skip:hover { color: var(--amb-text-main); text-decoration: underline; }
.anomalous-ai-now-error { color: var(--amb-danger-text); font-size: 12px; }
.anomalous-ai-now-error:empty { display: none; }
@media (prefers-reduced-motion: reduce) { .anomalous-ai-now-pulse, .anomalous-ai-now-bar.is-waiting .anomalous-ai-now-fill { animation: none; } }
/* A picture dragged over the message box (ui_ai_drop.js), and while one uploads. */
.anomalous-ai-message.is-dropping { border-color: var(--amb-link); border-style: dashed; background: color-mix(in srgb, var(--amb-link) 12%, var(--amb-bg-page)); }
.anomalous-ai-message.is-dropping::after {
position: absolute;
z-index: 7;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: inherit;
background: color-mix(in srgb, var(--amb-link) 18%, rgba(0, 0, 0, 0.55));
color: #fff;
content: attr(data-drop);
font-size: 14px;
font-weight: 650;
pointer-events: none;
}
.anomalous-ai-message.is-uploading .anomalous-ai-message-pics { animation: anomalous-ai-now-pulse 1.2s ease-in-out infinite; }