From 36668d2ebf5917a3c46ae8652bb4b46ad287eb6a Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:17:37 +0800 Subject: [PATCH] Show on the timeline which skills the AI went by Each skill the AI fetches with get_toolbox(skill=...) is a "skill" step of its task (ai_skills.note_use), with the form its advice came in for the canvas's model; the same advice again before the next run adds nothing. A run's row lists the skills fetched since the run before it (ai_task_view.skillsBefore). A skill step never names an untitled task. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 5 ++-- CHANGELOG.md | 4 +-- api/ai_skills.py | 52 +++++++++++++++++++++++++++------- api/ai_tasks.py | 2 +- api/mcp_task_tools.py | 4 ++- docs/guides/mcp.md | 2 +- tests/ai_task_view.mjs | 12 +++++++- tests/test_ai_skills.py | 20 +++++++++++++ web/modules/ai_task_view.js | 12 +++++++- web/modules/locales.js | 12 ++++++++ web/modules/ui_ai_step_body.js | 22 ++++++++++++-- web/modules/ui_ai_workbench.js | 2 +- web/styles/37-ai-workbench.css | 3 ++ 13 files changed, 129 insertions(+), 23 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c1b82bc..b48247c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -209,7 +209,7 @@ covered by the `styles.css` manifest. `api/ai_skills.py` holds the skills beside them: advice the AI reads for a kind of job (how to write a prompt a model draws well), a data file each in `api/skills/`, building nothing. begin_task lists the ones on (name and a line), `get_toolbox(skill=…)` gives one, tool - descriptions only point at them; advice that depends on the canvas's main model comes in the + descriptions only point at them; each one fetched is a "skill" step of the task (`note_use`); advice that depends on the canvas's main model comes in the form for its size, its parameters counted from the file's header (`model_kind.diffusion_params`, `model_advice.main_params`) (`tests/test_ai_skills.py`). `web/modules/mcp_canvas_brief.js` is the canvas in brief for an AI (pure): bounded path chains and node lines, @@ -366,7 +366,8 @@ covered by the `styles.css` manifest. `api/ai_message_refs.py` checks what the message names and gives each to the AI with the message (in full, pictures attached; get_toolbox shares its `lookup`), `tests/test_ai_message_refs.py`. `ui_ai_step_body.js` is what a timeline step says under its - title (the user's own steps with whether the AI has them, a decision's card, the AI's note, what + title (the user's own steps with whether the AI has them, a decision's card, the AI's note, a + skill fetched and, under a run, the skills fetched before it (`ai_task_view.skillsBefore`), what happened outside Anomalous). `api/ai_outside.py` is that last: when the AI calls (never in the background) it looks at the top of the output and input folders, by the files' own times, for pictures no ComfyUI run saved and pictures rewritten in place since the AI's previous call diff --git a/CHANGELOG.md b/CHANGELOG.md index c312aa9..7fc4b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ - 同一个模型的几个版本放在一起:扫描认出来自同一个 Civitai 模型页的文件,在模型列表里合成一张卡片,卡片上的版本名旁边多了 **“3 个版本 ▾”**,点版本名就能换成别的版本(封面、名字、插入节点都跟着换),选过的版本下次还是它。只认 Civitai 的模型编号,文件名再像也不会合在一起;没扫描、Civitai 上找不到的模型照旧各自一张。模型详情里名字下面一排列出这里有的几个版本,点一下就过去;再下面的 **Civitai 上的全部版本** 点了才联网,列出这个模型页的每个版本(底模、日期、大小),这里有的可以直接打开,没有的点 **下载** 放到这个模型旁边,下载完按 SHA-256 核对;名字和已有文件重了,就在名字后面加上版本名,不覆盖。 - Versions of one model sit together: files a scan found on the same Civitai model page share one card in the model list, whose version label gains **"3 versions ▾"** and switches to another version (its cover, name and Add node too); the version picked stays picked. Only Civitai's model id groups them, never a similar file name; models not scanned or not on Civitai keep a card each. A model's details list the versions here under its name, one click each, and **All versions on Civitai**, asked only when pressed, lists every version of the model page (base model, date, size): the ones here open, the others **Download** next to this one, checked by their SHA-256; a taken file name gets the version's name added, nothing is replaced. -- 新增 **skill**,和做法并列:做法是 AMB 替 AI 搭好的节点,skill 是 AI 遇到某类活时自己去取的建议,不搭节点。第一个是“写提示词·动作适合模型”,按画布主模型的大小给建议:AMB 读模型文件头数出画图部分的参数量(压缩过的 GGUF 也一样,不看文件大小),100 亿参数以上的大模型(Flux.1、Flux.2、千问图像这类)手和复杂动作画得好,AI 照画面需要写,不藏手;小模型(SD1.5、SDXL、Illustrious、Anima 这类)没指定姿势就选画得清楚的动作,手不是重点时可以自然遮挡或构图不露手,但这只是可选,不是规定;认不出模型时两种情况都告诉它。你指定的姿势和你给的提示词照原样。AI 开工时只看到 skill 的名字和一句话,要写提示词时才去取全文,工具说明里只留一句提示,给 AI 的工具清单不变长。AI 工作台右边的**做法**面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。 -- New: **skills**, beside the ways. A way is nodes AMB builds for the AI; a skill is advice the AI fetches when a kind of job comes up, and it builds nothing. The first: "Writing prompts: poses the model can draw", by the size of the canvas's main model: AMB counts its drawing model's parameters from the file's header (a compressed GGUF alike; the file size is not the yardstick). A large one, 10 billion or more (Flux.1, Flux.2, Qwen-Image and the like), draws hands and hard poses well, so the AI writes what the picture wants with no hiding; on a smaller one (SD 1.5, SDXL, Illustrious, Anima…), with no pose asked for, it picks one the model draws cleanly, and hands that are not the point may be hidden naturally or framed out, as an option, not a rule; a model it cannot tell gets both cases. Poses you ask for and prompts you give stay as they are. At the start of a task the AI sees only each skill's name and a line, and fetches the advice when it writes a prompt; the tool descriptions only point at it, so the tool list AI apps hold does not grow. The skills are at the bottom of the **Ways** panel on the right of the AI workbench, each with a switch: the AI can't fetch the ones turned off. +- 新增 **skill**,和做法并列:做法是 AMB 替 AI 搭好的节点,skill 是 AI 遇到某类活时自己去取的建议,不搭节点。第一个是“写提示词·动作适合模型”,按画布主模型的大小给建议:AMB 读模型文件头数出画图部分的参数量(压缩过的 GGUF 也一样,不看文件大小),100 亿参数以上的大模型(Flux.1、Flux.2、千问图像这类)手和复杂动作画得好,AI 照画面需要写,不藏手;小模型(SD1.5、SDXL、Illustrious、Anima 这类)没指定姿势就选画得清楚的动作,手不是重点时可以自然遮挡或构图不露手,但这只是可选,不是规定;认不出模型时两种情况都告诉它。你指定的姿势和你给的提示词照原样。AI 开工时只看到 skill 的名字和一句话,要写提示词时才去取全文,工具说明里只留一句提示,给 AI 的工具清单不变长。AI 工作台右边的**做法**面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。AI 取了哪个 skill、按哪一档给的建议,时间线上都有一行 **Skill**;之后那次跑图下面也写着“这次跑图前参照了:……”。 +- New: **skills**, beside the ways. A way is nodes AMB builds for the AI; a skill is advice the AI fetches when a kind of job comes up, and it builds nothing. The first: "Writing prompts: poses the model can draw", by the size of the canvas's main model: AMB counts its drawing model's parameters from the file's header (a compressed GGUF alike; the file size is not the yardstick). A large one, 10 billion or more (Flux.1, Flux.2, Qwen-Image and the like), draws hands and hard poses well, so the AI writes what the picture wants with no hiding; on a smaller one (SD 1.5, SDXL, Illustrious, Anima…), with no pose asked for, it picks one the model draws cleanly, and hands that are not the point may be hidden naturally or framed out, as an option, not a rule; a model it cannot tell gets both cases. Poses you ask for and prompts you give stay as they are. At the start of a task the AI sees only each skill's name and a line, and fetches the advice when it writes a prompt; the tool descriptions only point at it, so the tool list AI apps hold does not grow. The skills are at the bottom of the **Ways** panel on the right of the AI workbench, each with a switch: the AI can't fetch the ones turned off. Each skill the AI fetches is a **Skill** row on the timeline, with the form its advice came in, and the run after it says "Went by before this run: …". - 跑图前的尺寸提醒放宽一档:6GB 显卡采样到约 420 万像素才提醒(以前 210 万),12GB 约 840 万。今天在 6GB 的笔记本显卡上实测,400 万像素一次约 100 秒,并不吃紧;以前的门槛会让 AI 动不动就问你要不要停。解码大图的提醒不变。 - The size warning before a run is less eager: on a 6 GB card from about 4.2 megapixels (2.1 before), 8.4 on 12 GB. A 6 GB laptop card sampled 4 megapixels in about 100 s today; the old line had the AI ask you to stop runs that were fine. The warning for decoding a large picture is as before. diff --git a/api/ai_skills.py b/api/ai_skills.py index 65a0bc3..fd2a807 100644 --- a/api/ai_skills.py +++ b/api/ai_skills.py @@ -9,12 +9,15 @@ model or its size cannot be told (model_advice.main_params counts them from the begin_task lists the skills on (name and short); get_toolbox(skill=…) gives one. The tools' own descriptions only point at a skill, so the list every app holds stays small. The user turns a skill off in the panel (user/anomalous/ai_skills.json): the AI is not -offered it and get_toolbox refuses it. +offered it and get_toolbox refuses it. Each skill fetched is a step of the AI's task (note_use), with +the form its advice came in, so the user sees what the AI went by; the run after it says so too. """ import json import os import threading +import time +import uuid FOLDER = os.path.join(os.path.dirname(__file__), "skills") NOTE = "Advice: get_toolbox(skill=name) when its job comes up: " @@ -83,14 +86,14 @@ def by_model(name): return bool(SKILLS.get(name, {}).get("by_model")) -def _for_model(forms, model): - """The lines of `forms` (a skill's by_model) for `model` {name, params}, or the unknown ones.""" +def _form(forms, model): + """Which form of `forms` (a skill's by_model) is for `model` {name, params}: large, small or unknown.""" params = (model or {}).get("params") or 0 - if not params: - return forms["unknown"] - size = f"{params / 1e9:.1f} billion" - name = os.path.splitext(os.path.basename(str(model.get("name") or "").replace("\\", "/")))[0] or "The model" - return [line.format(model=name, size=size) for line in forms["large" if params >= forms["large_from"] else "small"]] + return "unknown" if not params else "large" if params >= forms["large_from"] else "small" + + +def _model_name(model): + return os.path.splitext(os.path.basename(str((model or {}).get("name") or "").replace("\\", "/")))[0] def get(name, model=None): @@ -102,6 +105,33 @@ def get(name, model=None): raise SkillError(f"The user turned the skill {name!r} off: do the job your own way.") skill = SKILLS[name] lines = list(skill["text"]) - if skill.get("by_model"): - lines[1:1] = _for_model(skill["by_model"], model) - return {"skill": name, "advice": lines} + answer = {"skill": name, "advice": lines} + forms = skill.get("by_model") + if forms: + form = _form(forms, model) + size = f"{model['params'] / 1e9:.1f} billion" if form != "unknown" else "" + lines[1:1] = [line.format(model=_model_name(model) or "The model", size=size) for line in forms[form]] + answer["for_model"] = {"form": form, "model": _model_name(model), "billions": round(model["params"] / 1e9, 1) if size else None} + return answer + + +def note_use(client, answer, session="", task_id=None, now=None): + """Skill `answer` (get's) fetched by the AI, as a step of its task: which skill and, for one that goes + by the model, the form it came in. The same skill in the same form again before a run adds nothing.""" + from . import ai_tasks + + skill = SKILLS[answer["skill"]] + now = time.time() if now is None else now + step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "use_skill", "kind": "skill", "ok": True, "state": "done", + "summary": "use_skill", "skill": answer["skill"], "title": skill["title"], "for_model": answer.get("for_model"), "results": []} + with ai_tasks._lock: + task = ai_tasks.current_task(client, now, session=session, task_id=task_id) + for earlier in reversed(task.get("steps") or []): + if earlier.get("tool") == "run_workflow": + break + if earlier.get("tool") == "use_skill" and earlier.get("skill") == step["skill"] and earlier.get("for_model") == step["for_model"]: + return earlier + ai_tasks._add_step(task, step) + ai_tasks._save(task) + ai_tasks._notify(task["id"]) + return step diff --git a/api/ai_tasks.py b/api/ai_tasks.py index 99ef48f..1c03116 100644 --- a/api/ai_tasks.py +++ b/api/ai_tasks.py @@ -345,7 +345,7 @@ def _add_step(task, step): task["steps"].append(step) del task["steps"][:-MAX_STEPS] task["updated"] = max(task.get("updated", 0), step["at"] + step.get("ms", 0) / 1000) - if task.get("title_from") == "auto" and not task.get("title") and step["kind"] not in ("look", "note") and step["ok"]: + if task.get("title_from") == "auto" and not task.get("title") and step["kind"] not in ("look", "note", "skill") and step["ok"]: task["title"] = step["summary"][:MAX_TITLE] diff --git a/api/mcp_task_tools.py b/api/mcp_task_tools.py index 6123065..de378a7 100644 --- a/api/mcp_task_tools.py +++ b/api/mcp_task_tools.py @@ -232,9 +232,11 @@ def get_toolbox(ctx, task=None, skill=None): if skill: try: - return ai_skills.get(str(skill), _canvas_model() if ai_skills.by_model(str(skill)) else None) + answer = ai_skills.get(str(skill), _canvas_model() if ai_skills.by_model(str(skill)) else None) except ai_skills.SkillError as error: raise ToolError(str(error)) + ai_skills.note_use(ctx.get("client") or "AI", answer, session=ctx.get("session") or "", task_id=task or ctx.get("task")) + return answer task_id = task or ctx.get("task") or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "") box = ai_toolbox.effective(ai_tasks.get_task(task_id)) if task_id else None if not box: diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 8ba5fad..fef29a6 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -150,7 +150,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp **参考做法**:有几件常见的活,AMB 带着现成的做法,是从擅长这些活的工作流里提炼出来的:**局部重绘·裁切缝合**(圈出的地方裁出来放大到 1024 重画再缝回去,需要节点包 ComfyUI-Inpaint-CropAndStitch)、**局部重绘(官方节点)**(没装那个包时用)、**局部重绘·LanPaint**(融合最好、任何模型都行,但慢好几倍,需要节点包 LanPaint)、**放大**(1.25 倍再轻轻重画一遍,只用自带节点,另存一张,文件名以 upscaled 开头)、**分块放大**(2 倍,需要节点包 ComfyUI_UltimateSDUpscale)、**照着姿势画**和**照着构图画**(你递给 AI 一张图,AMB 读出姿势或深度,挑一个和底模对得上的控制网,需要节点包 comfyui_controlnet_aux)、**迭代放大**(分 3 小步放大到 1.3 倍,用 Impact Pack)、**双模型放大**(重画换一个更稳的模型)、**背景移除**(需要节点包 comfyui-inspyrenet-rembg)、**读图出标签**(需要节点包 ComfyUI-WD14-Tagger,AI 跑完会收到读出的标签)、**部位细化(脸和手)**(需要 Impact Pack、Impact Subpack 和 models/ultralytics/bbox 里的检测模型,细化后的图另存一张,文件名以 detailed 开头)、**修手·先放大再修**(你递给 AI 一张图:整张放大到约 400 万像素轻轻重画,原图越小放得越大,最多 2 倍,让手变大,再逐只修手,另存一张,文件名以 hands_fixed 开头;Anima 模型上有 Anima Turbo LoRA 时只跑 10 步,1280×832 的图在 6GB 显卡上约 100 秒。AI 一次出 4 张,自己先放大看手,把手指数不对、粘连、大小不对的去掉,都不行就再出 4 张,最多两轮,再把好的放进只放大手那块的面板让你挑)。AI 工作台右边的**做法**面板列着全部做法:缺节点包或模型的变灰并写明缺什么,每块有个开关,关掉的 AI 就看不到。AI 开工时只收到开着又能用的那些;它说用哪块,AMB 就把那几个节点搭好、接进画布上那个出图的工作流里(采样器、VAE 解码、保存图片都由 AMB 按插口认出来),一次就好,也是一步 Ctrl+Z。画布上有好几个采样器时,AI 得指明用哪一个;缺节点包时,它会知道缺哪个、该用哪块代替。 -**skill**:和做法并列,但不搭节点,是 AI 遇到某类活时自己去取的建议。AI 开工时只看到每个 skill 的名字和一句话,真要做那类活时才用 `get_toolbox(skill=名字)` 取全文,这样给 AI 的工具清单不变长,小模型也吃得消。现在有一个:**写提示词·动作适合模型**(按画布主模型的大小给建议:AMB 从模型文件头数出参数量,100 亿以上的大模型手画得好,照画面需要写,不藏手;小模型没指定姿势就选画得清楚的动作,手不是重点时可以自然遮挡或构图不露手,只是可选。你指定的姿势和你给的提示词照原样)。做法面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。 +**skill**:和做法并列,但不搭节点,是 AI 遇到某类活时自己去取的建议。AI 开工时只看到每个 skill 的名字和一句话,真要做那类活时才用 `get_toolbox(skill=名字)` 取全文,这样给 AI 的工具清单不变长,小模型也吃得消。现在有一个:**写提示词·动作适合模型**(按画布主模型的大小给建议:AMB 从模型文件头数出参数量,100 亿以上的大模型手画得好,照画面需要写,不藏手;小模型没指定姿势就选画得清楚的动作,手不是重点时可以自然遮挡或构图不露手,只是可选。你指定的姿势和你给的提示词照原样)。做法面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。AI 每取一个 skill,时间线上多一行 **Skill**,写着是哪个、按哪一档给的建议(比如“按 Kirazuri(2.1B,小模型)那一档”);之后那次跑图下面也写着“这次跑图前参照了:……”。同一个建议在两次跑图之间只记一次。 **先对一下重画的范围**:圈得不准也没关系。AI 准备只重画标出的地方时,会先在展台把 **你圈的** 和 **AI 调整后** 的范围并排给你看(红色是要重画的地方;AI 可以挪位置、改成方框或椭圆、往外扩、把边缘做软,图下写着外扩和羽化了多少像素)。点 **用 AI 调整的**、**用我圈的** 或 **我重新圈**,也可以在下面写一句(比如“再往手腕多圈一点”),AI 会照着再调一次;点 **让 AI 定** 就用 AI 调整的。选好以后 AMB 才把带这个范围的 `anomalous_inpaint_….png` 放进 input 文件夹,节点怎么搭由 AI 自己来。 diff --git a/tests/ai_task_view.mjs b/tests/ai_task_view.mjs index 6fd1d74..b535f70 100644 --- a/tests/ai_task_view.mjs +++ b/tests/ai_task_view.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { activityIds, audioResult, chainTools, duration, flowStages, foldLooks, groupSteps, isVideo, panelGist, lookTools, newestPicture, outputCount, outputUrl, outputPicture, pictureShown, - pictureKey, picturePreview, uniquePictures, stepDetail, taskCounts, taskOutputs, taskResults, taskState, toolKey, + pictureKey, picturePreview, uniquePictures, skillsBefore, stepDetail, taskCounts, taskOutputs, taskResults, taskState, toolKey, } from '../web/modules/ai_task_view.js'; const look = (tool, ok = true, state = 'done') => ({ kind: 'look', tool, ok, state, summary: tool }); @@ -126,3 +126,13 @@ assert.deepEqual(newestPicture(outputs), { type: 'output', filename: 'a.png', su assert.equal(newestPicture(taskOutputs({ steps: [] })), null); assert.equal(outputCount(taskOutputs(null)), 0); console.log('ai_task_view: results ok'); + +// A run says which skills the AI fetched since the run before it. +const skill = (id, name) => ({ id, tool: 'use_skill', kind: 'skill', skill: name }); +const run = id => ({ id, tool: 'run_workflow', kind: 'run' }); +const timeline = [skill('a', 'old'), run('r1'), skill('b', 'prompt_pose'), look('describe_canvas'), skill('c', 'other'), run('r2'), run('r3')]; +assert.deepEqual(skillsBefore(timeline, timeline[5]).map(step => step.skill), ['prompt_pose', 'other']); +assert.deepEqual(skillsBefore(timeline, timeline[1]).map(step => step.skill), ['old']); +assert.deepEqual(skillsBefore(timeline, timeline[6]), [], 'only since the run before'); +assert.deepEqual(skillsBefore(undefined, run('x')), []); +console.log('ai_task_view: skills ok'); diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 0b05e97..a11a13d 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -64,6 +64,26 @@ class SkillTests(unittest.TestCase): self.assertIn("not known", advice(unknown)) self.assertTrue(advice(None).startswith("For a prompt you write yourself."), "the user's own words first") + def test_a_skill_fetched_is_a_step_once_until_the_next_run(self): + def fetch(params): + with mock.patch.object(mcp_task_tools, "_canvas_model", return_value={"name": "Anima/K.safetensors", "params": params}): + return mcp_task_tools.get_toolbox(self.ctx, skill="prompt_pose") + steps = lambda: [step for step in ai_tasks.get_task(self.ctx["task"])["steps"] if step["tool"] == "use_skill"] + self.assertEqual(fetch(2_100_000_000)["for_model"], {"form": "small", "model": "K", "billions": 2.1}) + fetch(2_100_000_000) + self.assertEqual(len(steps()), 1, "the same advice again adds nothing") + self.assertEqual((steps()[0]["kind"], steps()[0]["title"]["zh"]), ("skill", "写提示词·动作适合模型")) + fetch(12_000_000_000) + self.assertEqual([step["for_model"]["form"] for step in steps()], ["small", "large"], "another form is a step") + ai_tasks.record("Gemini", "run_workflow", {}, ok({}), time.time(), task_id=self.ctx["task"]) + fetch(12_000_000_000) + self.assertEqual(len(steps()), 3, "after a run it is told again") + + def test_a_skill_does_not_name_a_task(self): + task = {"title": "", "title_from": "auto", "steps": []} # a task the AI began without a title + ai_tasks._add_step(task, {"id": "s", "at": time.time(), "tool": "use_skill", "kind": "skill", "ok": True, "summary": "use_skill"}) + self.assertEqual(task["title"], "") + def test_a_skill_turned_off_is_neither_listed_nor_given(self): ai_skills.set_on("prompt_pose", False) self.assertFalse(next(item for item in ai_skills.status() if item["name"] == "prompt_pose")["on"]) diff --git a/web/modules/ai_task_view.js b/web/modules/ai_task_view.js index 9fca538..1127968 100644 --- a/web/modules/ai_task_view.js +++ b/web/modules/ai_task_view.js @@ -5,7 +5,7 @@ */ /** The kinds of step, in the order a task's counts name them. */ -export const KINDS = ['panel', 'user', 'decision', 'canvas', 'run', 'speech', 'download', 'scan', 'show', 'note', 'act', 'outside', 'look']; +export const KINDS = ['panel', 'user', 'decision', 'canvas', 'run', 'speech', 'download', 'scan', 'show', 'note', 'act', 'outside', 'skill', 'look']; const WORK = new Set(['look', 'canvas', 'decision']); // the AI going about the work, not what the user looks for const CHAIN_MIN = 3; @@ -113,6 +113,16 @@ export function toolKey(tool) { return `aiTool_${String(tool || '').replace(/[^a-z_]/g, '')}`; } +/** The skills the AI fetched (use_skill steps, api/ai_skills.py) after the run before `step` and before it. */ +export function skillsBefore(steps, step) { + const list = steps || []; + const found = []; + for (let at = list.findIndex(item => item.id === step?.id) - 1; at >= 0 && list[at].tool !== 'run_workflow'; at--) { + if (list[at].tool === 'use_skill') found.unshift(list[at]); + } + return found; +} + /** What a step was about: its summary without the tool's name ("place_combo: K" → "K"). */ export function stepDetail(step) { const summary = String(step?.summary || ''); diff --git a/web/modules/locales.js b/web/modules/locales.js index 8bb1113..9e49129 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1356,6 +1356,11 @@ export const i18n = { aiState_error: "有一步没做成", aiState_done: "完成", aiKind_look: "查看", + aiKind_skill: "Skill", + aiSkillForm_large: "按 {model}({billions}B,大模型)那一档给的建议", + aiSkillForm_small: "按 {model}({billions}B,小模型)那一档给的建议", + aiSkillForm_unknown: "认不出画布主模型的大小,两档建议都给了", + aiRunSkills: "这次跑图前参照了:{names}", aiKind_canvas: "画布", aiKind_run: "出图", aiKind_download: "下载", @@ -1967,6 +1972,7 @@ export const i18n = { aiTool_get_situation: "看了现状一览", aiTool_outside_files: "AMB 发现有图片在它之外被改动", aiTool_log_outside: "AI 说明在 AMB 之外做的事", + aiTool_use_skill: "AI 参照了一个 skill", aiOutside_new: "新建了 {name}(不是出图产生的)", aiOutside_rewritten: "改写了 {name}", aiOutside_changed: "新建或改写了 {name}", @@ -4828,6 +4834,11 @@ export const i18n = { aiState_error: "A step did not go through", aiState_done: "Done", aiKind_look: "Look", + aiKind_skill: "Skill", + aiSkillForm_large: "The advice for {model} ({billions}B, a large model)", + aiSkillForm_small: "The advice for {model} ({billions}B, a smaller model)", + aiSkillForm_unknown: "The canvas's main model could not be sized: the advice for both", + aiRunSkills: "Went by before this run: {names}", aiKind_canvas: "Canvas", aiKind_run: "Run", aiKind_download: "Download", @@ -5439,6 +5450,7 @@ export const i18n = { aiTool_get_situation: "Looked at where things stand", aiTool_outside_files: "Pictures changed outside AMB", aiTool_log_outside: "What the AI did outside AMB", + aiTool_use_skill: "The AI went by a skill", aiOutside_new: "New: {name} (no run made it)", aiOutside_rewritten: "Rewritten: {name}", aiOutside_changed: "New or changed: {name}", diff --git a/web/modules/ui_ai_step_body.js b/web/modules/ui_ai_step_body.js index 61b0caf..8feab28 100644 --- a/web/modules/ui_ai_step_body.js +++ b/web/modules/ui_ai_step_body.js @@ -3,11 +3,13 @@ * draws the row around it): the user's own steps (marks on a picture, materials, a change made in * Anomalous, pictures handed over, a stop, the canvas mode, a message with the pictures it * carries) each with whether the AI has it (ui_ai_delivery.js), a decision (ui_ai_decisions.js), - * what happened outside Anomalous (pictures changed, the AI's own word: api/ai_outside.py), or + * what happened outside Anomalous (pictures changed, the AI's own word: api/ai_outside.py), a + * skill the AI went by (api/ai_skills.py) and, under a run, the skills fetched before it, or * the AI's note laid out (a long one folded until asked). */ -import { translate as t } from './locales.js'; +import { resolveLocale, translate as t } from './locales.js'; +import { skillsBefore } from './ai_task_view.js'; import { entrySummary } from './activity_log.js'; import { changeNodeId } from './activity_diff.js'; import { noteNode } from './ai_text_format.js'; @@ -27,6 +29,16 @@ function el(tag, className, text) { } const said = text => el('div', 'anomalous-ai-user-text', text); +const words = value => (resolveLocale() === 'zh' ? value?.zh : value?.en) || value?.en || value?.zh || ''; + +/** A skill the AI fetched: its name and, for advice that goes by the model, the form it came in. */ +function skillUsed(step) { + const model = step.for_model; + const box = el('div', 'anomalous-ai-skill-used'); + box.append(el('strong', '', words(step.title))); + if (model) box.append(el('span', '', t(`aiSkillForm_${model.form}`, { model: model.model, billions: model.billions }))); + return box; +} const FOLD_CHARS = 420; const FOLD_LINES = 8; @@ -102,6 +114,12 @@ export function stepBody(step, ctx) { case 'user_canvas': return [said(t(step.canvas === 'new' ? 'aiCanvasChoseNew' : 'aiCanvasChoseCurrent')), deliveryState(step)]; case 'outside_files': return outsideFiles(step, ctx.owner); case 'log_outside': return outsideLog(step); + case 'use_skill': return [skillUsed(step)]; + case 'run_workflow': { + const used = skillsBefore(ctx.steps, step).map(item => words(item.title)); + return [...(used.length ? [el('div', 'anomalous-ai-skill-used', t('aiRunSkills', { names: [...new Set(used)].join(' · ') }))] : []), + ...(step.text ? [note(step.text)] : [])]; + } default: if (step.kind === 'decision') return [decisionCard(step.decision || {}, ctx.numberOf)]; if (step.kind === 'user') return [...(step.text ? [said(step.text)] : []), ...namedPictures(step, ctx.owner), deliveryState(step)]; diff --git a/web/modules/ui_ai_workbench.js b/web/modules/ui_ai_workbench.js index 76e925b..d4ab376 100644 --- a/web/modules/ui_ai_workbench.js +++ b/web/modules/ui_ai_workbench.js @@ -150,7 +150,7 @@ function stepRow(step, number) { text.title = detail; line.append(text); } - body.append(line, ...stepBody(step, { taskId: state.task?.id, owner: state.owner, entries: state.entries, numberOf: id => state.numbers.get(id) })); + body.append(line, ...stepBody(step, { taskId: state.task?.id, owner: state.owner, entries: state.entries, numberOf: id => state.numbers.get(id), steps: state.task?.steps })); const other = step.reply_to || step.replied; if (other) { body.append(iconButton(`anomalous-ai-reply-link${step.replied ? ' is-replied' : ''}`, step.replied ? 'chat' : 'undo', diff --git a/web/styles/37-ai-workbench.css b/web/styles/37-ai-workbench.css index a900650..5da5845 100644 --- a/web/styles/37-ai-workbench.css +++ b/web/styles/37-ai-workbench.css @@ -466,10 +466,13 @@ .anomalous-ai-step.is-note { --kind: var(--ai-note); } .anomalous-ai-step.is-act { --kind: var(--amb-text-soft); } .anomalous-ai-step.is-outside { --kind: var(--amb-warn); } +.anomalous-ai-step.is-skill { --kind: var(--amb-accent); } /* What happened outside Anomalous (ui_ai_step_body.js): the pictures changed, or the AI's own word. */ .anomalous-ai-outside-list { margin: 4px 0 0; padding-left: 18px; color: var(--amb-text-main); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; } .anomalous-ai-outside-who { margin-top: 6px; color: var(--amb-text-dim); font-size: 12px; } +.anomalous-ai-skill-used { display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 4px; color: var(--amb-text-soft); font-size: 13px; line-height: 1.5; } +.anomalous-ai-skill-used strong { color: var(--amb-text-main); font-weight: 600; } .anomalous-ai-outside-log { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin: 4px 0 0; font-size: 13px; line-height: 1.5; } .anomalous-ai-outside-log dt { color: var(--amb-text-dim); } .anomalous-ai-outside-log dd { margin: 0; color: var(--amb-text-main); white-space: pre-wrap; overflow-wrap: anywhere; }