From fa0d0d7c4572fd7482e7cbbdad997a03a7f58fca Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:26:39 +0800 Subject: [PATCH] Add three safety levels to the Gates window: careful, balanced, hands-off Step 6 of the lower-barrier plan. The Gates window opens with a Safety level row: Careful asks before everything and allows only the ready-made ways' nodes; Balanced is exactly today's defaults; Hands-off downloads without asking, builds blueprints at once and takes the recommendation when the user does not answer in time. Moving files asks in all three. A level sets the five gates, the node and route rules, no_answer and ai_plans.auto() in one write (ai_gates.LEVELS, set_level) and leaves the canvas tab, run waiting, answer time and privacy alone; level() names the one the settings match, "custom" once a row was changed by itself. GET /anomalous/ai_gates gives level, POST {level} applies one. Privacy stays out of the levels. Written by Gemini 4 Hydrogen (xhigh) in Antigravity from a brief the user approved, in a separate worktree; reviewed, tested and checked live on the test ComfyUI before merging. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 2 + api/ai_gates.py | 63 +++++++++++++++- api/ai_tasks_routes.py | 9 ++- docs/architecture/backend.md | 7 +- docs/architecture/ui-flows.md | 2 +- tests/test_ai_gates.py | 124 +++++++++++++++++++++++++++++++- web/modules/locales.js | 16 +++++ web/modules/ui_ai_gate.js | 130 +++++++++++++++++++++++++++------- web/styles.css | 2 +- web/styles/42-ai-flow.css | 3 + 11 files changed, 325 insertions(+), 37 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 12ced75..454c696 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -455,7 +455,9 @@ covered by the `styles.css` manifest. `change`); `activity_log.tag` names the task on the entry, and the activity page links to 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. It also keeps (`anomalous/ai_tools.json`) how long in all an AI waits for the user's answer (`answer_wait`: 1, 3 or 10 minutes): `api/ai_answer_wait.py` + tool. Its three safety levels (`LEVELS`: `careful`, `balanced`, `free`, or `custom` when a + row differs; `level()`, `set_level()`) set the five gates together with `node_rule`, + `route_rule`, `no_answer` and `ai_plans.auto()`, leaving unrelated settings alone. It also keeps (`anomalous/ai_tools.json`) how long in all an AI waits for the user's answer (`answer_wait`: 1, 3 or 10 minutes): `api/ai_answer_wait.py` tells an AI whose question is still open to wait on with get_answers until then, and while the user types (the page posts `POST /anomalous/ai_tasks/{id}/typing`) until a minute after their last key; report and show_panel remind it to end each round with ask_user. With `ask` the step waits in state `asking` (what it would do listed) for up to 50 s; diff --git a/CHANGELOG.md b/CHANGELOG.md index 8597449..62a9e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,8 @@ - The home page has "Let the AI do it": a line on the AI app that came last (with a warning when it holds a short tool list that cannot take a task handed over here), and six cards: Try it once, Run my preset, Draw what I describe, Change a picture, Draw in privacy mode, Explain my canvas. A card opens a sheet first: what happens on Start, what stays as it is, what the AI does not get, what to mind; Start makes the task with its switches set (a new tab; the privacy card makes a private task) and gives the sentence to copy to the AI. A card's tags name only what Anomalous itself makes sure of. - AI 工作台有了新手引导:第一次打开、里面有任务时,自动带你看一遍新任务、连接 AI、关卡、任务列表、任务的 ⋯、时间线和留言框;以后在主页「让 AI 帮你做」点「怎么用」可以再看。要等 AI 做过事才出现的东西(跑完图的「不太行」、AI 改画布的「撤销这一串」、隐私任务里的「藏」),第一次出现时下面有一行说明,点「知道了」就不再出现。引导遇到看不见的按钮会跳过,很高的一块只框住看得见的部分。 - The AI workbench has a newcomer's tour: the first time it shows a task it walks you through New task, Connect an AI, the gates, the list, a task's ⋯, the timeline and the message box; the home page's How it works (under Let the AI do it) runs it again. What shows up only after the AI has done something (Not quite under a run, the AI's canvas undo, Hide in a private task) gets one line the first time, gone after Got it. A tour skips a control that cannot be seen and lights only the visible part of a tall one. +- 「关卡」窗口最上面多了「安全级别」:点一下 **谨慎**(每件事都先问、只用现成做法里的节点)、**平衡**(默认:改画布、用显卡和联网直接做,下载和挪文件先问)或 **放手**(下载也不问、蓝图直接搭、没及时回答按推荐走;挪文件仍然先问),一次性设好下面那一串开关,不用一个一个改;单独改过下面某一行会变成自定义。 +- The Gates window has a **Safety level** row at the top: one click sets **Careful** (asks before everything, uses only nodes of the ready-made ways), **Balanced** (the default: canvas, graphics card and going online go ahead; downloads and moving files ask first) or **Hands-off** (downloads without asking, builds blueprints at once, takes its recommendation when you do not answer in time; moving files still asks) across the switches below; changing any row on its own shows Custom instead. ## v1.63.0 (AI Ways and Skills, Saves, Official Example Workflows, Privacy Mode) — 2026-11-10 diff --git a/api/ai_gates.py b/api/ai_gates.py index 21956eb..a8ed772 100644 --- a/api/ai_gates.py +++ b/api/ai_gates.py @@ -15,9 +15,10 @@ AI waits for the user's answer to its question (answer_wait, ai_answer_wait.py), when that time runs out (no_answer): "stop", the AI ends its turn and waits for the user, or "recommended", a choice the AI marked a recommended option for takes it (never a gate's question); which custom nodes the AI may add itself (node_rule): any, or only those of the ready-made ways; -and a new route (route_rule): an AI placing nodes by itself rather than through a way, card or route +a new route (route_rule): an AI placing nodes by itself rather than through a way, card or route builds something nobody tried. "allow" goes on with one reminder in the task that a stronger model -suits that better; "ask" waits for the user's OK, once a task. +suits that better; "ask" waits for the user's OK, once a task. Three safety levels (LEVELS: +"careful", "balanced", "free") set those gates, rules, plan_auto and no_answer together. """ import json @@ -26,7 +27,7 @@ import threading import time import uuid -from . import action_catalog, ai_tasks +from . import action_catalog, ai_plans, ai_tasks from .path_utils import atomic_write_json POLICIES = ("auto", "ask", "never") @@ -39,6 +40,29 @@ NEW_ROUTE_NOTE = ("AI 在自己搭一条新路线:现成的卡、做法里没 ANSWER_WAITS = (60, 180, 600) # seconds; the first call waits 30-55 s, get_answers the rest NO_ANSWERS = ("stop", "recommended") DEFAULTS = {"canvas": "auto", "gpu": "auto", "download": "ask", "network": "auto", "files": "ask"} +LEVELS = { + "careful": { + "policies": {"canvas": "ask", "gpu": "ask", "download": "ask", "network": "ask", "files": "ask"}, + "nodes": "ways", + "routes": "ask", + "plan_auto": False, + "no_answer": "stop", + }, + "balanced": { + "policies": dict(DEFAULTS), + "nodes": "any", + "routes": "allow", + "plan_auto": False, + "no_answer": "stop", + }, + "free": { + "policies": {"canvas": "auto", "gpu": "auto", "download": "auto", "network": "auto", "files": "ask"}, + "nodes": "any", + "routes": "allow", + "plan_auto": True, + "no_answer": "recommended", + }, +} 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 @@ -171,6 +195,39 @@ def set_no_answer(mode): return _set_tool_setting("no_answer", mode) +def level(): + """Which safety level the current settings match (careful / balanced / free), or "custom".""" + with _decided: + current = { + "policies": policies(), + "nodes": node_rule(), + "routes": route_rule(), + "plan_auto": ai_plans.auto(), + "no_answer": no_answer(), + } + for name, spec in LEVELS.items(): + if current == spec: + return name + return "custom" + + +def set_level(name): + """Apply all settings of safety level `name`, leaving unrelated settings alone.""" + if not isinstance(name, str) or name not in LEVELS: + raise ValueError("level is careful, balanced or free") + spec = LEVELS[name] + with _decided: + atomic_write_json(_path(), dict(spec["policies"])) + atomic_write_json(_tools_path(), { + **_tool_settings(), + "nodes": spec["nodes"], + "routes": spec["routes"], + "no_answer": spec["no_answer"], + }) + ai_plans.set_auto(spec["plan_auto"]) + return name + + def describe(tool, arguments): """What a call would do, as [{label, value}] for the card the user answers.""" out = [] diff --git a/api/ai_tasks_routes.py b/api/ai_tasks_routes.py index 4b5a04a..244fc17 100644 --- a/api/ai_tasks_routes.py +++ b/api/ai_tasks_routes.py @@ -196,7 +196,8 @@ async def api_gate(request): async def api_gates(request): - """GET /anomalous/ai_gates: {policies: {gate: policy}, canvas_tab, tools}; POST {gate, policy} changes one, + """GET /anomalous/ai_gates: {policies: {gate: policy}, canvas_tab, tools, level}; POST {level: careful | balanced | free} + applies a safety level, POST {gate, policy} changes one, POST {canvas_tab: new | current} whether new tasks work in a tab of their own (ai_task_canvas.py), POST {privacy: bool} whether new tasks are private, POST {privacy_models: bool} whether a private task keeps its models' names too (ai_privacy.py), @@ -209,13 +210,15 @@ async def api_gates(request): return {"policies": ai_gates.policies(), "canvas_tab": ai_task_canvas.default_mode(), "privacy": ai_privacy.default_on(), "privacy_models": ai_privacy.hides_models(), "runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer(), - "nodes": ai_gates.node_rule(), "routes": ai_gates.route_rule()} + "nodes": ai_gates.node_rule(), "routes": ai_gates.route_rule(), "level": ai_gates.level()} if request.method == "GET": return web.json_response(settings()) body = await _body(request) try: - if "canvas_tab" in (body or {}): + if "level" in (body or {}): + ai_gates.set_level(body["level"]) + elif "canvas_tab" in (body or {}): ai_task_canvas.set_default(body["canvas_tab"]) elif "privacy" in (body or {}): ai_privacy.set_default(body["privacy"]) diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index d0b62b8..804ade5 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -368,7 +368,12 @@ The Gates' custom-node rule (`ai_tools.json` `nodes`, `ai_gates.node_rule`): wit `mcp_canvas_edit._allowed` refuses a custom node no ready-made way builds (`reference_blocks.node_types`) and the task did not pick; ComfyUI's own nodes stay allowed. A picks block the user left to the AI (`no_answer` "recommended") takes the item it marked -recommended, as a choice does (`ai_answer_wait.take_recommended`). The user's word on a finished +recommended, as a choice does (`ai_answer_wait.take_recommended`). Safety levels +(`ai_gates.LEVELS`: `careful`, `balanced`, `free`, or `custom` when any of those settings +differs; `ai_gates.level`, `ai_gates.set_level`) let `GET/POST /anomalous/ai_gates` read `level` +and apply the five gates together with `node_rule`, `route_rule`, `no_answer` and +`ai_plans.auto()` in one write, leaving `canvas_tab`, `runs`, `answer_wait` and privacy settings +unchanged. The user's word on a finished run (`POST /anomalous/ai_tasks/{id}/runs/{step}/verdict`, `ai_run_control.set_verdict`) stays on the run's step and reaches the AI as a `user_verdict` step; nothing else keeps it. With "bad" go `issues` (keys of `ai_run_control.ISSUES`: hands, face, body, composition, dark, bright, blurry, style, diff --git a/docs/architecture/ui-flows.md b/docs/architecture/ui-flows.md index 47ce584..9efa6fd 100644 --- a/docs/architecture/ui-flows.md +++ b/docs/architecture/ui-flows.md @@ -26,7 +26,7 @@ node tools/ui_map.mjs build 写出 docs/ui-map.md:按页面列 | --- | --- | --- | | 「新任务」 | 马上开一个任务,显示“等 AI 接手”,要做什么在那一页写(可以不写);下一个来调用的 AI 直接接手它,不会接在旧任务后面 | `POST /anomalous/ai_tasks` | | 「连接 AI」 | 弹出连接说明:地址、复制给 AI 的那句话、各个软件怎么加(`ui_ai_connect.js`);地址上面选给 AI 哪些工具:全部工具 / 跑图精简 / 我选的,「我选的…」按类别勾选后「保存」,地址跟着加上 `?tools=…`(`ui_ai_tool_set.js`) | `GET/POST /anomalous/ai_tool_sets`(只在保存时写) | -| 「关卡」 | AI 做哪些事要先问你(改画布、用显卡、下载……),还有 AI 怎么等你回答、怎么等跑图(`ui_ai_gate.js`) | `/anomalous/ai_gates` | +| 「关卡」 | 顶上一行「安全级别」(「谨慎」「平衡」「放手」,改过下面某一行变成「自定义:你单独改过下面某一行」),一次性设好下面这些开关;再往下是 AI 做哪些事要先问你(改画布、用显卡、下载……),还有 AI 怎么等你回答、怎么等跑图(`ui_ai_gate.js`) | `/anomalous/ai_gates` | | 「全部」「进行中」「等你回答」「出错」 | 只筛这一栏,不改任何东西 | 无 | | 一个任务 | 中间显示它。看过的、鼠标停过的马上出来,再悄悄补上最新内容(`ai_task_cache.js`) | `GET /anomalous/ai_tasks/{task_id}` | | 任务行上的 ⋯ | 鼠标移到任务上才出现,当前任务一直有。菜单和标题栏的 ⋯ 一样(`ui_ai_task_menu.js`),不用先打开这个任务 | `POST /anomalous/ai_tasks/{task_id}` | diff --git a/tests/test_ai_gates.py b/tests/test_ai_gates.py index 5d734f6..e4af122 100644 --- a/tests/test_ai_gates.py +++ b/tests/test_ai_gates.py @@ -1,5 +1,7 @@ """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 asyncio +from contextlib import ExitStack import json import os import sys @@ -13,7 +15,7 @@ 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, mcp_canvas_edit # noqa: E402 +from api import ai_gates, ai_panels, ai_plans, ai_privacy, ai_task_canvas, ai_tasks, ai_tasks_routes, mcp_canvas_edit # noqa: E402 from api.mcp_tools import ToolError # noqa: E402 from test_ai_tasks import isolate, ok # noqa: E402 @@ -236,6 +238,126 @@ class GateTests(unittest.TestCase): with self.assertRaises(ValueError): ai_gates.decide(ref[0], ref[1], "allow") + def _isolated_settings(self): + stack = ExitStack() + folder = stack.enter_context(tempfile.TemporaryDirectory(prefix="amb_gate_levels_")) + for target, filename in ( + (ai_gates, "ai_gates.json"), + (ai_plans, "ai_plans.json"), + (ai_task_canvas, "ai_canvas.json"), + (ai_privacy, "ai_privacy.json"), + ): + stack.enter_context(mock.patch.object(target, "_path", lambda name=filename: os.path.join(folder, name))) + stack.enter_context(mock.patch.object(ai_gates, "_tools_path", lambda: os.path.join(folder, "ai_tools.json"))) + return stack + + def test_safety_levels_default_apply_custom_and_validation(self): + expected = { + "careful": { + "policies": {"canvas": "ask", "gpu": "ask", "download": "ask", "network": "ask", "files": "ask"}, + "nodes": "ways", + "routes": "ask", + "plan_auto": False, + "no_answer": "stop", + }, + "balanced": { + "policies": {"canvas": "auto", "gpu": "auto", "download": "ask", "network": "auto", "files": "ask"}, + "nodes": "any", + "routes": "allow", + "plan_auto": False, + "no_answer": "stop", + }, + "free": { + "policies": {"canvas": "auto", "gpu": "auto", "download": "auto", "network": "auto", "files": "ask"}, + "nodes": "any", + "routes": "allow", + "plan_auto": True, + "no_answer": "recommended", + }, + } + with self._isolated_settings(): + self.assertEqual(ai_gates.level(), "balanced") + ai_gates.set_run_wait("wait") + ai_gates.set_answer_wait(600) + ai_task_canvas.set_default("current") + ai_privacy.set_default(True) + ai_privacy.set_hides_models(False) + self.assertEqual(ai_gates.level(), "balanced", "unrelated settings leave the level unchanged") + + for name, spec in expected.items(): + self.assertEqual(ai_gates.set_level(name), name) + self.assertEqual(ai_gates.level(), name) + self.assertEqual(ai_gates.policies(), spec["policies"], name) + self.assertEqual(ai_gates.node_rule(), spec["nodes"], name) + self.assertEqual(ai_gates.route_rule(), spec["routes"], name) + self.assertIs(ai_plans.auto(), spec["plan_auto"], name) + self.assertEqual(ai_gates.no_answer(), spec["no_answer"], name) + self.assertEqual(ai_gates.run_wait(), "wait") + self.assertEqual(ai_gates.answer_wait(), 600) + self.assertEqual(ai_task_canvas.default_mode(), "current") + self.assertIs(ai_privacy.default_on(), True) + self.assertIs(ai_privacy.hides_models(), False) + + ai_gates.set_level("balanced") + tweaks = ( + (lambda: ai_gates.set_policy("canvas", "ask"), lambda: ai_gates.set_policy("canvas", "auto")), + (lambda: ai_gates.set_policy("download", "auto"), lambda: ai_gates.set_policy("download", "ask")), + (lambda: ai_gates.set_node_rule("ways"), lambda: ai_gates.set_node_rule("any")), + (lambda: ai_gates.set_route_rule("ask"), lambda: ai_gates.set_route_rule("allow")), + (lambda: ai_plans.set_auto(True), lambda: ai_plans.set_auto(False)), + (lambda: ai_gates.set_no_answer("recommended"), lambda: ai_gates.set_no_answer("stop")), + ) + for change, restore in tweaks: + change() + self.assertEqual(ai_gates.level(), "custom") + restore() + self.assertEqual(ai_gates.level(), "balanced") + + for wrong in ("unknown", "", None, 123, ["careful"]): + with self.assertRaises(ValueError): + ai_gates.set_level(wrong) + self.assertEqual(ai_gates.level(), "balanced") + + def test_safety_level_route(self): + def call(method, body=None): + request = mock.Mock() + request.method = method + request.json = lambda: asyncio.sleep(0, body) + response = asyncio.run(ai_tasks_routes.api_gates(request)) + return response.status, json.loads(response.text) + + with self._isolated_settings(): + status, initial = call("GET") + self.assertEqual(status, 200) + self.assertEqual(initial["level"], "balanced") + + status, careful = call("POST", {"level": "careful"}) + self.assertEqual(status, 200) + self.assertEqual(careful["level"], "careful") + self.assertEqual(careful["policies"], {"canvas": "ask", "gpu": "ask", "download": "ask", "network": "ask", "files": "ask"}) + self.assertEqual((careful["nodes"], careful["routes"], careful["no_answer"]), ("ways", "ask", "stop")) + self.assertIs(ai_plans.auto(), False) + + status, free = call("POST", {"level": "free"}) + self.assertEqual(status, 200) + self.assertEqual(free["level"], "free") + self.assertEqual(free["policies"], {"canvas": "auto", "gpu": "auto", "download": "auto", "network": "auto", "files": "ask"}) + self.assertEqual((free["nodes"], free["routes"], free["no_answer"]), ("any", "allow", "recommended")) + self.assertIs(ai_plans.auto(), True) + + status, custom = call("POST", {"gate": "download", "policy": "ask"}) + self.assertEqual((status, custom["level"], custom["policies"]["download"]), (200, "custom", "ask")) + + status, balanced = call("POST", {"level": "balanced"}) + self.assertEqual((status, balanced["level"]), (200, "balanced")) + status, still_balanced = call("POST", {"runs": "wait"}) + self.assertEqual((status, still_balanced["runs"], still_balanced["level"]), (200, "wait", "balanced")) + + status, bad = call("POST", {"level": "nope"}) + self.assertEqual(status, 400) + self.assertIn("error", bad) + self.assertEqual(call("GET")[1]["level"], "balanced") + if __name__ == "__main__": unittest.main() diff --git a/web/modules/locales.js b/web/modules/locales.js index 97970e5..515abd8 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1745,6 +1745,14 @@ export const i18n = { aiGatesHint: "哪些事 AI 可以直接做,哪些要先问你", aiGatesIntro: "AI 通过 AMB 做这几类事时,可以直接做、先问你,或者不许做。选“先问我”时,AI 的这一步会停在时间线上,等你点“允许”。", aiGatesNote: "只管 AI 通过 AMB 做的事;查资料不用问。AI 软件自己可能也会问你一次。", + aiLevel: "安全级别", + aiLevel_careful: "谨慎", + aiLevel_balanced: "平衡", + aiLevel_free: "放手", + aiLevelWhy_careful: "AI 每做一件事都先问你,只用现成做法里的节点。适合刚开始用。", + aiLevelWhy_balanced: "默认:改画布、用显卡、联网直接做;下载模型和挪文件先问你。", + aiLevelWhy_free: "AI 自己做到底:下载也不问,蓝图直接搭,你没及时回答就按推荐走;挪文件仍然先问。", + aiLevelWhy_custom: "自定义:你单独改过下面某一行", aiGate_canvas: "改画布", aiGate_gpu: "用显卡出图、配音", aiGate_download: "下载模型", @@ -5741,6 +5749,14 @@ export const i18n = { 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.", + aiLevel: "Safety level", + aiLevel_careful: "Careful", + aiLevel_balanced: "Balanced", + aiLevel_free: "Hands-off", + aiLevelWhy_careful: "The AI asks before everything and uses only nodes of the ready-made ways. Good to start with.", + aiLevelWhy_balanced: "The default: canvas changes, the graphics card and going online go ahead; downloads and moving files ask you first.", + aiLevelWhy_free: "The AI goes all the way: downloads without asking, builds blueprints at once, takes its recommendation when you do not answer in time; moving files still asks.", + aiLevelWhy_custom: "Custom: you changed a row below yourself", aiGate_canvas: "Change the canvas", aiGate_gpu: "Use the GPU (runs, speech)", aiGate_download: "Download models", diff --git a/web/modules/ui_ai_gate.js b/web/modules/ui_ai_gate.js index 74017fe..237392a 100644 --- a/web/modules/ui_ai_gate.js +++ b/web/modules/ui_ai_gate.js @@ -3,8 +3,9 @@ * (what the AI wants to do; Allow, No, a reason for the AI folded until asked for, 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, moving model files), whether a new - * task works in a canvas tab of its own (api/ai_task_canvas.py), how long the AI waits for the + * or never (changing the canvas, using the GPU, downloading, going online, moving model files), safety levels + * (careful, balanced, free) that set them together with node/route rules, blueprint auto-build and timeout behavior, + * whether a new task works in a canvas tab of its own (api/ai_task_canvas.py), how long the AI waits for the * user's answer and what happens when nobody answers, and whether the AI waits for a run or the user tells it when the run is done * (ui_ai_call.js). */ @@ -17,6 +18,8 @@ import { icon } from './ui_ai_icons.js'; export const GATES = ['canvas', 'gpu', 'download', 'network', 'files']; const POLICIES = ['auto', 'ask', 'never']; +const LEVELS = ['careful', 'balanced', 'free']; +const LEVEL_VALUES = new Set([...LEVELS, 'custom']); function el(tag, className, text) { const node = document.createElement(tag); @@ -98,6 +101,40 @@ export function gateCard(step, taskId, changed) { return card; } +/** Top row of the Gates window: three safety levels, none lit when `current` is custom. */ +function levelRow(current, apply) { + const row = el('div', 'anomalous-ai-gates-row anomalous-ai-gates-level'); + row.append(el('div', 'anomalous-ai-gates-name', t('aiLevel'))); + const choice = el('div', 'anomalous-ai-gates-choice'); + choice.setAttribute('role', 'radiogroup'); + const note = el('div', 'anomalous-ai-gates-tools'); + let kept = current; + const draw = () => { + choice.querySelectorAll('button').forEach((item, at) => { + const on = LEVELS[at] === kept; + item.classList.toggle('is-on', on); + item.setAttribute('aria-checked', String(on)); + }); + note.textContent = LEVEL_VALUES.has(kept) ? t(`aiLevelWhy_${kept}`) : ''; + note.classList.toggle('is-custom', kept === 'custom'); + }; + for (const value of LEVELS) { + const option = button('anomalous-ai-gates-option', t(`aiLevel_${value}`), () => apply(value)); + option.setAttribute('role', 'radio'); + option.dataset.level = value; + choice.append(option); + } + draw(); + row.append(choice, note); + return { + row, + set(next) { + kept = next; + draw(); + }, + }; +} + /** A row of the Gates window: a setting, its words and its choices; `save(choice)` posts it and gives what is kept. */ function choiceRow(key, choices, current, labelOf, save) { const row = el('div', 'anomalous-ai-gates-row'); @@ -106,7 +143,11 @@ function choiceRow(key, choices, current, labelOf, save) { const choice = el('div', 'anomalous-ai-gates-choice'); choice.setAttribute('role', 'radiogroup'); let kept = current; - const draw = () => choice.querySelectorAll('button').forEach((item, at) => item.classList.toggle('is-on', choices[at] === kept)); + const draw = () => choice.querySelectorAll('button').forEach((item, at) => { + const on = choices[at] === kept; + item.classList.toggle('is-on', on); + item.setAttribute('aria-checked', String(on)); + }); for (const value of choices) { const option = button('anomalous-ai-gates-option', labelOf(value), async () => { try { @@ -125,35 +166,72 @@ function choiceRow(key, choices, current, labelOf, save) { return row; } -/** The Gates window: each kind of action the AI may do (goes ahead, asks or never), where it works, which tools it gets. */ +/** The Gates window: safety levels, each kind of action the AI may do, where it works, which tools it gets. */ export async function openGates() { const body = el('div', 'anomalous-ai-gates'); body.append(el('p', 'anomalous-ai-gates-intro', t('aiGatesIntro'))); let known = {}; + let loaded = false; try { known = await (await fetch('/anomalous/ai_gates')).json(); + loaded = true; } catch (_) { /* drawn with nothing chosen; a change still saves */ } - const save = async change => post('/anomalous/ai_gates', change); - for (const gate of GATES) { - body.append(choiceRow(gate, POLICIES, known.policies?.[gate], policy => t(`aiPolicy_${policy}`), - async policy => (await save({ gate, policy })).policies[gate])); - } - body.append(choiceRow('canvasTab', ['new', 'current'], known.canvas_tab || 'new', mode => t(`aiCanvasTab_${mode}`), - async mode => (await save({ canvas_tab: mode })).canvas_tab), - choiceRow('privacy', [false, true], known.privacy === true, on => t(`aiPrivacyDefault_${on}`), - async on => (await save({ privacy: on })).privacy), - choiceRow('privacyModels', [true, false], known.privacy_models !== false, on => t(`aiPrivacyModels_${on}`), - async on => (await save({ privacy_models: on })).privacy_models), - choiceRow('answerWait', [60, 180, 600], known.answer_wait || 180, seconds => t(`aiAnswerWait_${seconds}`), - async seconds => (await save({ answer_wait: seconds })).answer_wait), - choiceRow('noAnswer', ['stop', 'recommended'], known.no_answer || 'stop', mode => t(`aiNoAnswer_${mode}`), - async mode => (await save({ no_answer: mode })).no_answer), - choiceRow('runs', ['call', 'wait'], known.runs || 'call', mode => t(`aiRunWait_${mode}`), - async mode => (await save({ runs: mode })).runs), - choiceRow('nodes', ['any', 'ways'], known.nodes || 'any', mode => t(`aiNodeRule_${mode}`), - async mode => (await save({ nodes: mode })).nodes), - choiceRow('route', ['allow', 'ask'], known.routes || 'allow', mode => t(`aiRouteRule_${mode}`), - async mode => (await save({ routes: mode })).routes), - darkroomPart(), el('p', 'anomalous-ai-gates-note', t('aiGatesNote'))); + const rows = el('div', 'anomalous-ai-gates-rows'); + const drawRows = (data) => { + let top = null; + const save = async (change) => { + const next = await post('/anomalous/ai_gates', change); + if (!loaded) { + loaded = true; + drawRows(next); + } else { + top?.set(next.level); + } + return next; + }; + const applyLevel = async (value) => { + const hadFocus = top.row.contains(document.activeElement); + const buttons = [...top.row.querySelectorAll('button')]; + buttons.forEach((item) => { item.disabled = true; }); + let next; + try { + next = await post('/anomalous/ai_gates', { level: value }); + } catch (error) { + buttons.forEach((item) => { item.disabled = false; }); + await anomalousAlert(error.message); + return; + } + loaded = true; + drawRows(next); + if (hadFocus) rows.querySelector(`[data-level="${value}"]`)?.focus(); + }; + top = levelRow(data.level, applyLevel); + const list = [top.row]; + for (const gate of GATES) { + list.push(choiceRow(gate, POLICIES, data.policies?.[gate], policy => t(`aiPolicy_${policy}`), + async policy => (await save({ gate, policy })).policies[gate])); + } + list.push( + choiceRow('canvasTab', ['new', 'current'], data.canvas_tab || 'new', mode => t(`aiCanvasTab_${mode}`), + async mode => (await save({ canvas_tab: mode })).canvas_tab), + choiceRow('privacy', [false, true], data.privacy === true, on => t(`aiPrivacyDefault_${on}`), + async on => (await save({ privacy: on })).privacy), + choiceRow('privacyModels', [true, false], data.privacy_models !== false, on => t(`aiPrivacyModels_${on}`), + async on => (await save({ privacy_models: on })).privacy_models), + choiceRow('answerWait', [60, 180, 600], data.answer_wait || 180, seconds => t(`aiAnswerWait_${seconds}`), + async seconds => (await save({ answer_wait: seconds })).answer_wait), + choiceRow('noAnswer', ['stop', 'recommended'], data.no_answer || 'stop', mode => t(`aiNoAnswer_${mode}`), + async mode => (await save({ no_answer: mode })).no_answer), + choiceRow('runs', ['call', 'wait'], data.runs || 'call', mode => t(`aiRunWait_${mode}`), + async mode => (await save({ runs: mode })).runs), + choiceRow('nodes', ['any', 'ways'], data.nodes || 'any', mode => t(`aiNodeRule_${mode}`), + async mode => (await save({ nodes: mode })).nodes), + choiceRow('route', ['allow', 'ask'], data.routes || 'allow', mode => t(`aiRouteRule_${mode}`), + async mode => (await save({ routes: mode })).routes), + ); + rows.replaceChildren(...list); + }; + drawRows(known); + body.append(rows, darkroomPart(), el('p', 'anomalous-ai-gates-note', t('aiGatesNote'))); aiWindow(t('aiGates'), body); } diff --git a/web/styles.css b/web/styles.css index c3bc75d..a95d969 100644 --- a/web/styles.css +++ b/web/styles.css @@ -40,7 +40,7 @@ @import url("./styles/39-ai-stage.css?v=20261021-stage"); @import url("./styles/40-ai-side.css?v=20261024-links"); @import url("./styles/41-ai-results.css?v=20261024-results2"); -@import url("./styles/42-ai-flow.css?v=20261024-links"); +@import url("./styles/42-ai-flow.css?v=20261113-levels"); @import url("./styles/43-ai-toolbox.css?v=20261024-feel"); @import url("./styles/44-ai-status.css?v=20261024-stop"); @import url("./styles/45-ai-side-views.css?v=20261025-stage-badge"); diff --git a/web/styles/42-ai-flow.css b/web/styles/42-ai-flow.css index d66c3d5..56b4552 100644 --- a/web/styles/42-ai-flow.css +++ b/web/styles/42-ai-flow.css @@ -133,6 +133,7 @@ /* The Gates window. */ .anomalous-ai-gates { display: flex; flex-direction: column; gap: 12px; max-width: 560px; } +.anomalous-ai-gates-rows { display: flex; flex-direction: column; gap: 12px; } .anomalous-ai-gates-intro, .anomalous-ai-gates-note { margin: 0; color: var(--amb-text-soft); font-size: 12.5px; line-height: 1.6; } @@ -168,6 +169,8 @@ .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-level .anomalous-ai-gates-tools { flex: 1 0 100%; line-height: 1.5; } +.anomalous-ai-gates-level .anomalous-ai-gates-tools.is-custom { color: var(--amb-text-soft); } .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;