From 57e70dee48fed4475f652d4da591b28888583178 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:59:41 +0800 Subject: [PATCH] Run progress for the AI and the workbench: run_status, and a live bar under the run - ai_task_runs.py reads where a run stands from ComfyUI's queue and progress registry (never changing them): queued and how many are ahead, or the node running, its steps and the nodes done. It is kept on the step in memory and sent as "progress" events that only move the step's bar; the run's step is saved only when something new is kept. - run_status (read-only): the task's latest run and its progress, its pictures once done; wait_seconds (<= 55) waits for the end. run_workflow's answer tells the AI to use it instead of guessing from the time. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 6 ++- CHANGELOG.md | 1 + api/action_catalog.py | 1 + api/ai_task_runs.py | 99 +++++++++++++++++++++++++++++++++- api/mcp_actions.py | 6 ++- api/mcp_task_tools.py | 17 +++++- docs/guides/mcp.md | 2 + tests/test_ai_task_runs.py | 91 +++++++++++++++++++++++++++++++ web/modules/locales.js | 8 +++ web/modules/ui_ai_results.js | 39 +++++++++++++- web/modules/ui_ai_workbench.js | 7 ++- web/styles.css | 2 +- web/styles/42-ai-flow.css | 9 ++++ 13 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 tests/test_ai_task_runs.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dbad973..482ed40 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -150,7 +150,11 @@ covered by the `styles.css` manifest. named by the AI (`begin_task`) or is one app's calls without a long pause (20 minutes); a step keeps the tool, a short summary, whether it worked and what it made (models, the combo, the canvas change by its activity entry, the spoken audio, the scan; a run's pictures are - watched in ComfyUI's history until it ends, `api/ai_task_runs.py`). A step a restart left + watched in ComfyUI's history until it ends, `api/ai_task_runs.py`, which also reads where it + stands from ComfyUI's queue and progress registry, never changing them: queued and how many + are ahead, or the node running, its steps and the nodes done; kept on the step in memory, + sent to the page as `progress` events that only move the step's bar, and given to the AI + by `run_status`, which can wait for the run to end). A step a restart left `"running"` is marked stopped when the record is read again. What each tool is, in one place, is `api/action_catalog.py`: its kind (colours, words, counts), the stage of the work it belongs to (model, combo, prompt, canvas, run, picture, voice, library: the flow over a diff --git a/CHANGELOG.md b/CHANGELOG.md index 041de93..e805823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - AI 干活时你在 AMB 里做的改动(画布改动、改模型信息、存搭配和提示词等)会作为“你在 AMB 里改了”出现在同一条时间线上,画布改动附带修改前后;AI 下一次调用任何工具时会收到这条改动,并被告知保留它,不要改回去;时间线上写着 AI 是否已经知道。操作记录页里 AI 做的、以及 AI 干活期间你做的记录,旁边有“AI 任务 →”可以跳到对应任务。 - ComfyUI 在 AI 某一步进行到一半时重启,这一步不再永远显示“正在执行…”,而是标成没有结束。 - **AI 软件里直接显示 AMB 卡片**(MCP Apps,Claude、ChatGPT、VS Code 等支持的软件):AI 开始任务、出图、画面板、留言时,对话里会出现一张 AMB 卡片,显示它在做哪几步、最新的几张图,和它问你的问题。在卡片里点选项就算回答,AI 马上收到并接着做。为了安全,“关卡”只能在 AMB 里点允许,卡片里只提示你去 AMB。Gemini 命令行和 Claude Code 终端版不显示卡片,照常用 AMB 的工作台。 +- **运行进度 AI 也看得见**:AI 让工作流开始运行后,时间线上那一步下面有进度条:排队时写“前面还有几个”,运行时写正在跑哪个节点、第几步(比如 采样器 14/28)和节点做完了几个。AI 多了一个 `run_status` 工具,可以随时问进度,也可以等到跑完(每次最多等 55 秒),跑完直接拿到出的图;运行的回复里也会提醒它用这个,别凭时间猜有没有跑完。 - **提示词改动一眼看清**:AI(或你自己)改了提示词时,记录里先列出“去掉 N 个”(红色划掉)和“加上 N 个”(绿色)的词,点“看词义”用 AMB 的翻译在每个词下面显示中文意思;“看完整的修改前 / 后”里,两段全文中变化的词也标了颜色,没变的变暗。其他文字改动会把不同的那一段标出来。操作记录里提示词改动最多保留 2000 字(以前 400 字),整段提示词都能对比,不会把被截断的半个词算成改动。 - **等 AI 接手的任务**重新排版:中间一个输入框写想让 AI 做什么(就是任务名),下面是要发给 AI 的那句话和“复制这句话”,再加三个小步骤;第一次连接 AI 的说明收进“AI 还没连上 AMB?”里,不再整屏铺开。等 AI 的时候不显示留言框。 - **交给 AI**:模型页、图片详情(右上角 ✦)和搭配卡片上多了“交给 AI”。写一句想让 AI 做什么(已经帮你填好,比如“用这个模型出一张图”),AMB 会建好一个带着这个模型、图片或搭配的任务,再给你一句话贴给 AI;AI 接手时会直接拿到它们,不用再自己去找。 diff --git a/api/action_catalog.py b/api/action_catalog.py index 9ce7ad3..2e51062 100644 --- a/api/action_catalog.py +++ b/api/action_catalog.py @@ -53,6 +53,7 @@ TOOLS = { "begin_task": Entry(None, None, None), "update_panel": Entry(None, None, None), "get_answers": Entry(None, None, None), + "run_status": Entry("look", "run", None), # Anomalous's card in the conversation (mcp_apps.py): its own calls, not the AI's steps "app_task_view": Entry(None, None, None), "app_answer": Entry(None, None, None), diff --git a/api/ai_task_runs.py b/api/ai_task_runs.py index 46a69a9..71d9942 100644 --- a/api/ai_task_runs.py +++ b/api/ai_task_runs.py @@ -2,7 +2,10 @@ ComfyUI queued for it are watched in its history until they end, and the pictures and videos they saved become the step's results (ai_tasks.py keeps the step). A thread per run, polling every RUN_POLL_SECONDS, for at most ai_tasks.RUN_WATCH_SECONDS; nothing runs when no AI ran -anything.""" +anything. While it runs, where it stands (queued and how many are ahead, the node running and +its steps, nodes done; read from ComfyUI's queue and its progress registry, never changed) is +the step's "progress", sent to the page with each change, and status() answers the AI's +run_status with it, waiting for the end when asked.""" import threading import time @@ -10,6 +13,7 @@ import time from . import ai_tasks RUN_POLL_SECONDS = 2.0 +MAX_STATUS_WAIT = 55 def _queue(): @@ -41,6 +45,59 @@ def outputs_of(record_): return out +def _registry(): + """ComfyUI's per-node progress of the prompt running now, or None (an older ComfyUI has none).""" + try: + from comfy_execution import progress + except ImportError: + return None + return progress.global_progress_registry + + +def progress_of(prompts): + """Where the run of `prompts` stands: queued (how many are ahead), running (the node and its + steps, nodes done of all), or None when ComfyUI cannot say.""" + try: + running, pending = _queue().get_current_queue() + except Exception: # noqa: BLE001 - no server (tests) + return None + running_ids = [item[1] for item in running] + order = [item[1] for item in sorted(pending, key=lambda item: item[0])] + current = next((pid for pid in prompts if pid in running_ids), None) + if not current: + waiting = [pid for pid in prompts if pid in order] + if not waiting: + return None + return {"state": "queued", "ahead": order.index(waiting[0]) + len(running_ids)} + out = {"state": "running", "prompt": prompts.index(current) + 1, "prompts": len(prompts)} + registry = _registry() + if registry is None or registry.prompt_id != current: + return out + nodes = dict(registry.nodes) + out["nodes_done"] = sum(1 for node in nodes.values() if node["state"].value == "finished") + out["nodes"] = len(registry.dynprompt.original_prompt) + active = next(((node_id, node) for node_id, node in nodes.items() if node["state"].value == "running"), None) + if active: + try: + info = registry.dynprompt.get_node(active[0]) or {} + except Exception: # noqa: BLE001 - a node of the run that is gone already + info = {} + out["node"] = str((info.get("_meta") or {}).get("title") or info.get("class_type") or active[0])[:80] + out["value"], out["max"] = int(active[1]["value"]), int(active[1]["max"]) + return out + + +def _show_progress(task_id, step_id, progress): + """The run's progress on its step (kept in memory; the page hears of each change).""" + with ai_tasks._lock: + task = ai_tasks._load().get(task_id) + step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None) + if not step or step.get("progress") == progress or step.get("state") != "running": + return + step["progress"] = progress + ai_tasks._notify(task_id, {"progress": {"step": step_id, **progress}}) + + def _finish_run(task_id, step_id, prompts, history): images, failed, done = [], False, 0 for prompt_id in prompts: @@ -61,13 +118,17 @@ def _finish_run(task_id, step_id, prompts, history): step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None) if not step: return True - step["results"] = [item for item in step["results"] if item["type"] != "image"] + images[:ai_tasks.MAX_RESULTS] + results = [item for item in step["results"] if item["type"] != "image"] + images[:ai_tasks.MAX_RESULTS] + if done < len(prompts) and results == step["results"] and step.get("prompts") == len(prompts): + return False # nothing new to keep: no write + step["results"] = results step["state"] = "running" if done < len(prompts) else ("error" if failed else "done") step["prompts"] = len(prompts) if failed: step["ok"] = False step["error"] = "ComfyUI 运行出错 / The run failed" if done >= len(prompts): + step.pop("progress", None) step["ms"] = int((time.time() - step["at"]) * 1000) task["updated"] = max(task["updated"], time.time()) ai_tasks._save(task) @@ -99,6 +160,40 @@ def watch(step_id, before): return if _finish_run(task_id, step_id, prompts, {k: v for k, v in history.items() if v}): return + progress = progress_of([pid for pid in prompts if not history.get(pid)]) + if progress: + _show_progress(task_id, step_id, progress) time.sleep(RUN_POLL_SECONDS) threading.Thread(target=follow, name="anomalous-ai-run", daemon=True).start() + + +def status(client, session="", task_id=None, wait_seconds=0): + """The AI's run_status: its task's latest run, where it stands, and what it made; with + `wait_seconds`, waits (up to MAX_STATUS_WAIT) until the run ends.""" + task_id = ai_tasks.active_task_id(client, session, task_id) + deadline = time.monotonic() + max(0.0, min(float(wait_seconds or 0), MAX_STATUS_WAIT)) + while True: + task = ai_tasks.get_task(task_id) if task_id else None + run = next((step for step in reversed((task or {}).get("steps", [])) if step.get("tool") == "run_workflow"), None) + if not run: + raise ValueError("No run in this task yet: run_workflow starts one.") + if run.get("state") not in ("running", "asking", "allowed") or time.monotonic() >= deadline: + break + time.sleep(1) + answer = {"state": run.get("state"), "seconds": round(run.get("ms", 0) / 1000) if run.get("state") != "running" + else round(time.time() - run["at"])} + if run.get("progress"): + answer["progress"] = run["progress"] + pictures = ["/".join(part for part in (item.get("subfolder"), item["filename"]) if part) + for item in run.get("results") or [] if item.get("type") == "image"] + if pictures: + answer["pictures"] = pictures + if run.get("error"): + answer["error"] = run["error"] + answer["note"] = { + "running": "Still running: call run_status again (wait_seconds up to 55) instead of guessing it is done.", + "done": "Finished: the pictures are in the output folder (search_images / get_image_info show them).", + "asking": "Waiting for the user's OK in Anomalous before it runs.", + }.get(run.get("state"), "The run did not finish; see error.") + return answer diff --git a/api/mcp_actions.py b/api/mcp_actions.py index 3c21e18..9cb972f 100644 --- a/api/mcp_actions.py +++ b/api/mcp_actions.py @@ -123,7 +123,11 @@ def download_status(ctx): def run_workflow(ctx, batch=1): batch = max(1, min(8, int(batch))) - return _page("run_workflow", {"batch": batch}) + answer = _page("run_workflow", {"batch": batch}) + if isinstance(answer, dict): + answer["note"] = ("Queued. A run takes a while: follow it with run_status (wait_seconds up to 55) until it " + "says done; do not guess from the time passed. Its pictures come with run_status.") + return answer def open_in_anomalous(ctx, model_id): diff --git a/api/mcp_task_tools.py b/api/mcp_task_tools.py index a50bef3..dccd178 100644 --- a/api/mcp_task_tools.py +++ b/api/mcp_task_tools.py @@ -3,7 +3,7 @@ work, report leaves a note for the user (with pictures made outside Anomalous's show_panel / update_panel draw a panel from Anomalous's blocks (ai_panels.py), get_answers reads what the user picked or typed on it.""" -from . import ai_panels, ai_tasks +from . import ai_panels, ai_task_runs, ai_tasks from .mcp_tools import MAX_LIMIT, ToolError, _schema @@ -70,6 +70,13 @@ def update_panel(ctx, panel, title=None, blocks=None, closed=None, task=None): for block in updated["blocks"]]} +def run_status(ctx, wait_seconds=0, task=None): + try: + return ai_task_runs.status(ctx.get("client") or "AI", ctx.get("session") or "", task or ctx.get("task"), wait_seconds) + except ValueError as error: + raise ToolError(str(error)) + + def get_answers(ctx, panel=None, wait_seconds=0, task=None): try: return ai_panels.get_answers(ctx.get("client") or "AI", panel, wait_seconds, task_id=task or ctx.get("task")) @@ -102,7 +109,7 @@ def _task_tool(name, title, description, run, properties, required=()): return name, {"run": run, "context": True, "spec": { "name": name, "title": title, "description": description, "inputSchema": _schema(properties, required), - "annotations": {"title": title, "readOnlyHint": False, "destructiveHint": False, + "annotations": {"title": title, "readOnlyHint": name == "run_status", "destructiveHint": False, "idempotentHint": False, "openWorldHint": False}, }} @@ -142,6 +149,12 @@ TASK_TOOLS = dict([ update_panel, {"panel": {"type": "string"}, "title": {"type": "string"}, "blocks": {"type": "array", "items": {"type": "object"}, "maxItems": ai_panels.MAX_BLOCKS}, "closed": {"type": "boolean"}, "task": _TASK_PROP}, ["panel"]), + _task_tool("run_status", "How the run is going", + "Where your task's latest workflow run stands: queued (how many runs are ahead), running (which node, " + "its steps, nodes done of all), done (with the pictures it saved) or failed. wait_seconds (up to 55) " + "waits for it to end. Runs take a while: call this instead of guessing whether a run has finished.", + run_status, {"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_task_runs.MAX_STATUS_WAIT, "default": 0}, + "task": _TASK_PROP}), _task_tool("get_answers", "Read the user's answers and messages", "What the user picked or typed on your panels, and what they wrote to you in the AI workbench " "(messages: a change of mind, 'stop', 'make it brighter'; kind=change: something they changed " diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 8dc1f5d..bad5b1e 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -120,6 +120,8 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp **斜杠命令**:支持 MCP prompts 的软件(比如 Claude)里可以直接选 AMB 提供的四个现成请求:在工作台开一个任务、出几张让我挑、补齐缺的模型、讲讲现在的画布。 +**运行进度**:AI 运行工作流后,时间线上那一步下面有进度条(排队时前面还有几个;运行时哪个节点、第几步、节点做完几个)。AI 用 `run_status` 问同样的进度,或者等到跑完再继续,不会凭时间猜。 + **流程图和关卡**:任务标题下面的一排流程按 AI 做事的先后列出它经过的阶段(模型、搭配、提示词、运行、挑图……),点一下跳到那一步。任务列表上方的 **关卡** 里,改画布、用显卡出图配音、下载模型、联网扫描四类事各自可以选 **直接做 / 先问我 / 不许做**(默认只有下载先问)。选了“先问我”,AI 的那一步会停在时间线上列出它要做什么,你点 **允许** 才做;点 **不要** 时可以写原因或想怎么改,AI 会收到。AI 最多等 50 秒,等不及的话会先去问你的回答(`get_answers`),你允许后它再做一次,不会问第二遍。 **AI 干活时你自己改的东西**:AI 在做一个任务时(10 分钟内调用过),你在 AMB 里做的改动,比如画布上的改动、改模型信息、存搭配或提示词,会以“你在 AMB 里改了”出现在这个任务的时间线上,画布改动带修改前后。AI 下一次调用任何工具时会收到它(并被告知保留你的改动,不要改回去),时间线上写着它是否已经知道。操作记录页里,AI 做的和 AI 干活时你做的记录旁边有 **AI 任务 →**。 diff --git a/tests/test_ai_task_runs.py b/tests/test_ai_task_runs.py new file mode 100644 index 0000000..80836fc --- /dev/null +++ b/tests/test_ai_task_runs.py @@ -0,0 +1,91 @@ +"""A run an AI started (api/ai_task_runs.py): where it stands, read from a stand-in ComfyUI queue +and progress registry, on its step and for the AI's run_status. Temporary folders only.""" +import sys +import threading +import time +import unittest +from enum import Enum +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1]), str(PLUGIN / "tests")] + +from api import ai_task_runs, ai_tasks # noqa: E402 +from test_ai_tasks import isolate, ok # noqa: E402 + + +class State(Enum): + Pending = "pending" + Running = "running" + Finished = "finished" + + +class Queue: + def __init__(self, running=(), pending=()): + self.running, self.pending = list(running), list(pending) + + def get_current_queue(self): + return self.running, self.pending + + +def registry(prompt_id, nodes): + prompt = {"3": {"class_type": "KSampler", "_meta": {"title": "采样器"}}, "4": {"class_type": "CheckpointLoaderSimple"}, + "8": {"class_type": "VAEDecode"}, "9": {"class_type": "SaveImage"}} + return SimpleNamespace(prompt_id=prompt_id, nodes=nodes, + dynprompt=SimpleNamespace(original_prompt=prompt, get_node=lambda node_id: prompt[node_id])) + + +class ProgressTests(unittest.TestCase): + def setUp(self): + isolate() + + def test_queued_then_running_with_the_node_and_its_steps(self): + queue = Queue(running=[(5, "other")], pending=[(7, "mine"), (6, "first")]) + with mock.patch.object(ai_task_runs, "_queue", return_value=queue): + self.assertEqual(ai_task_runs.progress_of(["mine"]), {"state": "queued", "ahead": 2}) + queue.running, queue.pending = [(7, "mine")], [] + nodes = {"4": {"state": State.Finished, "value": 1, "max": 1}, "3": {"state": State.Running, "value": 14, "max": 28}} + with mock.patch.object(ai_task_runs, "_registry", return_value=registry("mine", nodes)): + got = ai_task_runs.progress_of(["mine"]) + self.assertEqual(got, {"state": "running", "prompt": 1, "prompts": 1, "nodes_done": 1, "nodes": 4, + "node": "采样器", "value": 14, "max": 28}) + with mock.patch.object(ai_task_runs, "_queue", side_effect=RuntimeError("no server")): + self.assertIsNone(ai_task_runs.progress_of(["mine"])) + + def test_progress_reaches_the_step_and_run_status_waits_for_the_end(self): + ai_tasks.record("Claude", "run_workflow", {"batch": 1}, ok({"queued": 1}), time.time()) + task_id = ai_tasks.list_tasks()[0]["id"] + step_id = ai_tasks.get_task(task_id)["steps"][0]["id"] + with mock.patch.object(ai_tasks, "_notify") as told: + ai_task_runs._show_progress(task_id, step_id, {"state": "running", "value": 3, "max": 20}) + ai_task_runs._show_progress(task_id, step_id, {"state": "running", "value": 3, "max": 20}) + self.assertEqual(told.call_count, 1, "the page hears only of changes") + self.assertEqual(told.call_args[0][1]["progress"]["value"], 3) + status = ai_task_runs.status("Claude") + self.assertEqual((status["state"], status["progress"]["max"]), ("running", 20)) + self.assertIn("instead of guessing", status["note"]) + history = {"p1": {"status": {"completed": True}, "outputs": {"9": {"images": [{"filename": "a.png", "subfolder": "x", "type": "output"}]}}}} + threading.Timer(0.3, ai_task_runs._finish_run, (task_id, step_id, ["p1"], history)).start() + status = ai_task_runs.status("Claude", wait_seconds=5) + self.assertEqual((status["state"], status["pictures"]), ("done", ["x/a.png"])) + self.assertNotIn("progress", ai_tasks.get_task(task_id)["steps"][0], "a finished run has no progress left") + + def test_a_run_saves_only_when_something_changes(self): + ai_tasks.record("A", "run_workflow", {}, ok({}), time.time()) + task_id = ai_tasks.list_tasks()[0]["id"] + step_id = ai_tasks.get_task(task_id)["steps"][0]["id"] + with mock.patch.object(ai_tasks, "_save") as saved: + ai_task_runs._finish_run(task_id, step_id, ["p1"], {}) + ai_task_runs._finish_run(task_id, step_id, ["p1"], {}) + self.assertEqual(saved.call_count, 1) + + def test_no_run_yet(self): + ai_tasks.record("A", "describe_canvas", {}, ok({}), time.time(), True) + with self.assertRaises(ValueError): + ai_task_runs.status("A") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/modules/locales.js b/web/modules/locales.js index d57ea47..5ed4a53 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1258,6 +1258,10 @@ export const i18n = { aiKeepImage: "保存为…", aiVideoResult: "视频 · 点击播放", aiMoreResults: "显示更多结果", + aiRunQueued: "排队中:前面还有 {ahead} 个", + aiRunPrompt: "第 {at}/{count} 次", + aiRunNodes: "节点 {done}/{count}", + aiTool_run_status: "看了运行进度", aiTagsOut: "去掉 {count} 个", aiTagsIn: "加上 {count} 个", aiTagsMeaning: "看词义", @@ -4180,6 +4184,10 @@ export const i18n = { aiKeepImage: "Keep as…", aiVideoResult: "Video · click to play", aiMoreResults: "Show more results", + aiRunQueued: "Queued: {ahead} ahead", + aiRunPrompt: "run {at} of {count}", + aiRunNodes: "nodes {done}/{count}", + aiTool_run_status: "Checked the run", aiTagsOut: "{count} taken out", aiTagsIn: "{count} put in", aiTagsMeaning: "Show meanings", diff --git a/web/modules/ui_ai_results.js b/web/modules/ui_ai_results.js index 08bae47..c67ba46 100644 --- a/web/modules/ui_ai_results.js +++ b/web/modules/ui_ai_results.js @@ -2,7 +2,8 @@ * What an AI task made, and where it leads (ui_ai_workbench.js draws the task): the pictures a * step made (the viewer), its models (their page), the combo, the spoken audio (▶), the canvas * change (found on the canvas, undone while it can be), the scan; and the strip of everything - * a task made above its timeline. `shared` is set by the workbench: the browser (`owner`) and + * a task made above its timeline, and a run's progress while it goes (runProgress, kept live by + * updateRunProgress from the server's events). `shared` is set by the workbench: the browser (`owner`) and * the activity entries of the task's canvas steps. */ @@ -82,9 +83,45 @@ export function locateNode(id, note) { app.canvas.setDirty?.(true, true); } +/** Where a run stands (api/ai_task_runs.py progress_of): queued, or the node running and its steps. */ +export function runProgress(progress) { + const box = el('div', 'anomalous-ai-run-progress'); + let words; + let share = null; + 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'); + share = progress.max > 1 ? progress.value / progress.max : progress.nodes ? progress.nodes_done / progress.nodes : null; + } + 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)}%`; + bar.append(fill); + box.append(el('div', 'anomalous-ai-run-words', words), bar); + return box; +} + +/** A progress event for step `progress.step` drawn into `root` in place; false when it is not shown there. */ +export function updateRunProgress(root, progress) { + const row = [...(root?.querySelectorAll('[data-step-ids]') || [])].find(item => item.dataset.stepIds.split(' ').includes(progress.step)); + const body = row?.querySelector('.anomalous-ai-step-body'); + if (!body) return false; + const fresh = runProgress(progress); + const there = body.querySelector('.anomalous-ai-run-progress'); + if (there) there.replaceWith(fresh); + else body.append(fresh); + return true; +} + /** The links under a step: what it made and where that leads. */ export function resultLinks(step, changed = () => {}) { const box = el('div', 'anomalous-ai-results'); + if (step.tool === 'run_workflow' && step.state === 'running' && step.progress) box.append(runProgress(step.progress)); const pictures = (step.results || []).filter(result => result.type === 'image'); if (pictures.length) box.append(imageResults(pictures, shared.owner)); const links = el('div', 'anomalous-ai-links'); diff --git a/web/modules/ui_ai_workbench.js b/web/modules/ui_ai_workbench.js index 38a24ff..31daf9f 100644 --- a/web/modules/ui_ai_workbench.js +++ b/web/modules/ui_ai_workbench.js @@ -29,7 +29,7 @@ import { connectGuide, openConnectGuide, waitingCard } from './ui_ai_connect.js' import { gateCard, openGates } from './ui_ai_gate.js'; import { flowMap } from './ui_ai_flow.js'; import { drawRecentAiTasks } from './ui_ai_recent.js'; -import { locateNode, openModel, resultLinks, shared, strip } from './ui_ai_results.js'; +import { locateNode, openModel, resultLinks, shared, strip, updateRunProgress } from './ui_ai_results.js'; import { compareBar } from './ui_ai_images.js'; import { canvasChanges } from './ui_canvas_changes.js'; import { @@ -547,6 +547,11 @@ export function watchAiTasks(owner) { api.addEventListener('anomalous.ai_task', (event) => { const detail = event?.detail || {}; const onPage = () => owner.modal?.classList.contains('visible') && owner.currentShellPage?.() === 'ai' && visible(); + // A run's progress only moves its bar (every couple of seconds while it runs): no refetch. + if (detail.progress) { + if (onPage()) updateRunProgress(state.page, detail.progress); + return; + } if (detail.panel && !onPage()) panelToast(owner, String(detail.panel), Boolean(detail.asks)); else if (detail.gate && !onPage()) panelToast(owner, t(`aiGateQ_${detail.gate}`), true); clearTimeout(state.refreshTimer); diff --git a/web/styles.css b/web/styles.css index 8afe37d..275a207 100644 --- a/web/styles.css +++ b/web/styles.css @@ -40,4 +40,4 @@ @import url("./styles/39-ai-stage.css?v=20261021-stage"); @import url("./styles/40-ai-side.css?v=20261023-wait"); @import url("./styles/41-ai-results.css?v=20261023-tags"); -@import url("./styles/42-ai-flow.css?v=20261022-hand"); +@import url("./styles/42-ai-flow.css?v=20261023-run"); diff --git a/web/styles/42-ai-flow.css b/web/styles/42-ai-flow.css index 0dfb726..44ffb66 100644 --- a/web/styles/42-ai-flow.css +++ b/web/styles/42-ai-flow.css @@ -148,3 +148,12 @@ .anomalous-ai-handoff { display: flex; flex-direction: column; gap: 12px; max-width: 560px; } .anomalous-ai-handoff-text { margin: 0; color: var(--amb-text-soft); font-size: 12.5px; line-height: 1.6; } .anomalous-ai-handoff-actions { display: flex; flex-wrap: wrap; gap: 14px; } + +/* A run's progress under its step (ui_ai_results.js runProgress). */ +.anomalous-ai-run-progress { display: flex; flex-direction: column; gap: 5px; max-width: 520px; margin: 6px 0 4px; } +.anomalous-ai-run-words { color: var(--amb-text-soft); font-size: 12px; font-variant-numeric: tabular-nums; } +.anomalous-ai-run-bar { position: relative; height: 6px; overflow: hidden; border-radius: 999px; background: rgba(var(--amb-ink-rgb), 0.08); } +.anomalous-ai-run-fill { position: absolute; inset: 0 auto 0 0; width: 0; border-radius: inherit; background: var(--ai-run, var(--amb-ok)); transition: width 0.6s ease; } +.anomalous-ai-run-bar.is-waiting .anomalous-ai-run-fill { width: 30%; animation: anomalous-ai-run-wait 1.6s ease-in-out infinite; } +@keyframes anomalous-ai-run-wait { 0% { left: -30%; } 100% { left: 100%; } } +@media (prefers-reduced-motion: reduce) { .anomalous-ai-run-bar.is-waiting .anomalous-ai-run-fill { animation: none; } }