From cd9751ef1491d1fd3d9eedd302fc28da55ea13c0 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:48:43 +0800 Subject: [PATCH] AI workbench: the flow over a task, and gates that wait for your OK - api/ai_gates.py: changing the canvas, using the GPU, downloading and scanning online can each go ahead, ask first or never (downloads ask by default; anomalous/ai_gates.json). A call that asks waits on its step (state "asking", what it would do listed) up to 50 s for Allow or No (with the user's reason for the AI); unanswered, the AI is told to wait and call again, the later answer reaches it as a message, and the same call after an Allow goes ahead. - ui_ai_gate.js: the card under a waiting step and the Gates window; a toast while elsewhere. - ui_ai_flow.js + ai_task_view.flowStages: the task's stages in order, each done, running, waiting or failed; a stage leads to its first step. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 10 ++- CHANGELOG.md | 4 +- api/ai_gates.py | 152 +++++++++++++++++++++++++++++++++ api/ai_panels.py | 9 ++ api/ai_tasks.py | 4 +- api/ai_tasks_routes.py | 42 ++++++++- api/mcp_server.py | 12 ++- docs/guides/mcp.md | 2 + tests/ai_task_view.mjs | 17 +++- tests/test_ai_gates.py | 108 +++++++++++++++++++++++ web/modules/ai_task_view.js | 20 +++++ web/modules/locales.js | 80 +++++++++++++++++ web/modules/ui_ai_connect.js | 14 ++- web/modules/ui_ai_flow.js | 34 ++++++++ web/modules/ui_ai_gate.js | 113 ++++++++++++++++++++++++ web/modules/ui_ai_workbench.js | 30 +++++-- web/styles.css | 1 + web/styles/42-ai-flow.css | 145 +++++++++++++++++++++++++++++++ 18 files changed, 777 insertions(+), 20 deletions(-) create mode 100644 api/ai_gates.py create mode 100644 tests/test_ai_gates.py create mode 100644 web/modules/ui_ai_flow.js create mode 100644 web/modules/ui_ai_gate.js create mode 100644 web/styles/42-ai-flow.css diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b3a439..580e5a1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -149,7 +149,15 @@ covered by the `styles.css` manifest. (activity entries, `activity_log.add_listener`) made while an AI works into that AI's task as `user_activity` steps, which the AI receives once with its next call (a message of kind `change`); `activity_log.tag` names the task on the entry, and the activity page links to - it. It tells the page through the + it. `api/ai_gates.py` holds the user's gates: each gate is `auto`, `ask` or `never` + (`anomalous/ai_gates.json`, `/anomalous/ai_gates`); `mcp_server.py` asks it before running a + tool. With `ask` the step waits in state `asking` (what it would do listed) for up to 50 s; + the user's Allow or No comes through `POST /anomalous/ai_tasks/{id}/gate`. Unanswered by + then, the AI is told to wait and call again; the later answer reaches it as a message of kind + `gate`, and the same call made again after an Allow goes ahead without asking twice. + `web/modules/ui_ai_gate.js` draws the card under a step that asks and the Gates window; + `ui_ai_flow.js` the flow over a task (its stages in order, each done, running, waiting or + failed; a stage leads to its first step). It tells the page through the `anomalous.ai_task` event; `api/ai_tasks_routes.py` serves `/anomalous/ai_tasks` (list, one task, start, rename or remove, a panel's answer, a message). The user can start a task first (New task): it waits, and the next app that calls takes it over, so its work starts there diff --git a/CHANGELOG.md b/CHANGELOG.md index 523c464..fc98097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,11 @@ - 工作台的出图结果和 AI 展台图片:鼠标移到图片上,右上角出现 ⓘ 图片与参数、⇄ 和当前画布对比、☆ 保存为(同一个保存菜单存成工作流、搭配和提示词),不再在每张图下面排三个大按钮。左上角可以勾选:勾两张(不同步骤、不同任务都行),页面底部出现“查看参数差异”,用插件现有的参数对比看真实差异;勾第三张会替换最早那张。“本次成果”直接显示缩略图,不用再点开。视频和临时文件没有这些参数操作,缩略图按需加载。 - AI 干活时你在 AMB 里做的改动(画布改动、改模型信息、存搭配和提示词等)会作为“你在 AMB 里改了”出现在同一条时间线上,画布改动附带修改前后;AI 下一次调用任何工具时会收到这条改动,并被告知保留它,不要改回去;时间线上写着 AI 是否已经知道。操作记录页里 AI 做的、以及 AI 干活期间你做的记录,旁边有“AI 任务 →”可以跳到对应任务。 - ComfyUI 在 AI 某一步进行到一半时重启,这一步不再永远显示“正在执行…”,而是标成没有结束。 +- **流程图**:任务标题下面一排“模型 › 搭配 › 提示词 › 运行 › 挑图”,按 AI 做事的先后排列,每段标着做完了、正在做、等你点头或出了问题;点一下跳到时间线上那一步。 +- **关卡**:任务列表上方的“关卡”里,改画布、用显卡出图配音、下载模型、联网扫描四类事,各自可以选“直接做 / 先问我 / 不许做”(默认只有下载要先问)。选了“先问我”,AI 的这一步会停在时间线上,列出它要做什么,等你点“允许”或“不要”(可以写上原因或想怎么改,AI 会收到);勾上“以后直接做”就不再问。你不在工作台时右下角会提示“AI 在问你”。AI 等不及先走了也没关系:你回答后它下一步会收到,允许的话再做一次,不会问第二遍。 - 画布操作直接显示记录中的“修改前 / 修改后”、新增或删除的节点;与操作记录页共用展示,保留定位和适用时的撤销。旧记录缺失时明确提示,不用当前画布猜历史。任务页更新隔离迟到请求,并整理了结果卡片、时间线和窄布局。 - 修复参数对比窗口遮挡撤销提示的问题,撤销按钮现在可直接点击。 -- Task outputs and AI panel pictures now use the existing image inspector, Keep menu and image/canvas parameter comparison, as tools over each picture on hover; tick any two pictures (any step or task) and compare their settings from the bar at the foot of the page. The results strip shows thumbnails straight away. Changes you make in Anomalous while an AI works join its timeline, and the AI is told of them (to keep, not undo) with its next call; activity entries link to their AI task. Steps a restart interrupted are marked as not finished instead of running forever. Canvas changes share recorded before/after receipts with the activity page. Unavailable history is explicit; stale task responses cannot replace a newer selection. Refined result cards and timeline styling, and fixed Undo toasts being blocked by the comparison overlay. +- Task outputs and AI panel pictures now use the existing image inspector, Keep menu and image/canvas parameter comparison, as tools over each picture on hover; tick any two pictures (any step or task) and compare their settings from the bar at the foot of the page. The results strip shows thumbnails straight away. Changes you make in Anomalous while an AI works join its timeline, and the AI is told of them (to keep, not undo) with its next call; activity entries link to their AI task. Steps a restart interrupted are marked as not finished instead of running forever. A flow over each task shows its stages (models, combo, prompt, run, pictures) in order with their state. Gates: changing the canvas, using the GPU, downloading and scanning online can each go ahead, ask you first or never happen (downloads ask by default); a step that asks waits on the timeline with what it would do, for Allow or No (with your reason for the AI). Canvas changes share recorded before/after receipts with the activity page. Unavailable history is explicit; stale task responses cannot replace a newer selection. Refined result cards and timeline styling, and fixed Undo toasts being blocked by the comparison overlay. ### Fixes / 修复 - AI 工作台与 MCP 调用实时生命周期及消息送达:每个工具调用开始时立即写入“正在执行…”步骤并接手等待中的任务,完成后原位更新同一步骤(不再等到耗时操作结束后才显示,也不会重复生成步骤);用户在工作台留下的消息和展台面板回答,除了可通过 `get_answers` 读取外,还会在 AI 下一次成功调用任意工具时自动附带送达一次,并立即在界面上更新为“AI 已经看到”;多会话与显式 `task` ID 互不串任务。 diff --git a/api/ai_gates.py b/api/ai_gates.py new file mode 100644 index 0000000..d9f7432 --- /dev/null +++ b/api/ai_gates.py @@ -0,0 +1,152 @@ +"""Gates: what the user lets AI apps do through Anomalous's MCP endpoint without asking first. + +Each acting tool has a gate in action_catalog.py (canvas, gpu, download, network) and each gate +a policy the user sets on the AI workbench: "auto" (go ahead), "ask" (wait for the user's OK) +or "never". mcp_server.py asks check() before running a tool. With "ask" the tool's step waits +in state "asking", showing what it would do, for up to WAIT_SECONDS: the user's Allow lets the +call go on, No answers the AI with the user's reason. When the user has not answered by then, +the AI is told to wait (get_answers) and call the same tool again; the user's later answer +reaches it as a message (ai_panels._collect), and an allowed call made again with the same +arguments goes ahead without asking twice. Policies are kept in the ComfyUI user folder +(anomalous/ai_gates.json); changing one is a write (local_guard.py keeps it to this computer). +""" + +import json +import os +import threading +import time + +from . import action_catalog, ai_tasks +from .path_utils import atomic_write_json + +POLICIES = ("auto", "ask", "never") +DEFAULTS = {"canvas": "auto", "gpu": "auto", "download": "ask", "network": "auto"} +WAIT_SECONDS = 50 # below the minute many AI apps give a tool call +ALLOWED_SECONDS = 30 * 60 # an Allow the AI did not wait for holds this long for the same call +MAX_ARGS = 8 + +_decided = threading.Condition(ai_tasks._lock) + + +def _path(): + return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_gates.json") + + +def policies(): + try: + with open(_path(), encoding="utf-8") as source: + stored = json.load(source) + except (OSError, ValueError): + stored = {} + stored = stored if isinstance(stored, dict) else {} + return {gate: stored.get(gate) if stored.get(gate) in POLICIES else default for gate, default in DEFAULTS.items()} + + +def set_policy(gate, policy): + if gate not in DEFAULTS or policy not in POLICIES: + raise ValueError("Unknown gate or policy") + current = policies() + current[gate] = policy + atomic_write_json(_path(), current) + return current + + +def describe(tool, arguments): + """What a call would do, as [{label, value}] for the card the user answers.""" + out = [] + for key, value in list((arguments or {}).items())[:MAX_ARGS]: + if key == "task": + continue + if key == "model_id": + value = ai_tasks._model_result(value).get("name") or value + out.append({"label": key, "value": ai_tasks._short(value, 400)}) + return out + + +def _signature(tool, arguments): + return json.dumps([tool, {k: v for k, v in (arguments or {}).items() if k != "task"}], sort_keys=True, ensure_ascii=False) + + +def _blocked(text): + return {"content": [{"type": "text", "text": text}], "isError": True} + + +def _step(step_ref): + task = ai_tasks._load().get(step_ref[0]) if step_ref else None + step = next((item for item in (task or {}).get("steps", []) if item.get("id") == step_ref[1]), None) if task else None + return task, step + + +def check(tool, arguments, step_ref, now=None): + """None when the call may go ahead; else the result to give the AI instead of running it.""" + gate = action_catalog.entry(tool).gate + policy = policies().get(gate, "auto") if gate else "auto" + if policy == "auto": + return None + if policy == "never": + return _blocked(f"The user does not let AI apps do this ({gate}) through Anomalous; tell them what you " + "wanted to do instead, or ask them to change it in the AI workbench (Gates).") + now = time.time() if now is None else now + signature = _signature(tool, arguments) + with _decided: + task, step = _step(step_ref) + if not step: + return None # nothing recorded the call: nothing to show the user, so nothing to wait on + # An Allow given after the AI stopped waiting: the same call again goes ahead. + for earlier in task["steps"]: + asked = earlier.get("gate") or {} + if (asked.get("signature") == signature and asked.get("decision") == "allow" and not asked.get("used") + and now - asked.get("at", 0) < ALLOWED_SECONDS): + asked["used"] = True + earlier["state"] = "done" + step["gate"] = {"gate": gate, "signature": signature, "decision": "allow", "earlier": earlier["id"]} + ai_tasks._save(task) + return None + step["state"] = "asking" + step["gate"] = {"gate": gate, "signature": signature, "lines": describe(tool, arguments), "asked": now} + ai_tasks._save(task) + task_id = task["id"] + ai_tasks._notify(task_id, {"gate": gate, "asks": True}) + with _decided: + _decided.wait_for(lambda: "decision" in step["gate"], WAIT_SECONDS) + asked = step["gate"] + if asked.get("decision") == "allow": + step["state"] = "running" + ai_tasks._save(task) + return None + if asked.get("decision") == "deny": + return _blocked("你没有允许 / The user said no to this in Anomalous." + (f" They said: {asked['text']}" if asked.get("text") else "")) + asked["timed_out"] = True + ai_tasks._save(task) + return {"content": [{"type": "text", "text": "Waiting for the user's OK in Anomalous's AI workbench: nothing was done yet. " + "Call get_answers (wait_seconds up to 55) to hear their answer; once they allow " + "it, call this tool again with the same arguments."}], + "structuredContent": {"waiting_for_user": True, "gate": gate}, "isError": False} + + +def decide(task_id, step_id, decision, text="", remember=False, now=None): + """The user's answer to a step that asks: "allow" or "deny" (with words for the AI).""" + if decision not in ("allow", "deny"): + raise ValueError("decision is allow or deny") + with _decided: + task, step = _step((task_id, step_id)) + asked = (step or {}).get("gate") + if not asked or step.get("state") != "asking": + raise ValueError("Nothing waits for an answer there") + asked.update(decision=decision, at=time.time() if now is None else now) + if text: + asked["text"] = ai_tasks._short(str(text).strip(), 1000) + if asked.get("timed_out"): + # The AI stopped waiting: it hears of the answer as a message, and an Allow waits for its call. + asked["delivered"] = False + step["state"] = "allowed" if decision == "allow" else "error" + else: + step["state"] = "running" if decision == "allow" else "error" + if decision == "deny": + step.update(ok=False, error="你没有允许 / You said no") + ai_tasks._save(task) + _decided.notify_all() + if remember and decision == "allow": + set_policy(asked["gate"], "auto") + ai_tasks._notify(task_id) + return step diff --git a/api/ai_panels.py b/api/ai_panels.py index 9c1f313..498a771 100644 --- a/api/ai_panels.py +++ b/api/ai_panels.py @@ -438,6 +438,15 @@ def _collect(client, panel_id, task_id=None): message["kind"] = "change" # something the user changed in Anomalous, not words to the AI messages.append(message) continue + asked = step.get("gate") or {} + if asked.get("timed_out") and asked.get("decision") and not asked.get("delivered") and panel_id is None: + asked["delivered"] = True + changed.add(task["id"]) + messages.append({"id": step["id"], "task": task["id"], "kind": "gate", "at": asked.get("at"), + "text": (f"The user allowed {step['tool']}: call it again with the same arguments now." + if asked["decision"] == "allow" else + f"The user said no to {step['tool']}." + (f" They said: {asked['text']}" if asked.get("text") else ""))}) + continue panel = step.get("panel") if step.get("kind") != "panel" or (panel_id is not None and panel["id"] != panel_id): continue diff --git a/api/ai_tasks.py b/api/ai_tasks.py index dcaa197..baf74b8 100644 --- a/api/ai_tasks.py +++ b/api/ai_tasks.py @@ -401,7 +401,8 @@ def finish_step(client, tool, arguments, result, started, read_only=False, step_ ms = max(0, int((time.time() - started) * 1000)) what = action_catalog.entry(tool, read_only) kind = what.kind - state = "error" if failed else ("running" if tool == "run_workflow" else "done") + waiting = isinstance(data, dict) and data.get("waiting_for_user") # a gate the user has not answered (ai_gates.py) + state = "error" if failed else "asking" if waiting else "running" if tool == "run_workflow" else "done" summary = summary_of(tool, args) results = [] if failed else results_of(tool, args, data) with _lock: @@ -489,6 +490,7 @@ def task_summary(task, now=None): asks = sum(1 for step in steps if step.get("kind") == "panel" and not step["panel"].get("closed") for block in step["panel"]["blocks"] if block.get("type") in ("choices", "input", "picks") and block.get("id") and block["id"] not in step.get("answers", {})) + asks += sum(1 for step in steps if step.get("state") == "asking") return { "id": task["id"], "title": task.get("title") or "", "client": task.get("client") or ("" if task.get("waiting") else "AI"), "started": task.get("started"), "updated": task.get("updated"), "ended": task.get("ended"), diff --git a/api/ai_tasks_routes.py b/api/ai_tasks_routes.py index 77ee004..d861b84 100644 --- a/api/ai_tasks_routes.py +++ b/api/ai_tasks_routes.py @@ -1,11 +1,11 @@ """The AI workbench's routes over ai_tasks.py (the record) and ai_panels.py (panels, answers, messages): the list and one task (reads, open like the other pages), and the writes the page -makes - start a task, rename or remove one, answer a panel, write to the AI (local_guard.py keeps -the writes to this computer).""" +makes - start a task, rename or remove one, answer a panel, write to the AI, answer a gate and +set the gates' policies (ai_gates.py) - (local_guard.py keeps the writes to this computer).""" from aiohttp import web -from . import activity_log, ai_task_changes, ai_tasks +from . import activity_log, ai_gates, ai_task_changes, ai_tasks async def api_list(request): @@ -83,6 +83,39 @@ async def api_message(request): return web.json_response({"step": step}) +async def _body(request): + try: + body = await request.json() + except (ValueError, UnicodeDecodeError): + return None + return body if isinstance(body, dict) else None + + +async def api_gate(request): + """POST /anomalous/ai_tasks/{id}/gate: {step, decision: allow | deny, text?, remember?}.""" + body = await _body(request) + if body is None: + return web.json_response({"error": "Expected {step, decision}"}, status=400) + try: + step = ai_gates.decide(request.match_info["task_id"], str(body.get("step")), body.get("decision"), + body.get("text") or "", bool(body.get("remember"))) + except ValueError as error: + return web.json_response({"error": str(error)}, status=400) + return web.json_response({"step": step}) + + +async def api_gates(request): + """GET /anomalous/ai_gates: {gate: policy}; POST {gate, policy} changes one.""" + if request.method == "GET": + return web.json_response({"policies": ai_gates.policies()}) + body = await _body(request) + try: + policies = ai_gates.set_policy((body or {}).get("gate"), (body or {}).get("policy")) + except ValueError as error: + return web.json_response({"error": str(error)}, status=400) + return web.json_response({"policies": policies}) + + def register_routes(app): activity_log.add_listener(ai_task_changes.note_activity) # the user's changes while an AI works app.router.add_get("/anomalous/ai_tasks", api_list) @@ -91,3 +124,6 @@ def register_routes(app): app.router.add_post("/anomalous/ai_tasks/{task_id}", api_change) app.router.add_post("/anomalous/ai_tasks/{task_id}/answer", api_answer) app.router.add_post("/anomalous/ai_tasks/{task_id}/message", api_message) + app.router.add_post("/anomalous/ai_tasks/{task_id}/gate", api_gate) + app.router.add_get("/anomalous/ai_gates", api_gates) + app.router.add_post("/anomalous/ai_gates", api_gates) diff --git a/api/mcp_server.py b/api/mcp_server.py index 778492e..09517e3 100644 --- a/api/mcp_server.py +++ b/api/mcp_server.py @@ -23,7 +23,7 @@ import urllib.parse from aiohttp import web -from . import ai_panels, ai_task_runs, ai_tasks, mcp_bridge +from . import ai_gates, ai_panels, ai_task_runs, ai_tasks, mcp_bridge from .local_guard import is_local as _is_local, loopback_host as _loopback_host from .mcp_actions import ACTIONS from .mcp_task_tools import TASK_TOOLS @@ -153,10 +153,14 @@ async def _call(params, request): active_task = step_ref[0] if step_ref else task_id if active_task: ctx["task"] = active_task + before = None try: - async with _slots: - before = await asyncio.to_thread(ai_task_runs.prompt_ids) if name == "run_workflow" else None - result = await asyncio.to_thread(call_tool, TOOLS, name, arguments, ctx) + # The user's gate first (ai_gates.py): it may wait for their OK, or answer for them. + result = await asyncio.to_thread(ai_gates.check, name, arguments, step_ref) + if result is None: + async with _slots: + before = await asyncio.to_thread(ai_task_runs.prompt_ids) if name == "run_workflow" else None + result = await asyncio.to_thread(call_tool, TOOLS, name, arguments, ctx) except BaseException as error: failed_result = {"content": [{"type": "text", "text": f"{name} failed: {error}"}], "isError": True} await asyncio.to_thread( diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 5ecbed7..d51c92b 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -114,6 +114,8 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp 出图后不必回图库寻找功能:鼠标移到结果图片上,右上角的 **ⓘ** 打开完整的图片与参数,**⇄** 和当前画布对比,**☆** 用同一个菜单保存成工作流、搭配或提示词。图片左上角可以 **勾选**:勾两张(不同步骤、不同任务都行),页面底部出现 **查看参数差异**;它读取图片实际保存的工作流,不依赖 AI 口述参数。没有可用工作流的图片会显示原因,视频没有这些参数操作。AI 展台的图片、挑选卡片也一样;滑动对比下面有 **查看参数差异**。 +**流程图和关卡**:任务标题下面的一排流程按 AI 做事的先后列出它经过的阶段(模型、搭配、提示词、运行、挑图……),点一下跳到那一步。任务列表上方的 **关卡** 里,改画布、用显卡出图配音、下载模型、联网扫描四类事各自可以选 **直接做 / 先问我 / 不许做**(默认只有下载先问)。选了“先问我”,AI 的那一步会停在时间线上列出它要做什么,你点 **允许** 才做;点 **不要** 时可以写原因或想怎么改,AI 会收到。AI 最多等 50 秒,等不及的话会先去问你的回答(`get_answers`),你允许后它再做一次,不会问第二遍。 + **AI 干活时你自己改的东西**:AI 在做一个任务时(10 分钟内调用过),你在 AMB 里做的改动,比如画布上的改动、改模型信息、存搭配或提示词,会以“你在 AMB 里改了”出现在这个任务的时间线上,画布改动带修改前后。AI 下一次调用任何工具时会收到它(并被告知保留你的改动,不要改回去),时间线上写着它是否已经知道。操作记录页里,AI 做的和 AI 干活时你做的记录旁边有 **AI 任务 →**。 画布操作下面直接显示操作记录中的 **修改前 / 修改后**,可以定位对应节点,并在允许时撤销。长提示词可能只保留变化附近的片段;早期操作记录被清理后会提示不可用,不会用现在的画布替代历史值。操作记录页使用相同的改动展示。 diff --git a/tests/ai_task_view.mjs b/tests/ai_task_view.mjs index 946947c..e2fcd26 100644 --- a/tests/ai_task_view.mjs +++ b/tests/ai_task_view.mjs @@ -1,7 +1,7 @@ // The AI workbench's rules (web/modules/ai_task_view.js): folding reads, words, counts, addresses. import assert from 'node:assert/strict'; import { - audioResult, duration, groupSteps, isVideo, lookTools, outputUrl, outputPicture, pictureKey, picturePreview, uniquePictures, stepDetail, taskCounts, taskResults, taskState, toolKey, + activityIds, audioResult, duration, flowStages, groupSteps, isVideo, lookTools, outputUrl, outputPicture, pictureKey, picturePreview, uniquePictures, stepDetail, taskCounts, taskResults, taskState, toolKey, } from '../web/modules/ai_task_view.js'; const look = (tool, ok = true, state = 'done') => ({ kind: 'look', tool, ok, state, summary: tool }); @@ -66,4 +66,19 @@ assert.equal(duration(400), null); assert.deepEqual(duration(48_200), { key: 'aiSeconds', values: { count: 48 } }); assert.deepEqual(duration(150_000), { key: 'aiMinutes', values: { count: 3 } }); +// The flow: stages in the order they came, each with its last step's state. +const staged = (id, stage, extra = {}) => ({ id, stage, ok: true, state: 'done', results: [], ...extra }); +const flow = flowStages({ steps: [ + staged('a', 'model'), staged('b', 'prompt'), staged('c', 'model', { state: 'running' }), + { id: 'd', kind: 'note', ok: true, results: [{ type: 'image', filename: 'x.png' }] }, { id: 'e', kind: 'user', ok: true }, + staged('f', 'run', { state: 'asking' }), staged('g', 'canvas', { ok: false, state: 'stopped' }), +] }); +assert.deepEqual(flow.map(item => [item.stage, item.state, item.first, item.count]), [ + ['model', 'running', 'a', 2], ['prompt', 'done', 'b', 1], ['picture', 'done', 'd', 1], ['run', 'asking', 'f', 1], ['canvas', 'error', 'g', 1], +]); +assert.deepEqual(flowStages(null), []); + +// The activity entries a task shows: the AI's canvas changes and the user's own. +assert.deepEqual(activityIds({ steps: [{ results: [{ type: 'canvas', activity: 'a1' }, { type: 'model' }] }, { tool: 'user_activity', activity: 'u1', results: [] }] }), ['a1', 'u1']); + console.log('ai_task_view: ok'); diff --git a/tests/test_ai_gates.py b/tests/test_ai_gates.py new file mode 100644 index 0000000..164200b --- /dev/null +++ b/tests/test_ai_gates.py @@ -0,0 +1,108 @@ +"""Gates (api/ai_gates.py): what AI apps may do without asking, the step that waits for the +user's OK, an answer that comes after the AI stopped waiting. Everything in temporary folders.""" +import json +import os +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1]), str(PLUGIN / "tests")] + +from api import ai_gates, ai_panels, ai_tasks # noqa: E402 +from test_ai_tasks import isolate, ok # noqa: E402 + +GATES = tempfile.mkdtemp(prefix="amb_ai_gates_") + + +class GateTests(unittest.TestCase): + def setUp(self): + isolate() + ai_gates._path = lambda: os.path.join(GATES, "ai_gates.json") + if os.path.exists(ai_gates._path()): + os.remove(ai_gates._path()) + + def call(self, tool, arguments): + """What mcp_server does: a running step, the gate, then the outcome recorded.""" + ref = ai_tasks.begin_step("Claude", tool, arguments, time.time(), False) + verdict = ai_gates.check(tool, arguments, ref) + result = verdict if verdict is not None else ok({"done": True}) + ai_tasks.finish_step("Claude", tool, arguments, result, time.time(), False, step_ref=ref) + return ref, verdict + + def answer_soon(self, ref, decision, text=""): + def later(): + for _ in range(100): + task = ai_tasks.get_task(ref[0]) + if any(step.get("state") == "asking" for step in task["steps"]): + ai_gates.decide(ref[0], ref[1], decision, text) + return + time.sleep(0.02) + threading.Thread(target=later).start() + + def test_policies(self): + self.assertEqual(ai_gates.policies(), ai_gates.DEFAULTS) + self.assertEqual(ai_gates.set_policy("gpu", "ask")["gpu"], "ask") + self.assertEqual(ai_gates.policies()["gpu"], "ask") + with self.assertRaises(ValueError): + ai_gates.set_policy("gpu", "maybe") + with open(ai_gates._path(), "w", encoding="utf-8") as target: + json.dump({"canvas": "never", "gpu": "nonsense"}, target) + self.assertEqual((ai_gates.policies()["canvas"], ai_gates.policies()["gpu"]), ("never", "auto")) + + def test_auto_and_never(self): + self.assertIsNone(self.call("set_prompt", {"text": "x"})[1]) + self.assertIsNone(self.call("search_models", {})[1], "reads have no gate") + ai_gates.set_policy("canvas", "never") + ref, verdict = self.call("set_prompt", {"text": "x"}) + self.assertTrue(verdict["isError"]) + self.assertEqual(ai_tasks.get_task(ref[0])["steps"][-1]["state"], "error") + + def test_ask_and_allow(self): + ai_gates.set_policy("gpu", "ask") + ref = ai_tasks.begin_step("Claude", "run_workflow", {"batch": 2}, time.time(), False) + self.answer_soon(ref, "allow") + self.assertIsNone(ai_gates.check("run_workflow", {"batch": 2}, ref)) + step = ai_tasks.get_task(ref[0])["steps"][0] + self.assertEqual((step["state"], step["gate"]["decision"]), ("running", "allow")) + self.assertEqual(step["gate"]["lines"], [{"label": "batch", "value": "2"}]) + + def test_ask_and_no_with_words(self): + ai_gates.set_policy("download", "ask") + ref = ai_tasks.begin_step("Claude", "download_model", {"type": "loras", "url": "u"}, time.time(), False) + self.answer_soon(ref, "deny", "use the one I have") + verdict = ai_gates.check("download_model", {"type": "loras", "url": "u"}, ref) + self.assertTrue(verdict["isError"]) + self.assertIn("use the one I have", verdict["content"][0]["text"]) + + def test_an_answer_after_the_ai_stopped_waiting(self): + ai_gates.set_policy("gpu", "ask") + ai_gates.WAIT_SECONDS, wait = 0.05, ai_gates.WAIT_SECONDS + try: + ref, verdict = self.call("run_workflow", {"batch": 1}) + finally: + ai_gates.WAIT_SECONDS = wait + self.assertTrue(verdict["structuredContent"]["waiting_for_user"]) + self.assertFalse(verdict["isError"]) + self.assertEqual(ai_tasks.get_task(ref[0])["steps"][0]["state"], "asking") + self.assertEqual(ai_tasks.list_tasks()[0]["asks"], 1, "the task asks the user") + ai_gates.decide(ref[0], ref[1], "allow", remember=True) + self.assertEqual(ai_tasks.get_task(ref[0])["steps"][0]["state"], "allowed") + self.assertEqual(ai_gates.policies()["gpu"], "auto", "remembered: no more asking") + message = ai_panels.attach_pending(ok({}), "Claude", task_id=ref[0])["structuredContent"]["user_messages"][0] + self.assertEqual(message["kind"], "gate") + self.assertIn("call it again", message["text"]) + ai_gates.set_policy("gpu", "ask") + again, verdict = self.call("run_workflow", {"batch": 1}) + self.assertIsNone(verdict, "the same call goes ahead without asking twice") + steps = ai_tasks.get_task(ref[0])["steps"] + self.assertEqual(steps[0]["state"], "done") + with self.assertRaises(ValueError): + ai_gates.decide(ref[0], ref[1], "allow") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/modules/ai_task_view.js b/web/modules/ai_task_view.js index e370c0f..0ad6506 100644 --- a/web/modules/ai_task_view.js +++ b/web/modules/ai_task_view.js @@ -25,6 +25,26 @@ export function groupSteps(steps) { return items; } +/** + * The stages a task's steps went through, in the order they first came, with the last step's + * state for each: 'asking' (waits for the user's OK), 'running', 'error' or 'done'. Notes that + * hand in pictures count as the picture stage. `first`: the stage's first step, `count` its steps. + */ +export function flowStages(task) { + const stages = new Map(); + for (const step of task?.steps || []) { + const stage = step.stage || ((step.results || []).some(result => result.type === 'image') ? 'picture' : null); + if (!stage) continue; + const item = stages.get(stage) || { stage, first: step.id, count: 0 }; + item.count += 1; + item.state = step.state === 'asking' ? 'asking' + : step.state === 'running' || step.state === 'allowed' ? 'running' + : step.ok === false ? 'error' : 'done'; + stages.set(stage, item); + } + return [...stages.values()]; +} + /** The locale key naming a step's tool. */ export function toolKey(tool) { return `aiTool_${String(tool || '').replace(/[^a-z_]/g, '')}`; diff --git a/web/modules/locales.js b/web/modules/locales.js index 1ea390c..6f503f8 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1258,6 +1258,46 @@ export const i18n = { aiKeepImage: "保存为…", aiVideoResult: "视频 · 点击播放", aiMoreResults: "显示更多结果", + aiGates: "关卡", + aiGatesHint: "哪些事 AI 可以直接做,哪些要先问你", + aiGatesIntro: "AI 通过 AMB 做这几类事时,可以直接做、先问你,或者不许做。选“先问你”时,AI 的这一步会停在时间线上,等你点“允许”。", + aiGatesNote: "只管 AI 通过 AMB 做的事;查资料不用问。AI 软件自己可能也会问你一次。", + aiGate_canvas: "改画布", + aiGate_gpu: "用显卡出图、配音", + aiGate_download: "下载模型", + aiGate_network: "联网扫描模型", + aiGateWhy_canvas: "写提示词、换模型、加 LoRA、放搭配、补模型(都能撤销)", + aiGateWhy_gpu: "运行工作流、配音:会占用显卡", + aiGateWhy_download: "下载模型文件:占硬盘,可能很大", + aiGateWhy_network: "到 Civitai 查模型信息", + aiGateQ_canvas: "能不能改画布?", + aiGateQ_gpu: "能不能用显卡跑?", + aiGateQ_download: "能不能下载?", + aiGateQ_network: "能不能联网查?", + aiPolicy_auto: "直接做", + aiPolicy_ask: "先问我", + aiPolicy_never: "不许做", + aiGateReason: "不同意的话,可以告诉 AI 为什么或者想怎么改(选填)", + aiGateRemember: "以后“{gate}”直接做", + aiGateAllow: "允许", + aiGateDeny: "不要", + aiGateLate: "AI 已经不等了:你回答后,它下一步会收到;允许的话它会再做一次。", + aiGateAllowed: "你允许了", + aiGateAllowedLater: "你允许了,等 AI 再来做", + aiGateDenied: "你没有允许", + aiFlow: "这个任务的流程", + aiFlowState_done: "做完了", + aiFlowState_running: "正在做", + aiFlowState_asking: "等你点头", + aiFlowState_error: "出了问题", + aiStage_model: "模型", + aiStage_combo: "搭配", + aiStage_prompt: "提示词", + aiStage_canvas: "画布", + aiStage_run: "运行", + aiStage_picture: "挑图", + aiStage_voice: "配音", + aiStage_library: "模型库", aiTool_user_activity: "你在 AMB 里改了", aiChangeSeen: "AI 已经知道", aiChangeWaiting: "AI 做下一步时会知道", @@ -4116,6 +4156,46 @@ export const i18n = { aiKeepImage: "Keep as…", aiVideoResult: "Video · click to play", aiMoreResults: "Show more results", + aiGates: "Gates", + aiGatesHint: "What the AI may do straight away, and what it asks you first", + aiGatesIntro: "When an AI does these through Anomalous, it can go ahead, ask you first, or never do it. With \"Ask me\", its step waits on the timeline until you press Allow.", + aiGatesNote: "Only what an AI does through Anomalous; looking things up never asks. The AI app may ask you once itself as well.", + aiGate_canvas: "Change the canvas", + aiGate_gpu: "Use the GPU (runs, speech)", + aiGate_download: "Download models", + aiGate_network: "Scan models online", + aiGateWhy_canvas: "Write prompts, swap models, add LoRAs, place combos, put back models (all undoable)", + aiGateWhy_gpu: "Run the workflow or speak a line: takes the GPU", + aiGateWhy_download: "Download model files: disk space, possibly large", + aiGateWhy_network: "Look models up on Civitai", + aiGateQ_canvas: "May it change the canvas?", + aiGateQ_gpu: "May it use the GPU?", + aiGateQ_download: "May it download?", + aiGateQ_network: "May it go online?", + aiPolicy_auto: "Go ahead", + aiPolicy_ask: "Ask me", + aiPolicy_never: "Never", + aiGateReason: "If not, tell the AI why or what to do instead (optional)", + aiGateRemember: "From now on, go ahead with \"{gate}\"", + aiGateAllow: "Allow", + aiGateDeny: "No", + aiGateLate: "The AI stopped waiting: it hears your answer with its next step, and does it again if you allow it.", + aiGateAllowed: "You allowed it", + aiGateAllowedLater: "You allowed it; waiting for the AI to do it", + aiGateDenied: "You said no", + aiFlow: "How this task went", + aiFlowState_done: "Done", + aiFlowState_running: "In progress", + aiFlowState_asking: "Waiting for your OK", + aiFlowState_error: "Something went wrong", + aiStage_model: "Models", + aiStage_combo: "Combo", + aiStage_prompt: "Prompt", + aiStage_canvas: "Canvas", + aiStage_run: "Run", + aiStage_picture: "Pictures", + aiStage_voice: "Voice", + aiStage_library: "Library", aiTool_user_activity: "You changed in Anomalous", aiChangeSeen: "The AI knows", aiChangeWaiting: "The AI hears of it with its next step", diff --git a/web/modules/ui_ai_connect.js b/web/modules/ui_ai_connect.js index 1f7954e..25b5c3e 100644 --- a/web/modules/ui_ai_connect.js +++ b/web/modules/ui_ai_connect.js @@ -65,8 +65,8 @@ export function connectGuide(title = '') { return box; } -/** The guide in a window over the page (Esc or the backdrop closes it). */ -export function openConnectGuide() { +/** A window over the page with `title` and `body` (Esc, ×, or the backdrop closes it); returns close. */ +export function aiWindow(title, body) { const overlay = el('div', 'anomalous-ai-connect-overlay'); const dialog = el('div', 'anomalous-ai-connect-dialog'); dialog.setAttribute('role', 'dialog'); @@ -81,10 +81,16 @@ export function openConnectGuide() { close(); }; const head = el('div', 'anomalous-ai-connect-head'); - head.append(el('div', 'anomalous-ai-connect-title', t('aiConnect')), button('anomalous-ai-connect-close', '×', close, t('close'))); - dialog.append(head, connectGuide()); + head.append(el('div', 'anomalous-ai-connect-title', title), button('anomalous-ai-connect-close', '×', close, t('close'))); + dialog.append(head, body); overlay.append(dialog); overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); window.addEventListener('keydown', onKey, true); document.body.append(overlay); + return close; +} + +/** The guide in a window over the page. */ +export function openConnectGuide() { + aiWindow(t('aiConnect'), connectGuide()); } diff --git a/web/modules/ui_ai_flow.js b/web/modules/ui_ai_flow.js new file mode 100644 index 0000000..7cf4efd --- /dev/null +++ b/web/modules/ui_ai_flow.js @@ -0,0 +1,34 @@ +/** + * The flow over an AI task: the stages of the work its steps went through, in the order they + * came (models, combo, prompt, canvas, run, pictures, voice, library: action_catalog.py's), each + * saying whether it is done, running, waiting for the user or failed. A stage leads to its first + * step in the timeline (`goTo(stepId)`). The stages come from ai_task_view.js flowStages. + */ + +import { translate as t } from './locales.js'; +import { flowStages } from './ai_task_view.js'; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +export function flowMap(task, goTo) { + const stages = flowStages(task); + if (!stages.length) return null; + const box = el('div', 'anomalous-ai-flow'); + box.setAttribute('aria-label', t('aiFlow')); + stages.forEach((item, at) => { + if (at) box.append(el('span', 'anomalous-ai-flow-arrow', '›')); + const chip = el('button', `anomalous-ai-flow-stage is-${item.state}`); + chip.type = 'button'; + chip.title = t(`aiFlowState_${item.state}`); + chip.append(el('span', 'anomalous-ai-flow-mark'), el('span', '', t(`aiStage_${item.stage}`))); + if (item.count > 1) chip.append(el('span', 'anomalous-ai-flow-count', `×${item.count}`)); + chip.onclick = () => goTo(item.first); + box.append(chip); + }); + return box; +} diff --git a/web/modules/ui_ai_gate.js b/web/modules/ui_ai_gate.js new file mode 100644 index 0000000..5a831a2 --- /dev/null +++ b/web/modules/ui_ai_gate.js @@ -0,0 +1,113 @@ +/** + * Gates on the AI workbench (api/ai_gates.py): the card under a step that waits for the user's OK + * (what the AI wants to do; Allow, No with a reason for the AI, and "from now on, go ahead"), what + * the user answered, and the Gates window where each kind of action is set to go ahead, ask first + * or never (changing the canvas, using the GPU, downloading, going online). + */ + +import { translate as t } from './locales.js'; +import { anomalousAlert } from './ui_dialog.js'; +import { aiWindow } from './ui_ai_connect.js'; + +export const GATES = ['canvas', 'gpu', 'download', 'network']; +const POLICIES = ['auto', 'ask', 'never']; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function button(className, label, onClick, title) { + const node = el('button', className, label); + node.type = 'button'; + node.onclick = onClick; + if (title) node.title = title; + return node; +} + +async function post(url, body) { + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`); + return data; +} + +/** What the user answered, or the card to answer with, under a step that has a gate; null otherwise. */ +export function gateCard(step, taskId, changed) { + const asked = step.gate; + if (!asked || (!asked.lines && !asked.decision)) return null; + if (step.state !== 'asking') { + if (!asked.decision || asked.earlier) return null; + const words = asked.decision === 'allow' + ? t(step.state === 'allowed' ? 'aiGateAllowedLater' : 'aiGateAllowed') + : t('aiGateDenied') + (asked.text ? `:${asked.text}` : ''); + return el('div', `anomalous-ai-gate-answer is-${asked.decision}`, words); + } + const card = el('div', 'anomalous-ai-gate'); + card.append(el('div', 'anomalous-ai-gate-title', t(`aiGateQ_${asked.gate}`)), + el('div', 'anomalous-ai-gate-why', t(`aiGateWhy_${asked.gate}`))); + if (asked.lines?.length) { + const lines = el('dl', 'anomalous-ai-gate-lines'); + for (const line of asked.lines) lines.append(el('dt', '', line.label), el('dd', '', line.value)); + card.append(lines); + } + const reason = el('input', 'anomalous-ai-gate-reason'); + reason.placeholder = t('aiGateReason'); + reason.addEventListener('keydown', event => event.stopPropagation()); + const remember = el('label', 'anomalous-ai-gate-remember'); + const box = el('input'); + box.type = 'checkbox'; + remember.append(box, el('span', '', t('aiGateRemember', { gate: t(`aiGate_${asked.gate}`) }))); + const answer = async (decision) => { + card.querySelectorAll('button').forEach((item) => { item.disabled = true; }); + try { + await post(`/anomalous/ai_tasks/${encodeURIComponent(taskId)}/gate`, + { step: step.id, decision, text: reason.value.trim(), remember: box.checked }); + } catch (error) { + await anomalousAlert(error.message); + } + changed(); + }; + const actions = el('div', 'anomalous-ai-gate-actions'); + actions.append(button('anomalous-ai-gate-allow', t('aiGateAllow'), () => answer('allow')), + button('anomalous-ai-gate-deny', t('aiGateDeny'), () => answer('deny')), remember); + card.append(reason, actions); + if (asked.timed_out) card.append(el('div', 'anomalous-ai-gate-note', t('aiGateLate'))); + return card; +} + +/** The Gates window: each kind of action the AI may do, and whether it goes ahead, asks or never. */ +export async function openGates() { + const body = el('div', 'anomalous-ai-gates'); + body.append(el('p', 'anomalous-ai-gates-intro', t('aiGatesIntro'))); + let policies = {}; + try { + policies = (await (await fetch('/anomalous/ai_gates')).json()).policies || {}; + } catch (_) { /* drawn with nothing chosen; a change still saves */ } + for (const gate of GATES) { + const row = el('div', 'anomalous-ai-gates-row'); + const words = el('div', 'anomalous-ai-gates-words'); + words.append(el('div', 'anomalous-ai-gates-name', t(`aiGate_${gate}`)), el('div', 'anomalous-ai-gates-tools', t(`aiGateWhy_${gate}`))); + const choice = el('div', 'anomalous-ai-gates-choice'); + choice.setAttribute('role', 'radiogroup'); + for (const policy of POLICIES) { + const option = button(`anomalous-ai-gates-option${policies[gate] === policy ? ' is-on' : ''}`, t(`aiPolicy_${policy}`), async () => { + try { + policies = (await post('/anomalous/ai_gates', { gate, policy })).policies; + } catch (error) { + await anomalousAlert(error.message); + return; + } + choice.querySelectorAll('button').forEach((item, at) => item.classList.toggle('is-on', POLICIES[at] === policies[gate])); + }); + option.setAttribute('role', 'radio'); + choice.append(option); + } + row.append(words, choice); + body.append(row); + } + body.append(el('p', 'anomalous-ai-gates-note', t('aiGatesNote'))); + aiWindow(t('aiGates'), body); +} diff --git a/web/modules/ui_ai_workbench.js b/web/modules/ui_ai_workbench.js index ba6e9bd..167dc59 100644 --- a/web/modules/ui_ai_workbench.js +++ b/web/modules/ui_ai_workbench.js @@ -26,6 +26,8 @@ import { messageBox, updateMessageBox } from './ui_ai_message.js'; import { hidePanelToast, panelToast } from './ui_ai_panel.js'; import { focusPanel, openQuestions, panelLine, panelSteps, renderStage, stageNews, tabBar } from './ui_ai_side.js'; import { connectGuide, openConnectGuide } 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 { compareBar } from './ui_ai_images.js'; @@ -123,7 +125,7 @@ function renderList(box) { button('anomalous-ai-fold', '‹', () => foldTasks(true), t('aiTasksCollapse'))); const actions = el('div', 'anomalous-ai-tasks-actions'); actions.append(button('anomalous-ai-new-task', `+ ${t('aiNewTask')}`, newTask, t('aiNewTaskHint')), - button('anomalous-ai-link', t('aiConnect'), openConnectGuide)); + button('anomalous-ai-link', t('aiConnect'), openConnectGuide), button('anomalous-ai-link', t('aiGates'), openGates, t('aiGatesHint'))); const list = el('div', 'anomalous-ai-tasks-list'); // Newest first, under a heading for each day (today, yesterday, a date). let day = null; @@ -143,7 +145,8 @@ function renderList(box) { /** One step of the timeline; `number` is its place in the task (the dot shows it). */ function stepRow(step, number) { const running = step.state === 'running'; - const row = el('div', `anomalous-ai-step is-${step.kind}${step.ok ? '' : ' is-failed'}${running ? ' is-running' : ''}`); + const row = el('div', `anomalous-ai-step is-${step.kind}${step.ok ? '' : ' is-failed'}${running ? ' is-running' : ''}${step.state === 'asking' ? ' is-asking' : ''}`); + row.dataset.stepIds = step.id; row.append(el('span', 'anomalous-ai-step-dot', number)); const body = el('div', 'anomalous-ai-step-body'); const line = el('div', 'anomalous-ai-step-line'); @@ -166,7 +169,9 @@ function stepRow(step, number) { if (step.kind === 'panel' && state.task) { body.append(panelLine(step, () => showOnStage(step.panel.id))); } - if (step.error) body.append(el('div', 'anomalous-ai-step-error', step.error)); + const gate = state.task && gateCard(step, state.task.id, () => void refresh()); + if (gate) body.append(gate); + if (step.error && !gate) body.append(el('div', 'anomalous-ai-step-error', step.error)); if (running) body.append(el('div', 'anomalous-ai-step-running', t(step.kind === 'run' ? 'aiRunRunning' : 'aiStepRunning'))); const links = resultLinks(step, renderTask); if (links) body.append(links); @@ -193,6 +198,7 @@ function userChange(step) { function looksRow(steps, number) { const row = el('div', 'anomalous-ai-step is-look is-folded'); + row.dataset.stepIds = steps.map(step => step.id).join(' '); row.append(el('span', 'anomalous-ai-step-dot', number)); const body = el('div', 'anomalous-ai-step-body'); const tools = lookTools(steps).map(({ tool, count }) => (count > 1 ? `${t(toolKey(tool))} ×${count}` : t(toolKey(tool)))); @@ -241,7 +247,8 @@ function renderTask() { let view = state.view; if (!view || view.taskId !== task.id || !box.contains(view.timeline)) { view = state.view = { - taskId: task.id, header: el('div', 'anomalous-ai-task-head'), made: el('div', 'anomalous-ai-made-holder'), + taskId: task.id, header: el('div', 'anomalous-ai-task-head'), flow: el('div', 'anomalous-ai-flow-holder'), + made: el('div', 'anomalous-ai-made-holder'), timeline: el('div', 'anomalous-ai-timeline'), madeKey: null, message: messageBox(async (text) => { await postTask(task.id, { text }, 'message'); @@ -249,7 +256,7 @@ function renderTask() { }), }; view.tabs = el('div', 'anomalous-ai-tabs'); - box.replaceChildren(view.tabs, view.header, view.made, view.timeline, view.message); + box.replaceChildren(view.tabs, view.header, view.flow, view.made, view.timeline, view.message); } // A new panel that asks something brings the stage forward when the window is narrow. const news = stageNews(task, state.stage); @@ -290,6 +297,9 @@ function renderTask() { ...taskCounts(summary).map(say)].filter(Boolean); header.replaceChildren(titleLine, el('div', 'anomalous-ai-task-meta', meta.join(' · '))); + const flow = flowMap(task, stepId => showStep(view.timeline, stepId)); + view.flow.replaceChildren(...(flow ? [flow] : [])); + const madeKey = JSON.stringify(taskResults(task)); if (madeKey !== view.madeKey) { view.madeKey = madeKey; @@ -325,6 +335,15 @@ function renderTask() { drawStage(task, news.arrived); } +/** Brings a step's row into view (from the flow), marked for a moment. */ +function showStep(timeline, stepId) { + const row = [...timeline.querySelectorAll('[data-step-ids]')].find(item => item.dataset.stepIds.split(' ').includes(stepId)); + if (!row) return; + row.scrollIntoView({ block: 'center', behavior: 'smooth' }); + row.classList.add('is-flash'); + setTimeout(() => row.classList.remove('is-flash'), 1600); +} + /** An empty task the user started: say what to tell the AI, and how to connect one. */ function waitingCard(task) { const card = el('div', 'anomalous-ai-waiting'); @@ -529,6 +548,7 @@ export function watchAiTasks(owner) { const detail = event?.detail || {}; const onPage = () => owner.modal?.classList.contains('visible') && owner.currentShellPage?.() === 'ai' && visible(); 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); state.refreshTimer = setTimeout(() => { if (onPage()) void refresh(); diff --git a/web/styles.css b/web/styles.css index d6576ef..c6c19d7 100644 --- a/web/styles.css +++ b/web/styles.css @@ -40,3 +40,4 @@ @import url("./styles/39-ai-stage.css?v=20261021-stage"); @import url("./styles/40-ai-side.css?v=20261022-side"); @import url("./styles/41-ai-results.css?v=20261022-deep"); +@import url("./styles/42-ai-flow.css?v=20261022-gates"); diff --git a/web/styles/42-ai-flow.css b/web/styles/42-ai-flow.css new file mode 100644 index 0000000..149a202 --- /dev/null +++ b/web/styles/42-ai-flow.css @@ -0,0 +1,145 @@ +/* The flow over an AI task (ui_ai_flow.js), the step that waits for the user's OK and the Gates + window (ui_ai_gate.js). */ +.anomalous-ai-flow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: -4px 0 16px; +} + +.anomalous-ai-flow-arrow { color: var(--amb-text-dim); } + +.anomalous-ai-flow-stage { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--amb-border); + border-radius: 999px; + background: transparent; + color: var(--amb-text-soft); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.anomalous-ai-flow-stage:hover { border-color: var(--amb-link); color: var(--amb-text-main); } + +.anomalous-ai-flow-mark { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--amb-text-dim); +} + +.anomalous-ai-flow-stage.is-done .anomalous-ai-flow-mark { background: var(--amb-ok); } +.anomalous-ai-flow-stage.is-running .anomalous-ai-flow-mark { background: var(--amb-link); animation: anomalous-ai-flow-pulse 1.2s ease-in-out infinite; } +.anomalous-ai-flow-stage.is-error .anomalous-ai-flow-mark { background: var(--amb-danger); } +.anomalous-ai-flow-stage.is-asking { + border-color: var(--amb-warn); + background: color-mix(in srgb, var(--amb-warn) 14%, transparent); + color: var(--amb-text-main); +} +.anomalous-ai-flow-stage.is-asking .anomalous-ai-flow-mark { background: var(--amb-warn); } +.anomalous-ai-flow-count { color: var(--amb-text-dim); font-size: 11px; } + +@keyframes anomalous-ai-flow-pulse { 50% { opacity: 0.35; } } + +.anomalous-ai-step.is-flash > .anomalous-ai-step-body { + border-radius: 8px; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--amb-link) 60%, transparent); +} + +/* A step that waits for the user's OK. */ +.anomalous-ai-step.is-asking > .anomalous-ai-step-dot { border-color: var(--amb-warn); color: var(--amb-warn); } + +.anomalous-ai-gate { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--amb-warn) 60%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--amb-warn) 7%, var(--amb-bg-panel)); +} + +.anomalous-ai-gate-title { color: var(--amb-text-main); font-size: 13.5px; font-weight: 600; } +.anomalous-ai-gate-why, +.anomalous-ai-gate-note { color: var(--amb-text-soft); font-size: 12px; } + +.anomalous-ai-gate-lines { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 4px 12px; + margin: 0; + font-size: 12px; +} +.anomalous-ai-gate-lines dt { color: var(--amb-text-dim); } +.anomalous-ai-gate-lines dd { margin: 0; color: var(--amb-text-main); white-space: pre-wrap; overflow-wrap: anywhere; } + +.anomalous-ai-gate-reason { + padding: 7px 10px; + border: 1px solid var(--amb-border); + border-radius: 7px; + background: var(--amb-bg-panel); + color: var(--amb-text-main); + font: inherit; + font-size: 12.5px; +} + +.anomalous-ai-gate-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + +.anomalous-ai-gate-allow, +.anomalous-ai-gate-deny { + padding: 6px 16px; + border: 1px solid var(--amb-border-strong); + border-radius: 7px; + background: transparent; + color: var(--amb-text-main); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} +.anomalous-ai-gate-allow { border-color: var(--amb-ok); background: color-mix(in srgb, var(--amb-ok) 22%, transparent); } +.anomalous-ai-gate-allow:hover { background: color-mix(in srgb, var(--amb-ok) 35%, transparent); } +.anomalous-ai-gate-deny:hover { border-color: var(--amb-danger); } +.anomalous-ai-gate-actions button:disabled { opacity: 0.5; cursor: default; } + +.anomalous-ai-gate-remember { display: inline-flex; align-items: center; gap: 6px; color: var(--amb-text-soft); font-size: 12px; } + +.anomalous-ai-gate-answer { margin-top: 6px; color: var(--amb-text-soft); font-size: 12px; } +.anomalous-ai-gate-answer.is-allow { color: var(--amb-ok); } +.anomalous-ai-gate-answer.is-deny { color: var(--amb-danger); } + +/* The Gates window. */ +.anomalous-ai-gates { display: flex; flex-direction: column; gap: 12px; max-width: 560px; } +.anomalous-ai-gates-intro, +.anomalous-ai-gates-note { margin: 0; color: var(--amb-text-soft); font-size: 12.5px; line-height: 1.6; } +.anomalous-ai-gates-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + padding: 10px 0; + border-top: 1px solid var(--amb-border); +} +.anomalous-ai-gates-words { flex: 1 1 220px; min-width: 0; } +.anomalous-ai-gates-name { color: var(--amb-text-main); font-size: 13px; font-weight: 600; } +.anomalous-ai-gates-tools { color: var(--amb-text-dim); font-size: 11.5px; } +.anomalous-ai-gates-choice { display: inline-flex; border: 1px solid var(--amb-border); border-radius: 8px; overflow: hidden; } +.anomalous-ai-gates-option { + padding: 5px 12px; + border: 0; + background: transparent; + color: var(--amb-text-soft); + font: inherit; + font-size: 12px; + cursor: pointer; +} +.anomalous-ai-gates-option + .anomalous-ai-gates-option { border-left: 1px solid var(--amb-border); } +.anomalous-ai-gates-option.is-on { background: color-mix(in srgb, var(--amb-link) 22%, transparent); color: var(--amb-text-main); } + +@media (prefers-reduced-motion: reduce) { .anomalous-ai-flow-stage.is-running .anomalous-ai-flow-mark { animation: none; } }