From ecbebc049d771ca275b71574209b20fec662b263 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:11:10 +0800 Subject: [PATCH] Dock the message box at the foot of the task like a chat composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message box stays at the bottom of the task column however the timeline scrolls. Over the input, what the message carries (ui_ai_tray.js): pictures with ✎ to mark up (sent on their own) and ×, chips for what "@" names; gallery pictures are attached, not typed in as "@name". Beside it the gallery and materials buttons with their counts; the input grows (Enter sends, Shift+Enter breaks the line) and pictures alone may be sent. "New steps below" appears when the AI adds steps while the user reads further up; one's own message scrolls into view. Pictures a message carries show as small image cards with the usual tools. The ready page keeps the big gallery and what is ready; the box below takes its pictures. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 16 +++-- CHANGELOG.md | 6 +- api/ai_message_refs.py | 2 +- api/ai_panels.py | 7 ++- docs/guides/mcp.md | 4 +- tests/test_ai_message_refs.py | 3 + web/modules/locales.js | 34 ++++++----- web/modules/ui_ai_mention.js | 72 +++++++++++------------ web/modules/ui_ai_message.js | 103 ++++++++++++++++++++++++--------- web/modules/ui_ai_ready.js | 17 ++---- web/modules/ui_ai_step_body.js | 32 +++++----- web/modules/ui_ai_tray.js | 64 ++++++++++++++++++++ web/modules/ui_ai_workbench.js | 19 +++--- web/styles.css | 8 +-- web/styles/37-ai-workbench.css | 41 ++++++++++--- web/styles/40-ai-side.css | 5 +- web/styles/41-ai-results.css | 5 ++ web/styles/46-ai-mention.css | 98 +++++++++++++++++++++++++------ 18 files changed, 373 insertions(+), 163 deletions(-) create mode 100644 web/modules/ui_ai_tray.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f2e2c6c..0bd34a3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -270,12 +270,16 @@ covered by the `styles.css` manifest. undo, scan page, ▶ for audio), updated live; also the home page's "What the AI did lately" and the rail's dot (`owner.railNews`). A task's parts stay in the page between redraws: a step is drawn again only when it changed (`syncRows`), so a scrolled diagram or strip and - half-typed text survive the AI's steps coming in. `ui_ai_message.js` is the message box under - a task (made once per task); `ui_ai_mention.js` 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, chips of what is named), and - `ui_ai_gallery_pick.js` its picture button (gallery pictures picked into the message) and "/" (`ui_ai_commands.js`: stop, undo, side views, new task, canvas mode, - words to fill in); `api/ai_message_refs.py` checks what the "@" name and gives each to the AI + half-typed text survive the AI's steps coming in. `ui_ai_message.js` is the message box, made + 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 + 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 + fill in); `ui_ai_gallery_pick.js` is the gallery grid pictures are attached from. + `api/ai_message_refs.py` checks what the message names and gives each to the AI with the message (in full, pictures attached; get_toolbox shares its `lookup`), `tests/test_ai_message_refs.py`. `ui_ai_step_body.js` is what a timeline step says under its title (the user's own steps with whether the AI has them, a decision's card, the AI's note). diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f60dc..c33fab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,10 @@ - The AI switching to its task tab is no longer reported to it as a canvas change of the user's. - **工作台里直接用图库的图**:留言框左边新增图片按钮,打开图库(最新的在前,可按提示词、模型、seed 搜索),点选几张后 **加进消息**;打 **@** 时也能看到图库最新的图,打字会顺带在图库里搜。AI 收到的是图片本身和它的生成参数。 - 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 note below (click to add or drop a picture), the note itself ("@", send), the materials button and what is ready; the AI gets it all when it takes the task. Pictures a message names show as thumbnails on the timeline. +- **等 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)和 **×**;@ 提到的模型、搭配显示成小标签。从图库挑的图不再往文字里塞“@文件名”。左边是 **图库** 和 **素材** 两个按钮,给了东西就亮起并显示数量。输入框会随内容变高: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”在什么都没选时不能点。挪模型那一步的标题写文件名和挪到哪,不再是一串编号;只改了一个节点时不再出现两个“在画布上找到”;“接着哪个任务…”的字号和旁边一致。 - Clearer feedback: picked pictures get a thick glowing frame, a tint and a big tick; the picture button lights while its panel is open and counts the message's pictures; materials rows, the folded reads row (now with a ›), gate options, the mode switch, the source menu, send and copy buttons, the models page's tabs, the home page's set-up button and expandable activity rows all react clearly to the pointer. The Stage button opens an empty stage that says what will appear there; "Hand to AI · 0" is disabled with nothing picked; move_model's step names the file and where it goes; a one-node canvas change shows one "find on canvas"; the "Goes on from…" chip matches the size around it. - 右边那一栏的宽度把手更好抓了:卡片左边整条空隙(从上到下)都能拖,把手平时就淡淡地显示,鼠标靠近时整条边亮起;点一下后也能用 ← → 调宽窄。 diff --git a/api/ai_message_refs.py b/api/ai_message_refs.py index d2be7f1..63ac56a 100644 --- a/api/ai_message_refs.py +++ b/api/ai_message_refs.py @@ -82,7 +82,7 @@ def message_of(step): else: item["details"] = found items.append(item) - out = {"refs": items, "refs_note": "What the user's @ names in their text point at, exactly."} + out = {"refs": items, "refs_note": "What the user's @ names in their text point at, and the pictures they attached, exactly."} if files: out["_pictures"] = files return out diff --git a/api/ai_panels.py b/api/ai_panels.py index d979ec5..5743bfc 100644 --- a/api/ai_panels.py +++ b/api/ai_panels.py @@ -411,15 +411,16 @@ MAX_MESSAGE = 2000 def leave_message(task_id, text, refs=None, now=None): - """A message from the user to the AI working on task `task_id`, with what its "@" point at - (ai_message_refs.py). Returns the step.""" + """A message from the user to the AI working on task `task_id`, with what its "@" point at and + the pictures attached (ai_message_refs.py); words may be left out when something is attached. + Returns the step.""" from . import ai_message_refs - text = _text(text, MAX_MESSAGE, "text", True) try: refs = ai_message_refs.clean(refs) except ai_message_refs.RefsError as error: raise PanelError(str(error)) + text = _text(text, MAX_MESSAGE, "text", not refs) now = time.time() if now is None else now step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_message", "kind": "user", "ok": True, "summary": "user_message", "text": text, "delivered": False, "results": [], **({"refs": refs} if refs else {})} diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index bef1ed4..c8725c1 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -166,7 +166,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp - AI 还在干活时,时间线会自己往下长;你在别的页面时,左边栏的 AI 工作台会亮一个小点。首页也有“AI 最近做了什么”。 - **给 AI 留言**:任务时间线下面有个留言框,比如“背景再亮一点”“先别跑了”。留言会成为任务里的一步,AI 下一次调用任何工具(或 `get_answers`)时都会自动收到,下面写着它有没有看到。AMB 没法叫醒已经停下的 AI:它如果已经结束回合,请回到 AI 软件里让它继续。 - 左边的任务按日期分组(今天、昨天、某天),前面的小图标表示状态:● 进行中、? 等你回答、! 有一步出错、✓ 完成。 -- **新任务**:点任务列表上方的 **+ 新任务**,写上要 AI 做什么(也可以空着)。这个任务会显示“等 AI 接手”,下一个来调用的 AI 就会接手它。等的时候可以切到顶上的 **备料** 页先备好材料:在大图库里点图片放进说明,写上要交代的(@ 提到模型和搭配)点发送,用 **选模型和节点** 挑素材;备好的都列在那页,AI 接手时一起收到。之后的步骤都记在这里,不会接在它上一个任务后面。AI 即使先查资料再调用 `begin_task`,也会继续这个任务,保留你定的标题;只有另一件事才用 `new_task=true` 明确另开,或先用 `report(done=true)` 结束当前任务。 +- **新任务**:点任务列表上方的 **+ 新任务**,写上要 AI 做什么(也可以空着)。这个任务会显示“等 AI 接手”,下一个来调用的 AI 就会接手它。等的时候可以切到顶上的 **备料** 页,在大图库里点图片,它们会放到最下面的输入框上;在输入框里写上要交代的(@ 提到模型和搭配),用输入框左边的 **素材** 按钮挑模型和节点,点发送。备好的都列在那页,AI 接手时一起收到。之后的步骤都记在这里,不会接在它上一个任务后面。AI 即使先查资料再调用 `begin_task`,也会继续这个任务,保留你定的标题;只有另一件事才用 `new_task=true` 明确另开,或先用 `report(done=true)` 结束当前任务。 - 任务可以改名、从列表移除(只删记录,图和画布不受影响)。记录存在 ComfyUI 的 user 文件夹里,保留最近 300 个。 - 只看得到经过这个接口的事。AI 绕开它做的,可以让它用 `report` 报告。 @@ -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 下次接手这个任务时,也会先收到这些决策。 -**留言框里的 @ 和 /**:在任务下面的留言框里打 **@**,会弹出一张能搜索的列表:这个任务出过的图、图库里最新的图(打字时也按提示词、模型、seed 在图库里搜)、搭配、模型、工作流、提示词、节点包、节点。留言框左边的 **图片按钮** 打开图库:点选几张(一次最多 8 张),点 **加进消息**,它们就像 @ 一样写进留言。选中的会写成“@名字”,留言框下面显示成小标签;发出去时 AI 收到的是你指的那个东西本身(模型的准确 ID、搭配的参数、图片和它的生成参数),不用它去猜。在开头打 **/** 是快捷指令:/停止、/撤销、/展台、/结构、/素材、/专用标签页、/当前画布、/新任务 马上生效,不用等 AI;/重跑、/解释、/继续 帮你写好一句话,改一改再发。↑↓ 选,回车或 Tab 确认,Esc 关掉。 +**留言框**一直停在任务这一栏的最下面。它上面显示这条消息要带的图(每张可以 **✎ 标注** 或 **×** 拿掉)和 @ 提到的东西;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,所以回答之后要等它下一次来问。 diff --git a/tests/test_ai_message_refs.py b/tests/test_ai_message_refs.py index fcb019f..c5aa210 100644 --- a/tests/test_ai_message_refs.py +++ b/tests/test_ai_message_refs.py @@ -53,6 +53,9 @@ class RefsTests(unittest.TestCase): def test_a_plain_message_has_no_refs_and_bad_refs_are_refused(self): self.assertNotIn("refs", ai_panels.leave_message(self.task, "继续")) + # Pictures alone, no words: still a message. + alone = ai_panels.leave_message(self.task, "", [{"kind": "pictures", "id": "ref.png", "label": "ref.png"}]) + self.assertEqual((alone["text"], [ref["id"] for ref in alone["refs"]]), ("", ["ref.png"])) for refs in ("x", [{"kind": "files", "id": "a"}], [{"kind": "models", "id": ""}], [{"kind": "nodes", "id": str(n)} for n in range(13)]): with self.assertRaises(ai_panels.PanelError, msg=refs): ai_panels.leave_message(self.task, "看这个", refs) diff --git a/web/modules/locales.js b/web/modules/locales.js index 35c73b5..5fe2aa3 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1448,17 +1448,13 @@ export const i18n = { aiWaitingReadyGo: "去备料", aiWaitingReadySome: "已经备好 {count} 样", aiReadyTitle: "给 AI 备好材料", - aiReadyBody: "图片、说明、要用的模型和节点都可以先放在这里,AI 接手这个任务时会一起收到。", - aiReadyPictures: "图库:点图片,它就放进下面的说明里", - aiReadyWords: "说明(打 @ 可以提到模型、搭配和图),写好点发送", - aiReadyMaterials: "模型和节点", - aiReadyMaterialsBody: "在右边的素材里挑 AI 能用、或者只能用的模型、LoRA 和节点。", + aiReadyBody: "挑好的图会放到最下面的输入框上:可以点 ✎ 标注,写上要交代的,再发送。AI 接手这个任务时会一起收到。", + aiReadyPictures: "图库:点一张,它就出现在最下面的输入框里(再点一下拿掉)", aiWaitingReady: "先备好材料(可以跳过)", - aiWaitingReadyBody: "图片、模型、节点、补充说明都可以先放好,AI 接手时一起收到。", - aiWaitingMaterials: "选模型和节点", + aiWaitingReadyBody: "在最下面的输入框里写交代、加图片和素材,或者去「备料」从图库里挑。AI 接手时一起收到。", aiWaitingMaterialsCount: "选好了 {count} 样素材", aiWaitingReadyDone: "已经备好({count})", - aiMessageWaiting: "左边的图片按钮从图库选图,@ 提到模型、搭配和图。发出去的会等着,AI 接手时一起收到。", + aiMessageWaiting: "发出去的会等着,AI 接手时一起收到。Enter 发送,Shift+Enter 换行。", aiHintTitle: "第一次用?先把 AI 连上 AMB", aiHintBody: "只需要做一次:把 AMB 的地址加到你的 AI 软件里(Claude、Gemini、Cursor 等),以后它就能直接接手这里的任务。", aiHintOpen: "查看连接方法", @@ -1650,6 +1646,11 @@ export const i18n = { aiCmdHint_go: "写好:接着做下去", aiCmdFill_go: "继续。", aiMessageSend: "发送", + aiMessageMaterials: "选模型和节点给 AI(素材)", + aiTrayRemove: "不发这张", + aiTrayMark: "标注", + aiTrayMarkHint: "在图上画框、圈、箭头,写要改哪里;标好后单独发给 AI", + aiNewSteps: "下面有新的步骤", aiMessageHint: "AI 做下一步或来读取时就会收到。它要是已经停下了,请回到 AI 软件里说。", aiMessageEnded: "这个任务已经结束了,AI 多半不会再来看;新的要求请回到 AI 软件里说。", aiMarkZoom: "放大看这里(再点一下还原)", @@ -4726,17 +4727,13 @@ export const i18n = { aiWaitingReadyGo: "Put things ready", aiWaitingReadySome: "{count} ready", aiReadyTitle: "Put things ready for the AI", - aiReadyBody: "Pictures, notes and the models and nodes to use can wait here; the AI gets them when it takes the task.", - aiReadyPictures: "Gallery: click a picture to put it in the note below", - aiReadyWords: "Note (\"@\" names models, combos and pictures); send it when done", - aiReadyMaterials: "Models and nodes", - aiReadyMaterialsBody: "Pick in the materials on the right what the AI may, or may only, use.", + aiReadyBody: "Pictures picked here go onto the message box at the bottom: mark them up with ✎, say what you want, and send. The AI gets it all when it takes the task.", + aiReadyPictures: "Gallery: click a picture to put it on the message box below (click again to take it off)", aiWaitingReady: "Put things ready (you can skip this)", - aiWaitingReadyBody: "Pictures, models, nodes and notes can wait here; the AI gets them when it takes the task.", - aiWaitingMaterials: "Pick models and nodes", + aiWaitingReadyBody: "Write what you want in the message box at the bottom, add pictures and materials, or pick from the gallery on \"Put ready\". The AI gets it when it takes the task.", aiWaitingMaterialsCount: "{count} materials picked", aiWaitingReadyDone: "Ready for the AI ({count})", - aiMessageWaiting: "The picture button picks from the gallery; @ names models, combos and pictures. What you send waits; the AI gets it when it takes the task.", + aiMessageWaiting: "What you send waits; the AI gets it when it takes the task. Enter sends, Shift+Enter breaks the line.", aiHintTitle: "First time? Connect your AI to Anomalous", aiHintBody: "Only once: add Anomalous’s address to your AI app (Claude, Gemini, Cursor…) and it can take the tasks here from then on.", aiHintOpen: "How to connect", @@ -4928,6 +4925,11 @@ export const i18n = { aiCmdHint_go: "Writes: carry on", aiCmdFill_go: "Go on.", aiMessageSend: "Send", + aiMessageMaterials: "Pick models and nodes for the AI (materials)", + aiTrayRemove: "Leave this one out", + aiTrayMark: "Mark up", + aiTrayMarkHint: "Draw boxes, circles and arrows on it and say what to change; it goes to the AI on its own", + aiNewSteps: "New steps below", aiMessageHint: "The AI gets it on its next step or when it asks. If it has stopped, tell it in the AI app.", aiMessageEnded: "This task has ended, so the AI will probably not look here again; ask for more in the AI app.", aiMarkZoom: "Zoom in here (again: back)", diff --git a/web/modules/ui_ai_mention.js b/web/modules/ui_ai_mention.js index 7933a03..85ec1cb 100644 --- a/web/modules/ui_ai_mention.js +++ b/web/modules/ui_ai_mention.js @@ -3,11 +3,12 @@ * "@" lists what the user can point the AI at: this task's pictures, the gallery's newest ones * (and, for what is typed, those the gallery's search finds: prompt words, models, seed), combos, * models, workflows, prompts, custom node packs and node types (the materials picker's data, - * ai_toolbox_data.js). The picture button's panel (ui_ai_gallery_pick.js) adds pictures as well. - * Taking one writes "@name" into the text and keeps what it is; the message then carries exactly - * what each "@" points at (api/ai_message_refs.py), shown as chips under the box until it is - * sent. "/" at the start lists the commands (ui_ai_commands.js): some act at once, some fill in - * words to send. ↑↓ move, Enter or Tab take, Esc closes; the pointer works as well. + * ai_toolbox_data.js). Taking one writes "@name" into the text and keeps what it is; pictures + * picked from the gallery (ui_ai_gallery_pick.js, the "Put ready" page) are attached without a + * name in the text. The message carries exactly what each "@" points at and what is attached + * (api/ai_message_refs.py), shown over the input until it is sent (ui_ai_tray.js). "/" at the + * start lists the commands (ui_ai_commands.js): some act at once, some fill in words to send. + * ↑↓ move, Enter or Tab take, Esc closes; the pointer works as well. */ import { translate as t } from './locales.js'; @@ -17,6 +18,7 @@ import { sourceBadge } from './ui_ai_toolbox_icons.js'; import { packItems, pageNodes } from './ui_ai_toolbox.js'; import { icon } from './ui_ai_icons.js'; import { galleryPictures, pictureItem, thumbUrl } from './ui_ai_gallery_pick.js'; +import { drawTray } from './ui_ai_tray.js'; const SHOWN = 8; const RECENT = 30; // the gallery's newest pictures "@" offers @@ -79,20 +81,19 @@ const kindWords = item => [item.kind === 'pictures' ? t(item.gallery ? 'aiMentio item.kind === 'models' ? item.type : item.kind === 'prompts' && item.source ? t(`aiTbPromptFrom_${item.source}`) : ''].filter(Boolean).join(' · '); /** - * "@" and "/" for `field` in the message box `box`. `source`: {task(), commands()}. Returns - * {keydown(event) (true when the list took the key), refs(text) (what the "@" still in `text` - * point at), add(item) (names it at the end of the text), remove(id), has(id), pictures() and - * count() (how many pictures, and things, the text names), clear()}. `box` hears - * "anomalous-refs" whenever what is named changes. + * "@" and "/" for `field` in the message box `box`, what they name drawn into `tray`. `source`: + * {task(), commands(), mark(image, sent)? (marks a picture up for the AI)}. Returns {keydown(event) + * (true when the list took the key), refs() (what the message carries), add(item) (attaches it), + * remove(id), has(id), pictures() and count() (how many pictures, and things, it carries), + * clear()}. `box` hears "anomalous-refs" whenever that changes. */ -export function mentions(field, box, source) { +export function mentions(field, box, source, tray) { const list = el('div', 'anomalous-ai-mention'); list.setAttribute('role', 'listbox'); list.hidden = true; - const chips = el('div', 'anomalous-ai-mention-refs'); - chips.hidden = true; - box.append(list, chips); - let refs = []; + box.append(list); + let refs = []; // {kind, id, label, item, attached}: attached ones need no "@name" in the text + const live = () => refs.filter(ref => ref.attached || field.value.includes(`@${ref.label}`)); let open = null; // {mode: 'at' | 'slash', start, items, at} let recent = null; // the gallery's newest pictures, read when "@" first opens let found = { query: null, items: [] }; // what the gallery's search found for a query @@ -128,26 +129,20 @@ export function mentions(field, box, source) { function drawChips() { box.dispatchEvent(new Event('anomalous-refs')); - chips.hidden = !refs.length; - chips.replaceChildren(...refs.map((ref) => { - const chip = el('span', 'anomalous-ai-mention-chip'); - chip.append(lead(ref.item), el('span', 'anomalous-ai-mention-chip-name', ref.label)); - const x = el('button', 'anomalous-ai-mention-chip-x'); - x.type = 'button'; - x.title = t('aiMentionRemove'); - x.append(icon('close', 11)); - x.onclick = () => { + drawTray(tray, live(), { + lead, + remove: (ref) => { drop(ref); field.focus(); - }; - chip.append(x); - return chip; - })); + }, + // Marked up, the picture goes to the AI with its marks: not with this message too. + mark: source.mark ? ref => source.mark(ref.item.image, () => drop(ref)) : null, + }); } function drop(ref) { refs = refs.filter(other => other !== ref); - field.value = field.value.replace(`@${ref.label} `, '').replace(`@${ref.label}`, ''); + if (!ref.attached) field.value = field.value.replace(`@${ref.label} `, '').replace(`@${ref.label}`, ''); drawChips(); } @@ -221,7 +216,12 @@ export function mentions(field, box, source) { draw(); } - field.addEventListener('input', update); + field.addEventListener('input', () => { + const before = refs.length; + refs = live(); // an "@name" deleted from the text leaves the message too + if (refs.length !== before) drawChips(); + update(); + }); field.addEventListener('click', update); field.addEventListener('blur', () => setTimeout(close, 120)); @@ -245,21 +245,19 @@ export function mentions(field, box, source) { } return false; }, - refs: text => refs.filter(ref => text.includes(`@${ref.label}`)).map(({ kind, id, label }) => ({ kind, id, label })), + refs: () => live().map(({ kind, id, label }) => ({ kind, id, label })), add(item) { if (refs.some(ref => ref.kind === item.kind && ref.id === item.id)) return; - const text = field.value.replace(/\s+$/, ''); - field.value = `${text}${text ? ' ' : ''}@${item.title} `; - refs.push({ kind: item.kind, id: item.id, label: item.title, item }); + refs.push({ kind: item.kind, id: item.id, label: item.title, item, attached: true }); drawChips(); }, remove(id) { const ref = refs.find(item => item.id === id); if (ref) drop(ref); }, - has: id => refs.some(ref => ref.id === id && field.value.includes(`@${ref.label}`)), - pictures: () => refs.filter(ref => ref.kind === 'pictures' && field.value.includes(`@${ref.label}`)).length, - count: () => refs.filter(ref => field.value.includes(`@${ref.label}`)).length, + has: id => live().some(ref => ref.id === id), + pictures: () => live().filter(ref => ref.kind === 'pictures').length, + count: () => live().length, clear() { refs = []; close(); diff --git a/web/modules/ui_ai_message.js b/web/modules/ui_ai_message.js index 9fb2074..fdc4d3b 100644 --- a/web/modules/ui_ai_message.js +++ b/web/modules/ui_ai_message.js @@ -1,19 +1,25 @@ /** - * The AI workbench's message box (ui_ai_workbench.js): under a task's timeline, where the user - * writes to the AI on that task, with "@" to point at things and "/" for commands - * (ui_ai_mention.js), and a picture button for pictures from the gallery (ui_ai_gallery_pick.js). - * The text and what its "@" point at go to `onSend(text, refs)`; 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. + * The AI workbench's message box (ui_ai_workbench.js keeps it at the foot of a task's column, in + * 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 + * 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 + * is the "new steps below" button over it. */ import { translate as t } from './locales.js'; import { mentions } from './ui_ai_mention.js'; import { galleryPicker } from './ui_ai_gallery_pick.js'; -import { iconButton } from './ui_ai_icons.js'; +import { icon, iconButton } from './ui_ai_icons.js'; const MAX_REFS = 12; // api/ai_message_refs.py const MAX_PICTURES = 8; // a message's pictures, as ai_pictures.py hands them +const MAX_HEIGHT = 168; // the input grows up to about seven lines, then scrolls +const NEAR_BOTTOM = 60; function el(tag, className, text) { const node = document.createElement(tag); @@ -22,25 +28,49 @@ function el(tag, className, text) { return node; } -/** The box; `source`: {task(), commands()} for "@" and "/". With them, `box.refs` names things from outside. */ +/** A button beside the input, with the number of what it gave on its corner. */ +function sideTool(className, name, label, run) { + const node = iconButton(`anomalous-ai-message-tool ${className}`, name, label, run, { iconOnly: true, size: 17 }); + const count = el('span', 'anomalous-ai-message-tool-count'); + node.append(count); + node.setCount = (value) => { + count.textContent = value ? String(value) : ''; + node.classList.toggle('has-items', value > 0); + }; + return node; +} + +/** + * The box; `source`: {task(), commands(), mark(image, sent), openMaterials()} for "@", "/", the + * tray's ✎ and the materials button. With them, `box.refs` attaches things from outside (the + * "Put ready" page's gallery) and `box.setMaterials(count)` shows how many materials are picked. + */ export function messageBox(onSend, source) { const box = el('div', 'anomalous-ai-message'); + const tray = el('div', 'anomalous-ai-tray'); + tray.hidden = true; const line = el('div', 'anomalous-ai-message-line'); - const field = el('input', 'anomalous-ai-message-input'); - field.type = 'text'; + const field = el('textarea', 'anomalous-ai-message-input'); + field.rows = 1; field.maxLength = 2000; field.placeholder = t('aiMessagePlaceholder'); const note = el('div', 'anomalous-ai-message-hint'); const send = el('button', 'anomalous-ai-panel-send', t('aiMessageSend')); send.type = 'button'; let at = null; // "@" and "/" (made once the box is in place) + const fit = () => { + field.style.height = 'auto'; + field.style.height = `${Math.min(field.scrollHeight, MAX_HEIGHT)}px`; + }; send.onclick = async () => { const text = field.value.trim(); - if (!text) return; + const refs = at?.refs() || []; + if (!text && !refs.length) return; send.disabled = true; try { - await onSend(text, at?.refs(text) || []); + await onSend(text, refs); field.value = ''; + fit(); at?.clear(); } catch (error) { note.textContent = error.message; @@ -51,31 +81,30 @@ export function messageBox(onSend, source) { field.addEventListener('keydown', (event) => { event.stopPropagation(); // typing here is not the browser's shortcuts if (at?.keydown(event)) return; - if (event.key === 'Enter' && !event.isComposing) send.click(); + if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + send.click(); + } }); + field.addEventListener('input', fit); line.append(field, send); - box.append(line, note); + box.append(tray, line, note); if (source) { - at = mentions(field, box, source); + at = mentions(field, box, source, tray); const room = () => Math.min(MAX_PICTURES - at.pictures(), MAX_REFS - at.count()); - const pics = iconButton('anomalous-ai-message-pics', 'image', t('aiPicsButton'), () => picker.toggle(), { iconOnly: true, size: 17 }); - const count = el('span', 'anomalous-ai-message-pics-count'); - pics.append(count); + const pics = sideTool('anomalous-ai-message-pics', 'image', t('aiPicsButton'), () => picker.toggle()); pics.setAttribute('aria-expanded', 'false'); const picker = galleryPicker(box, room, (items) => { for (const item of items) at.add(item); field.focus(); }, pics); - // How many pictures the message names, on the button. - const counted = () => { - const named = at.pictures(); - count.textContent = named ? String(named) : ''; - pics.classList.toggle('has-pictures', named > 0); - }; + const materials = sideTool('anomalous-ai-message-materials', 'sparkle', t('aiMessageMaterials'), () => source.openMaterials()); + const counted = () => pics.setCount(at.pictures()); box.addEventListener('anomalous-refs', counted); field.addEventListener('input', counted); - line.prepend(pics); + line.prepend(pics, materials); 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); } box.note = note; return box; @@ -83,7 +112,29 @@ export function messageBox(onSend, source) { /** The hint for where the task stands; quieter while the AI is not working on it. While a task * waits for an AI (`status` "waiting"), what is sent waits with it. */ -export function updateMessageBox(box, ended, status) { +export function updateMessageBox(box, ended, status, materials = 0) { box.classList.toggle('is-quiet', !['live', 'asks', 'waiting'].includes(status)); box.note.textContent = t(ended ? 'aiMessageEnded' : status === 'waiting' ? 'aiMessageWaiting' : 'aiMessageHint'); + box.setMaterials?.(materials); +} + +/** + * "New steps below" over the message box, for a timeline in `scroller`: shown by `show()` when + * steps came in while the user read further up, gone once they are at the bottom; a click takes + * them there. + */ +export function newStepsPill(scroller) { + const pill = el('button', 'anomalous-ai-new-steps'); + pill.type = 'button'; + pill.hidden = true; + pill.append(icon('chevronDown', 14), el('span', '', t('aiNewSteps'))); + pill.onclick = () => scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' }); + const atBottom = () => scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < NEAR_BOTTOM; + const seen = () => { + if (!pill.isConnected) scroller.removeEventListener('scroll', seen); // another task's view by now + else if (atBottom()) pill.hidden = true; + }; + scroller.addEventListener('scroll', seen, { passive: true }); + pill.show = () => { if (!atBottom()) pill.hidden = false; }; + return pill; } diff --git a/web/modules/ui_ai_ready.js b/web/modules/ui_ai_ready.js index 45b817d..d2c79d8 100644 --- a/web/modules/ui_ai_ready.js +++ b/web/modules/ui_ai_ready.js @@ -1,14 +1,13 @@ /** * A task waiting for an AI (ui_ai_workbench.js), on two pages switched at the top: "Get started" * (ui_ai_connect.js waitingCard: what to do, the sentence for the AI, connecting) and "Put ready": - * the gallery as a big grid whose pictures go into the message box (named as "@" does), the - * message box itself (words, "@", send), the materials button, and what is ready so far (the - * user's own steps, drawn by the workbench). The AI gets what is ready when it takes the task. - * Which page shows is kept per task while the page is open. + * the gallery as a big grid whose pictures go onto the message box at the foot of the column + * (attached: to mark up, write about and send there), and what is ready so far (the user's own + * steps, drawn by the workbench). The AI gets what is ready when it takes the task. Which page + * shows is kept per task while the page is open. */ import { translate as t } from './locales.js'; -import { iconButton } from './ui_ai_icons.js'; import { waitingCard } from './ui_ai_connect.js'; import { pictureGrid } from './ui_ai_gallery_pick.js'; @@ -47,11 +46,7 @@ function readyPage(ctx) { pictures.stop(); }; box.addEventListener('anomalous-refs', follow); - const materials = el('div', 'anomalous-ai-ready-row'); - materials.append(el('span', 'anomalous-ai-ready-note', t('aiReadyMaterialsBody')), - iconButton('anomalous-ai-link', 'sparkle', ctx.materials ? t('aiWaitingMaterialsCount', { count: ctx.materials }) : t('aiWaitingMaterials'), ctx.openMaterials)); - page.append(head, section(t('aiReadyPictures'), pictures.search, pictures.grid), section(t('aiReadyWords'), box), - section(t('aiReadyMaterials'), materials)); + page.append(head, section(t('aiReadyPictures'), pictures.search, pictures.grid)); if (ctx.ready.length) page.append(section(t('aiWaitingReadyDone', { count: ctx.ready.length }), ...ctx.ready)); page.mark = pictures.mark; return page; @@ -59,7 +54,7 @@ function readyPage(ctx) { /** * The waiting task's two pages. `ctx`: {rename(title), message (the task's message box), materials - * (how many are picked), openMaterials(), ready (the user's steps so far, drawn)}. + * (how many are picked), ready (the user's steps so far, drawn)}. */ export function waitingView(task, ctx) { const view = el('div', 'anomalous-ai-waiting-view'); diff --git a/web/modules/ui_ai_step_body.js b/web/modules/ui_ai_step_body.js index f30d807..51e857d 100644 --- a/web/modules/ui_ai_step_body.js +++ b/web/modules/ui_ai_step_body.js @@ -1,8 +1,8 @@ /** * What a step of the AI workbench's timeline says under its title, by what it is (ui_ai_workbench.js * 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 its "@" - * name) each with whether the AI has it (ui_ai_delivery.js), a decision (ui_ai_decisions.js), or + * 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. */ @@ -16,7 +16,7 @@ import { toolboxStep } from './ui_ai_toolbox.js'; import { canvasChanges } from './ui_canvas_changes.js'; import { deliveryState } from './ui_ai_delivery.js'; import { decisionCard } from './ui_ai_decisions.js'; -import { thumbUrl } from './ui_ai_gallery_pick.js'; +import { imageResults } from './ui_ai_images.js'; function el(tag, className, text) { const node = document.createElement(tag); @@ -27,22 +27,16 @@ function el(tag, className, text) { const said = text => el('div', 'anomalous-ai-user-text', text); -/** The pictures a message's "@" name, small ("subfolder/name.png"; a deleted one is left out). */ -function namedPictures(step) { - const pictures = (step.refs || []).filter(ref => ref.kind === 'pictures'); - if (!pictures.length) return []; - const strip = el('div', 'anomalous-ai-user-pictures'); - for (const ref of pictures) { +/** 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) { + const pictures = (step.refs || []).filter(ref => ref.kind === 'pictures').map((ref) => { const parts = ref.id.split('/'); - const img = el('img'); - img.src = thumbUrl({ filename: parts.pop(), subfolder: parts.join('/') }); - img.alt = ''; - img.title = ref.id; - img.loading = 'lazy'; - img.onerror = () => img.remove(); - strip.append(img); - } - return [strip]; + return { type: 'image', folder: 'output', filename: parts.pop(), subfolder: parts.join('/') }; + }); + if (!pictures.length) return []; + const box = imageResults(pictures, owner); + box.classList.add('is-said'); // the size of what one hands over, not of results + return [box]; } /** A change the user made in Anomalous while the AI worked (an activity entry), and whether the AI knows. */ @@ -69,7 +63,7 @@ export function stepBody(step, ctx) { case 'user_canvas': return [said(t(step.canvas === 'new' ? 'aiCanvasChoseNew' : 'aiCanvasChoseCurrent')), deliveryState(step)]; default: if (step.kind === 'decision') return [decisionCard(step.decision || {}, ctx.numberOf)]; - if (step.kind === 'user') return [said(step.text), ...namedPictures(step), deliveryState(step)]; + if (step.kind === 'user') return [...(step.text ? [said(step.text)] : []), ...namedPictures(step, ctx.owner), deliveryState(step)]; return step.text ? [noteNode(step.text)] : []; } } diff --git a/web/modules/ui_ai_tray.js b/web/modules/ui_ai_tray.js new file mode 100644 index 0000000..4217dd6 --- /dev/null +++ b/web/modules/ui_ai_tray.js @@ -0,0 +1,64 @@ +/** + * What a message to the AI carries, over its input (ui_ai_message.js; the refs are + * ui_ai_mention.js's): the pictures as small pictures, each with ✎ to mark it up for the AI + * (ui_ai_marker.js, sent on its own) and × to leave it out, then what "@" names (models, combos, + * nodes…) as chips with ×. Nothing shows while nothing is attached. + */ + +import { translate as t } from './locales.js'; +import { icon } from './ui_ai_icons.js'; +import { thumbUrl } from './ui_ai_gallery_pick.js'; + +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 tool(className, name, label, run) { + const node = el('button', className); + node.type = 'button'; + node.title = label; + node.setAttribute('aria-label', label); + node.append(icon(name, 12)); + node.onclick = (event) => { + event.stopPropagation(); + run(); + }; + return node; +} + +function picture(ref, ctx) { + const tile = el('div', 'anomalous-ai-tray-picture'); + tile.title = ref.id; + const img = el('img'); + img.src = thumbUrl(ref.item.image || {}); + img.alt = ''; + img.onerror = () => tile.classList.add('is-gone'); + tile.append(img, tool('anomalous-ai-tray-x', 'close', t('aiTrayRemove'), () => ctx.remove(ref))); + if (ctx.mark) { + const mark = el('button', 'anomalous-ai-tray-mark'); + mark.type = 'button'; + mark.title = t('aiTrayMarkHint'); + mark.append(icon('pencil', 11), el('span', '', t('aiTrayMark'))); + mark.onclick = () => ctx.mark(ref); + tile.append(mark); + } + return tile; +} + +function chip(ref, ctx) { + const node = el('span', 'anomalous-ai-mention-chip'); + node.append(ctx.lead(ref.item), el('span', 'anomalous-ai-mention-chip-name', ref.label), + tool('anomalous-ai-mention-chip-x', 'close', t('aiMentionRemove'), () => ctx.remove(ref))); + return node; +} + +/** `refs` into `tray`. `ctx`: {remove(ref), mark(ref) (or none), lead(item) (a chip's small picture or icon)}. */ +export function drawTray(tray, refs, ctx) { + const pictures = refs.filter(ref => ref.kind === 'pictures'); + const others = refs.filter(ref => ref.kind !== 'pictures'); + tray.hidden = !refs.length; + tray.replaceChildren(...pictures.map(ref => picture(ref, ctx)), ...others.map(ref => chip(ref, ctx))); +} diff --git a/web/modules/ui_ai_workbench.js b/web/modules/ui_ai_workbench.js index 426c415..2ba969d 100644 --- a/web/modules/ui_ai_workbench.js +++ b/web/modules/ui_ai_workbench.js @@ -26,7 +26,7 @@ import { translate as t } from './locales.js'; 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, updateMessageBox } from './ui_ai_message.js'; +import { messageBox, newStepsPill, updateMessageBox } from './ui_ai_message.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'; @@ -219,11 +219,16 @@ function renderTask() { message: messageBox(async (text, refs) => { await postTask(task.id, { text, refs }, 'message'); await refresh(); + box.scrollTo({ top: box.scrollHeight, behavior: 'smooth' }); // what one sent, in view }, { task: () => state.task, commands: () => taskCommands({ task: () => state.task, undo: () => undoAll(state.task), newTask, - side: view => pickView(state.stage, view, false, state.task, renderTask, refresh), changed: refresh }) }), + 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'), }; view.tabs = el('div', 'anomalous-ai-tabs'); - box.replaceChildren(view.tabs, view.header, view.flow, view.made, view.timeline, view.message); + view.dock.append(view.pill, 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. const news = stageNews(task, state.stage); @@ -280,7 +285,7 @@ function renderTask() { : { key: JSON.stringify(['step', at, item.step, undoable(item.step)]), make: () => stepRow(item.step, at + 1) })); state.numbers = new Map(items.flatMap((item, at) => (item.type === 'looks' ? item.steps : [item.step]).map(step => [step.id, at + 1]))); // A task the user started waits for an AI: how to get one on it, and a page to put things - // ready for it meanwhile (their own steps are drawn there; the message box moves in too). + // ready for it meanwhile (their own steps are drawn there; its gallery feeds the message box). const preparing = Boolean(task.waiting) && items.every(item => item.type === 'step' && item.step.kind === 'user'); if (preparing) { const materials = toolboxCount(task); @@ -290,14 +295,14 @@ function renderTask() { await refresh(); }, message: view.message, materials, ready: items.map((item, at) => stepRow(item.step, at + 1)), - openMaterials: () => pickView(state.stage, 'toolbox', false, task, renderTask, () => void refresh()), }) }); } else if (!items.length) rows.push({ key: 'empty', make: () => el('div', 'anomalous-ai-empty-task', t('aiNoSteps')) }); syncRows(view.timeline, rows); - if (!preparing && view.message.parentNode !== box) box.append(view.message); // back under the timeline once an AI took it - updateMessageBox(view.message, Boolean(task.ended), preparing ? 'waiting' : status); + updateMessageBox(view.message, Boolean(task.ended), preparing ? 'waiting' : status, toolboxCount(task)); 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 + view.seen = (task.steps || []).length; drawStage(task, news.arrived); } diff --git a/web/styles.css b/web/styles.css index cd8ee7e..d62b031 100644 --- a/web/styles.css +++ b/web/styles.css @@ -35,14 +35,14 @@ @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-feel"); +@import url("./styles/37-ai-workbench.css?v=20261024-dock"); @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-feel"); -@import url("./styles/41-ai-results.css?v=20261024-gone"); +@import url("./styles/40-ai-side.css?v=20261024-dock"); +@import url("./styles/41-ai-results.css?v=20261024-dock"); @import url("./styles/42-ai-flow.css?v=20261024-feel"); @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-feel"); +@import url("./styles/46-ai-mention.css?v=20261024-dock"); @import url("./styles/47-ai-decisions.css?v=20261024-feel"); diff --git a/web/styles/37-ai-workbench.css b/web/styles/37-ai-workbench.css index 9f36bd0..96ff07c 100644 --- a/web/styles/37-ai-workbench.css +++ b/web/styles/37-ai-workbench.css @@ -207,12 +207,27 @@ /* ---------- one task ---------- */ .anomalous-ai-task { + display: flex; + flex-direction: column; min-width: 0; min-height: 0; padding: 24px 28px 28px; overflow-y: auto; } +.anomalous-ai-task > * { flex-shrink: 0; } + +/* The message box's dock: at the foot of the column however short the timeline, and in view while + it scrolls (the column's bottom padding is its own), the timeline fading out behind it. */ +.anomalous-ai-dock { + position: sticky; + bottom: -28px; + z-index: 3; + margin: auto 0 -28px; + padding: 16px 0 28px; + background: linear-gradient(to bottom, transparent, var(--amb-bg-page) 16px); +} + .anomalous-ai-task-head { position: sticky; top: -24px; /* stays on top while the timeline scrolls (the column's own top padding) */ @@ -430,14 +445,16 @@ .anomalous-ai-message { display: flex; flex-direction: column; - gap: 6px; - margin-top: 14px; - padding: 12px; - border: 1px solid var(--amb-border); - border-radius: 12px; - background: rgba(var(--amb-ink-rgb), 0.03); + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--amb-border-strong, var(--amb-border)); + border-radius: 14px; + background: color-mix(in srgb, var(--amb-bg-page), rgb(var(--amb-ink-rgb)) 4%); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); } +.anomalous-ai-message:focus-within { border-color: color-mix(in srgb, var(--amb-link) 55%, transparent); } + .anomalous-ai-message[hidden] { display: none; } .anomalous-ai-message.is-quiet { @@ -446,19 +463,25 @@ .anomalous-ai-message-line { display: flex; + align-items: flex-end; gap: 8px; } .anomalous-ai-message-input { flex: 1; min-width: 0; - padding: 8px 12px; + height: 38px; + max-height: 168px; + box-sizing: border-box; + padding: 9px 12px; border: 1px solid var(--amb-border); - border-radius: 8px; + border-radius: 9px; background: var(--amb-lt-sunken, rgba(0, 0, 0, 0.25)); color: var(--amb-text-main); font: inherit; font-size: 13px; + line-height: 1.45; + resize: none; } .anomalous-ai-message-input:focus { @@ -466,6 +489,8 @@ outline: none; } +.anomalous-ai-message-line .anomalous-ai-panel-send { align-self: flex-end; height: 38px; } + /* The step's number in a dot of its kind's colour; the line through the dots is the order. */ .anomalous-ai-step-dot { position: relative; diff --git a/web/styles/40-ai-side.css b/web/styles/40-ai-side.css index e2f8ca3..b6c4032 100644 --- a/web/styles/40-ai-side.css +++ b/web/styles/40-ai-side.css @@ -424,7 +424,7 @@ .anomalous-ai-waiting-ghost-row:nth-child(3) { opacity: 0.45; } /* A waiting task's two pages (ui_ai_ready.js): the switch on top, step 2's way to "Put ready", - and that page: the gallery grid, the note (the message box moved in), materials, what is ready. */ + and that page: the gallery grid feeding the message box below, and what is ready. */ .anomalous-ai-waiting-view { display: flex; flex-direction: column; max-width: 860px; margin: 8px auto 0; } .anomalous-ai-waiting-tabs { display: inline-flex; align-self: center; gap: 2px; margin: 0 0 18px; padding: 3px; border: 1px solid var(--amb-border); border-radius: 10px; } .anomalous-ai-waiting-tab { padding: 5px 16px; border: 0; border-radius: 7px; background: none; color: var(--amb-text-soft); font: inherit; font-size: 13px; cursor: pointer; } @@ -454,9 +454,6 @@ .anomalous-ai-ready-part { display: flex; flex-direction: column; gap: 8px; min-width: 0; } .anomalous-ai-ready-label { color: var(--amb-text-dim); font-size: 12px; font-weight: 650; } .anomalous-ai-ready .anomalous-ai-pics-grid { flex: none; height: min(380px, 48vh); grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } -.anomalous-ai-ready .anomalous-ai-message { margin-top: 0; } -.anomalous-ai-ready-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; } -.anomalous-ai-ready-note { flex: 1 1 240px; color: var(--amb-text-soft); font-size: 12.5px; } /* The card for an AI not connected yet (ui_ai_connect.js connectHint). */ .anomalous-ai-hint { diff --git a/web/styles/41-ai-results.css b/web/styles/41-ai-results.css index 3a82272..e7301c7 100644 --- a/web/styles/41-ai-results.css +++ b/web/styles/41-ai-results.css @@ -69,6 +69,11 @@ .anomalous-ai-action[hidden] { display: none; } .anomalous-ai-image-results > .anomalous-ai-action { margin-top: 10px; } +/* Pictures the user's message carries: a row of small cards, with the same tools. */ +.anomalous-ai-image-results.is-said { margin-top: 6px; } +.anomalous-ai-image-results.is-said .anomalous-ai-image-grid { grid-template-columns: repeat(auto-fill, 168px); gap: 8px; } +.anomalous-ai-image-results.is-said .anomalous-ai-image-preview img { height: 150px; object-fit: cover; } + /* The results strip: small pictures in a row that wraps. */ .anomalous-ai-image-results.is-compact .anomalous-ai-image-grid { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); diff --git a/web/styles/46-ai-mention.css b/web/styles/46-ai-mention.css index ccb92ed..c741af7 100644 --- a/web/styles/46-ai-mention.css +++ b/web/styles/46-ai-mention.css @@ -1,5 +1,5 @@ -/* "@" and "/" in the AI workbench's message box (ui_ai_mention.js): the list over the box, and the - chips of what the message names. */ +/* "@" and "/" in the AI workbench's message box (ui_ai_mention.js): the list over the box; what the + message carries over its input (ui_ai_tray.js); the gallery and materials buttons beside it. */ .anomalous-ai-message { position: relative; } .anomalous-ai-message.is-quiet:focus-within { opacity: 1; } /* typing in it: not faded, nor the list over it */ @@ -51,8 +51,51 @@ .anomalous-ai-mention-name { flex: 1; min-width: 0; overflow: hidden; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } .anomalous-ai-mention-kind { flex: none; max-width: 45%; overflow: hidden; color: var(--amb-text-dim); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; } -.anomalous-ai-mention-refs { display: flex; flex-wrap: wrap; gap: 6px; } -.anomalous-ai-mention-refs[hidden] { display: none; } +/* What the message carries: pictures with ✎ and ×, then chips. */ +.anomalous-ai-tray { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; } +.anomalous-ai-tray[hidden] { display: none; } +.anomalous-ai-tray-picture { + position: relative; + width: 76px; + height: 76px; + overflow: hidden; + border: 1px solid var(--amb-border); + border-radius: 10px; + background: rgba(var(--amb-ink-rgb), 0.05); +} +.anomalous-ai-tray-picture img { display: block; width: 100%; height: 100%; object-fit: cover; } +.anomalous-ai-tray-picture.is-gone img { visibility: hidden; } +.anomalous-ai-tray-x { + position: absolute; + top: 4px; + right: 4px; + display: inline-flex; + padding: 4px; + border: 0; + border-radius: 50%; + background: rgba(0, 0, 0, 0.62); + color: #fff; + cursor: pointer; +} +.anomalous-ai-tray-x:hover { background: var(--amb-danger, #d9534f); } +.anomalous-ai-tray-mark { + position: absolute; + right: 0; + bottom: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 24px; + border: 0; + background: rgba(0, 0, 0, 0.62); + color: #fff; + font: inherit; + font-size: 11.5px; + cursor: pointer; +} +.anomalous-ai-tray-mark:hover { background: var(--amb-link); } .anomalous-ai-mention-chip { display: inline-flex; align-items: center; @@ -69,24 +112,26 @@ .anomalous-ai-mention-chip-x { display: inline-flex; padding: 3px; border: 0; border-radius: 5px; background: none; color: var(--amb-text-soft); cursor: pointer; } .anomalous-ai-mention-chip-x:hover { background: rgba(var(--amb-ink-rgb), 0.1); color: var(--amb-text-main); } -/* The picture button and its panel of gallery pictures (ui_ai_gallery_pick.js), over the box like the list. */ -.anomalous-ai-message-pics { +/* The gallery and materials buttons beside the input, and the gallery's panel over the box like the list. */ +.anomalous-ai-message-tool { + position: relative; display: inline-flex; flex: none; align-items: center; justify-content: center; - width: 36px; + align-self: flex-end; + width: 38px; + height: 38px; border: 1px solid var(--amb-border); - border-radius: 8px; + border-radius: 9px; background: none; color: var(--amb-text-soft); cursor: pointer; } -.anomalous-ai-message-pics { position: relative; } -.anomalous-ai-message-pics:hover, .anomalous-ai-message-pics:focus-visible { border-color: var(--amb-link); background: color-mix(in srgb, var(--amb-link) 14%, transparent); color: var(--amb-text-main); outline: none; } -/* Its panel open, or pictures named in the message: lit, with their number. */ -.anomalous-ai-message-pics.is-on, .anomalous-ai-message-pics.has-pictures { border-color: var(--amb-link); background: color-mix(in srgb, var(--amb-link) 24%, transparent); color: var(--amb-text-main); } -.anomalous-ai-message-pics-count { +.anomalous-ai-message-tool:hover, .anomalous-ai-message-tool:focus-visible { border-color: var(--amb-link); background: color-mix(in srgb, var(--amb-link) 14%, transparent); color: var(--amb-text-main); outline: none; } +/* Its panel open, or what it gave in the message: lit, with their number. */ +.anomalous-ai-message-tool.is-on, .anomalous-ai-message-tool.has-items { border-color: var(--amb-link); background: color-mix(in srgb, var(--amb-link) 24%, transparent); color: var(--amb-text-main); } +.anomalous-ai-message-tool-count { position: absolute; top: -7px; right: -7px; @@ -102,7 +147,7 @@ line-height: 18px; text-align: center; } -.anomalous-ai-message-pics-count:empty { display: none; } +.anomalous-ai-message-tool-count:empty { display: none; } .anomalous-ai-pics { position: absolute; z-index: 6; @@ -177,6 +222,25 @@ .anomalous-ai-pics-foot { display: flex; flex: none; align-items: center; gap: 10px; } .anomalous-ai-pics-count { flex: 1; min-width: 0; color: var(--amb-text-dim); font-size: 12px; } -/* A message's named pictures on the timeline (ui_ai_step_body.js). */ -.anomalous-ai-user-pictures { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } -.anomalous-ai-user-pictures img { width: 64px; height: 64px; border-radius: 8px; object-fit: cover; background: rgba(var(--amb-ink-rgb), 0.05); } +/* "New steps below" over the message box (ui_ai_message.js newStepsPill). */ +.anomalous-ai-new-steps { + position: absolute; + z-index: 4; + bottom: calc(100% + 2px); + left: 50%; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border: 1px solid color-mix(in srgb, var(--amb-link) 60%, transparent); + border-radius: 999px; + background: var(--amb-bg-panel); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.4); + color: var(--amb-text-main); + font: inherit; + font-size: 12.5px; + cursor: pointer; + transform: translateX(-50%); +} +.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)); }