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; } }