Add a record of what works: ways, skills and packs by model
Each run the AI starts now counts for the ways it still has, the skills fetched before it and the custom node packs of its nodes, by the base model of its main model (api/ai_evidence.py). How it went comes from what the user does anyway: a picture picked in a picks panel or kept in the gallery, one passed over, or their word under the run (Turned out well / Not quite, told to the AI as their message). begin_task and get_toolbox give the AI the record in a few words, add_nodes a way's or the packs' own, and add_nodes(use=...) picks the use's way that did best on the canvas's model. The Ways panel shows each way's and skill's record by model. The Gates window gets "Custom nodes: Any / Only the ways'", which keeps the AI to the ways' nodes and the task's picks. Tests: tests/test_ai_evidence.py; full suite and structure check pass. Checked on an isolated ComfyUI: a real run was recorded with its model and skill, the verdict reached the AI, and the ways-only rule refused a custom node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bbe9d8a8c4
commit
73518dcf88
+12
-1
@@ -223,6 +223,16 @@ covered by the `styles.css` manifest.
|
||||
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`).
|
||||
`api/ai_evidence.py` is the record of what works here: a run the AI started counts for the ways
|
||||
it still has, the skills fetched before it and the custom node packs of its nodes, by the base
|
||||
model of its main model (`ai_task_runs._estimate` puts that on the step as `used`; its end makes an
|
||||
entry of `user/anomalous/ai_evidence.json`), and how it went comes from what the user does anyway:
|
||||
a picture picked in a picks panel or kept in the gallery (`kept_images.py`), passed over, or their
|
||||
word under the run (`web/modules/ui_ai_verdict.js`, told to the AI). begin_task and get_toolbox
|
||||
give the AI the record in a few words, add_nodes a way's or the node packs' own; of a use's ways
|
||||
that can run, the one that did best on the canvas's model is picked (`reference_blocks.pick(order=…)`);
|
||||
the Gates' `nodes` rule (`ai_gates.node_rule`) lets custom nodes in only by the ways
|
||||
(`tests/test_ai_evidence.py`).
|
||||
`web/modules/mcp_canvas_brief.js` is the canvas in brief for an AI (pure): bounded path chains and node lines,
|
||||
the inputs a run needs that nothing feeds yet with the outputs that fit them, ComfyUI's
|
||||
errors; every canvas change answers with what is still unwired. `api/mcp_situation.py` is
|
||||
@@ -408,7 +418,8 @@ covered by the `styles.css` manifest.
|
||||
keep menu's own `keepPictureAs`; its button counts what came since it was last seen
|
||||
(`stage.outputsSeen`). `ui_ai_blocks.js` is the side column's Ways view (做法): the ready-made
|
||||
blocks by job (GET /anomalous/ai_blocks, `reference_blocks.status`), each block's words from its
|
||||
data file, greyed with the pack (a way into ComfyUI-Manager) or model it lacks, and a switch that
|
||||
data file, greyed with the pack (a way into ComfyUI-Manager) or model it lacks, its record here by
|
||||
model (`ai_evidence.tally`), and a switch that
|
||||
keeps it from the AI (POST, `user/anomalous/ai_blocks.json`); under them the skills
|
||||
(`ai_skills.status`), each with the same switch (`user/anomalous/ai_skills.json`), those of the
|
||||
task's kind only (a private task's, else the others), as the AI gets. For a run the AI did not wait
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## v1.63.0 — unreleased
|
||||
|
||||
- 新增 **跑图记录**:AMB 记下 AI 每次跑图用了哪些做法、skill 和第三方节点包,按主模型的底模分开(Illustrious、Anima……),效果好不好从你平常的操作里自动来:挑图面板里选中的、收藏的图算效果好,没被选中的算“没被挑中”;跑完的图下面还有 **效果好 / 不太行** 两个按钮,点了最算数,也告诉 AI。做法面板里每块做法和 skill 下面写着它在这台电脑上的记录(跑过几次、几次效果好、哪些 AI 跑的),没跑过的也写明。AI 开工时就拿到这份记录的摘要,加做法时拿到那块做法的记录;同一种活有几块做法能用时,AMB 先给在画布主模型上记录最好的那块。换哪个 AI 来,一看就知道什么试过、哪里还没试。**关卡** 里新增 **第三方节点**:选“只用做法里的”,AI 自己加节点时只能用做法里搭好的组合和你给任务挑的,ComfyUI 自带的节点照常能用。记录只有名字和次数,没有提示词。
|
||||
- New: **a record of what works**. Anomalous notes the ways, skills and custom node packs of each run the AI starts, by the base model of the run's main model (Illustrious, Anima…), and how it went comes from what you do anyway: a picture picked in a picks panel or kept in the gallery counts the run good, one passed over "not picked"; under a finished run's pictures, **Turned out well / Not quite** is your own word, which counts most and is told to the AI. In the Ways panel each way and skill says how it did on this computer (how often it ran, how often it turned out well, which AI apps ran it), or that it never ran here. The AI gets the record in a few words when it begins a task and a way's own when it adds it, and of the ways that can do a job Anomalous hands it the one with the best record on the canvas's model first, so whichever AI comes next sees what was tried and what was not. The **Gates** window has a new **Custom nodes** row: with "Only the ways'" the AI may add only the nodes of the ready-made ways and what you picked for the task; ComfyUI's built-in nodes stay allowed. The record holds names and counts, never a prompt.
|
||||
- 新增 **隐私模式**:任务标题栏的 **🔒** 一点,这个任务的**图和提示词原文**都不会发给 AI。图一张都不给(放大看图、图片信息、跑完的预览、你递给它的图、AI 软件里任务卡片上的图都一样),它只知道出了几张、成没成功;提示词和图片文件名换成 **[P1]**、**[img1]** 这样的代号:画布上、图片信息里、提示词库、搭配、模型的触发词和介绍、你自己改画布的记录里都一样(只有节点的“文字输入”被换掉,模型名、数字、选项照常给,第三方节点也一样)。AI 拿代号去跑图、写提示词、放挑图面板,AMB 再换回原文,所以它照样能干活,你那边看到的也都是原样。要新的提示词时,AI 会放一个 **提示词框**:你在里面写几条提示词、反向提示词,可以从图库选或拖进参考图,原文只存在 AMB 里;框上方还会按画布主模型的大小提醒你(小模型手容易画坏,可以选简单动作或藏手,只是可选)。新增 skill“隐私模式·怎么干活”,隐私任务里的 AI 开工时会被指过去;隐私任务里 AI 只拿得到这一个 skill,**做法** 面板的 skill 一栏也只显示它,关掉隐私后才换回普通的 skill。**关卡** 窗口里设新任务默认开不开;打开、关掉都记在时间线上并告诉 AI。所有工具的结果出门前都过同一道关,测试会在每个工具的结果里埋“暗号”提示词和图片名,确认一个都漏不出去。
|
||||
- New: **privacy mode**. The **🔒** in a task's header keeps that task's **pictures and prompt text** from the AI. No picture goes (a closer look, picture info, a run's previews, pictures you hand over, the task card in the AI app alike): it hears how many came out and whether they did. Prompts and picture file names become tokens like **[P1]** and **[img1]**: on the canvas, in picture info, saved prompts, combos, models' trigger words and descriptions, and your own canvas changes (only the nodes' text inputs are swapped; model names, numbers and choices stay, third-party nodes too). The AI runs, writes prompts and shows pick panels with the tokens and Anomalous puts the texts back, so it still works and you see everything as it is. For new prompts the AI shows a **prompt box**: you write prompts and a negative one and add reference pictures from the gallery or by dropping them; the texts stay in Anomalous, and above the box a word on what suits the canvas's model (a smaller model often gets hands wrong: a simple pose, or hands hidden, as an option). A new skill, "Privacy mode: how to work", is where a private task's AI is pointed; it is the only skill a private task's AI gets, and the only one the **Ways** panel lists for it, the usual skills coming back once privacy mode is off. The **Gates** window sets it for new tasks; turning it on or off is on the timeline and told to the AI. Every tool's result passes one check on its way out, and a test plants canary prompts and picture names in every tool's result to make sure none gets through.
|
||||
- AI 可以一次排一串提示词:跑图时给几条提示词(最多 8 条),AMB 按顺序一条条填进去排队,其余设置(比如同一个种子)每条都一样,算同一次跑图,图都在时间线的同一步里。以前要一条条跑、每次一个来回。哪一条排不上就停在那里,前面排上的照常跑完。
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""What worked here: the record of the ready-made ways (reference_blocks.py), the skills
|
||||
(ai_skills.py) and the custom node packs the AI's runs used, by the base model of the run's main
|
||||
model, so any AI sees at a glance what was tried on which model and how it turned out (begin_task,
|
||||
get_toolbox, add_nodes), the user sees it in the Ways panel, and add_nodes picks the way that did best.
|
||||
|
||||
A run's step gets "used" once its prompt is known (ai_task_runs._estimate): the main model and its
|
||||
base model, the ways added in its task whose nodes it still has, the skills fetched before it in its
|
||||
task, the packs of its nodes. When it ends it becomes an entry of the record (ai_evidence.json in
|
||||
the ComfyUI user folder, the last MAX_RUNS), with the AI app that ran it. How it went comes from what
|
||||
the user does anyway, nothing to note down by hand: a picture of it picked in a picks panel or kept in
|
||||
the gallery (a recipe, a combo or a prompt made from it, kept_images.py), or their word on the run
|
||||
(set_verdict, told to the AI too), counts it good; their word "not good" counts it so; its pictures
|
||||
passed over in a picks panel count it "not chosen". The record keeps model, way, skill and pack
|
||||
names, never a prompt; its pictures' names stay on this computer, to match the user's picks.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import ai_tasks, kept_images, model_advice, reference_blocks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
MAX_RUNS = 2000
|
||||
MAX_PICTURES = 8
|
||||
MAX_BASES = 4 # base models in one item's line for the AI
|
||||
OVERVIEW_CHARS = 300
|
||||
KINDS = ("way", "skill", "pack")
|
||||
VERDICTS = ("good", "bad")
|
||||
MODEL_INPUTS = ("ckpt_name", "unet_name")
|
||||
COUNTS = (("good", "good"), ("bad", "not good"), ("passed", "not chosen"), ("failed", "failed"))
|
||||
_lock = threading.Lock()
|
||||
_cache = {"path": "", "runs": {}}
|
||||
|
||||
|
||||
def _path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_evidence.json")
|
||||
|
||||
|
||||
def _runs():
|
||||
"""The record, {run step id: entry}, read once and kept (the user folder can change: tests). Under _lock."""
|
||||
path = _path()
|
||||
if _cache["path"] != path:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as source:
|
||||
stored = json.load(source).get("runs")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
stored = None
|
||||
_cache.update(path=path, runs=stored if isinstance(stored, dict) else {})
|
||||
return _cache["runs"]
|
||||
|
||||
|
||||
def _save(runs):
|
||||
for key in sorted(runs, key=lambda key: runs[key].get("at", 0))[:max(0, len(runs) - MAX_RUNS)]:
|
||||
del runs[key]
|
||||
atomic_write_json(_path(), {"runs": runs})
|
||||
|
||||
|
||||
def _main_model(nodes):
|
||||
"""The main model file of a run's nodes: followed from its first sampler's model input up to the
|
||||
loader (through LoRAs and model patches), or ""."""
|
||||
samplers = sorted((key for key, node in nodes.items() if isinstance(node.get("inputs", {}).get("model"), list)
|
||||
and "positive" in node["inputs"]), key=lambda key: (len(key), key))
|
||||
node, seen = nodes.get(samplers[0]) if samplers else None, set()
|
||||
while node and id(node) not in seen:
|
||||
seen.add(id(node))
|
||||
inputs = node.get("inputs") or {}
|
||||
name = next((inputs[key] for key in MODEL_INPUTS if isinstance(inputs.get(key), str)), None)
|
||||
if name:
|
||||
return name
|
||||
link = inputs.get("model")
|
||||
node = nodes.get(str(link[0])) if isinstance(link, list) and link else None
|
||||
return ""
|
||||
|
||||
|
||||
def _packs(types):
|
||||
"""The custom node packs (their folders) node types `types` come from, as ComfyUI loaded them."""
|
||||
try:
|
||||
import nodes
|
||||
except ImportError: # no ComfyUI (tests)
|
||||
return []
|
||||
modules = {str(getattr(nodes.NODE_CLASS_MAPPINGS.get(kind), "RELATIVE_PYTHON_MODULE", "") or "") for kind in types}
|
||||
return sorted({module.split(".")[1] for module in modules if module.startswith("custom_nodes.")})
|
||||
|
||||
|
||||
def ran(prompt):
|
||||
"""What API prompt `prompt` runs, before its task is looked at: its main model, that model's base
|
||||
model, its packs and node types."""
|
||||
nodes = {str(key): node for key, node in (prompt or {}).items() if isinstance(node, dict) and node.get("class_type")}
|
||||
types = {node["class_type"] for node in nodes.values()}
|
||||
model = _main_model(nodes)
|
||||
return {"model": os.path.basename(model.replace("\\", "/")), "base": model_advice._main_base(model) if model else "",
|
||||
"packs": _packs(types), "types": sorted(types)}
|
||||
|
||||
|
||||
def _needs(block):
|
||||
"""The node types a run has when it has way `block`: those of its nodes, or of a variant's."""
|
||||
groups = [block["nodes"], *(variant["nodes"] for variant in (block.get("variants") or {}).values() if variant.get("nodes"))]
|
||||
return [{spec["class_type"] for spec in group.values() if not spec.get("optional")} for group in groups]
|
||||
|
||||
|
||||
def used(found, steps):
|
||||
"""A run's "used" from `found` (ran()) and the steps of its task before it: the ways added there that
|
||||
it still has (their names and titles), the skills fetched there."""
|
||||
types = set(found["types"])
|
||||
added = [result.get("name") for step in steps if step.get("tool") == "add_nodes" and step.get("ok")
|
||||
for result in step.get("results") or [] if result.get("type") == "way"]
|
||||
ways = [name for name in dict.fromkeys(added) if name in reference_blocks.BLOCKS
|
||||
and any(need <= types for need in _needs(reference_blocks.BLOCKS[name]))]
|
||||
return {"model": found["model"], "base": found["base"], "packs": found["packs"],
|
||||
"ways": [{"name": name, "title": reference_blocks.BLOCKS[name]["title"]} for name in ways],
|
||||
"skills": list(dict.fromkeys(step["skill"] for step in steps if step.get("tool") == "use_skill" and step.get("skill")))}
|
||||
|
||||
|
||||
def note_run(task_id, step_id):
|
||||
"""Run `step_id` of task `task_id` ended: an entry of the record, unless it was stopped or its
|
||||
prompt was never seen."""
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
|
||||
if not step or not step.get("used") or step.get("state") not in ("done", "error"):
|
||||
return
|
||||
found = step["used"]
|
||||
entry = {"at": step["at"], "task": task_id, "client": task.get("client") or "", "model": found["model"],
|
||||
"base": found["base"], "state": step["state"], "ways": [way["name"] for way in found["ways"]],
|
||||
"skills": found["skills"], "packs": found["packs"],
|
||||
"pictures": [[item.get("subfolder") or "", item["filename"]] for item in step.get("results") or []
|
||||
if item.get("type") == "image"][:MAX_PICTURES]}
|
||||
if step.get("verdict"):
|
||||
entry["verdict"] = step["verdict"]
|
||||
if not (entry["ways"] or entry["skills"] or entry["packs"]):
|
||||
return # ComfyUI's own nodes only, no way or skill: nothing of the record's to count
|
||||
with _lock:
|
||||
runs = _runs()
|
||||
runs[step_id] = {**runs.get(step_id, {}), **entry}
|
||||
_save(runs)
|
||||
|
||||
|
||||
def note_picks(items, picked):
|
||||
"""A picks panel answered (its pictures `items`, the ids `picked`): a run with a picture picked counts
|
||||
good, one whose pictures were only passed over "not chosen"."""
|
||||
key = lambda item: (str(item.get("subfolder") or ""), str(item.get("filename") or "")) # noqa: E731
|
||||
chosen = {key(item) for item in items if item.get("id") in picked}
|
||||
shown = {key(item) for item in items}
|
||||
with _lock:
|
||||
runs, changed = _runs(), False
|
||||
for entry in runs.values():
|
||||
pictures = {tuple(picture) for picture in entry.get("pictures") or []}
|
||||
if pictures & chosen and not entry.get("picked"):
|
||||
entry["picked"], changed = True, True
|
||||
elif pictures & shown and not pictures & chosen and not entry.get("picked") and not entry.get("passed"):
|
||||
entry["passed"], changed = True, True
|
||||
if changed:
|
||||
_save(runs)
|
||||
|
||||
|
||||
def set_verdict(task_id, step_id, verdict):
|
||||
"""The user's word on finished run `step_id`: "good", "bad" or None (taken back); kept on the step and
|
||||
in the record, and a word to the AI. Returns it."""
|
||||
if verdict not in (*VERDICTS, None):
|
||||
raise ValueError("verdict is good, bad or null")
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
steps = (task or {}).get("steps", [])
|
||||
step = next((item for item in steps if item["id"] == step_id), None)
|
||||
if not step or step.get("tool") != "run_workflow" or step.get("state") != "done":
|
||||
raise ValueError("No such finished run")
|
||||
if step.get("verdict") == verdict:
|
||||
return verdict
|
||||
if verdict:
|
||||
step["verdict"] = verdict
|
||||
number = sum(1 for item in steps[:steps.index(step) + 1] if item.get("tool") == "run_workflow")
|
||||
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": time.time(), "ms": 0, "tool": "user_verdict", "kind": "user",
|
||||
"ok": True, "state": "done", "summary": "user_verdict", "verdict": verdict, "run": step_id,
|
||||
"delivered": False, "results": [], "text": (
|
||||
f"The user says run {number} of this task (step {step_id}) turned out well."
|
||||
if verdict == "good" else
|
||||
f"The user says run {number} of this task (step {step_id}) did not turn out well: "
|
||||
"change the way, a setting or the prompt before running again, or ask them what is off.")})
|
||||
else:
|
||||
step.pop("verdict", None)
|
||||
ai_tasks._save(task)
|
||||
with _lock:
|
||||
runs = _runs()
|
||||
if step_id in runs:
|
||||
runs[step_id]["verdict"] = verdict
|
||||
_save(runs)
|
||||
ai_tasks._notify(task_id)
|
||||
return verdict
|
||||
|
||||
|
||||
def tally():
|
||||
"""The record summed up: {kind: {name: {base: {runs, good, bad, passed, failed, clients}}}}."""
|
||||
out = {kind: {} for kind in KINDS}
|
||||
with _lock:
|
||||
entries = list(_runs().values())
|
||||
if not entries:
|
||||
return out
|
||||
try:
|
||||
kept = kept_images.kept_keys()
|
||||
except (OSError, ValueError): # a store unreadable: the gallery's keeps are left out of the count
|
||||
kept = set()
|
||||
for entry in entries:
|
||||
pictures = {tuple(picture) for picture in entry.get("pictures") or []}
|
||||
verdict = entry.get("verdict")
|
||||
good = verdict == "good" or verdict != "bad" and bool(entry.get("picked") or pictures & kept)
|
||||
for kind in KINDS:
|
||||
for name in entry.get(f"{kind}s") or []:
|
||||
count = out[kind].setdefault(name, {}).setdefault(entry.get("base") or "", {
|
||||
"runs": 0, "good": 0, "bad": 0, "passed": 0, "failed": 0, "clients": []})
|
||||
count["runs"] += 1
|
||||
count["good"] += good
|
||||
count["bad"] += verdict == "bad"
|
||||
count["passed"] += not good and verdict != "bad" and bool(entry.get("passed"))
|
||||
count["failed"] += entry.get("state") == "error"
|
||||
if entry.get("client") and entry["client"] not in count["clients"]:
|
||||
count["clients"].append(entry["client"])
|
||||
return out
|
||||
|
||||
|
||||
def _line(base, count):
|
||||
parts = [f"{count[key]} {label}" for key, label in COUNTS if count[key]]
|
||||
return f"{base or 'other models'} {count['runs']} runs: {', '.join(parts) or 'none judged yet'}"
|
||||
|
||||
|
||||
def words(kind, name, found=None):
|
||||
"""The record of item `name` (a way, skill or pack) in a line for the AI, most run base models
|
||||
first, with the AI apps that ran it; "" when it never ran here."""
|
||||
bases = (found or tally())[kind].get(name)
|
||||
if not bases:
|
||||
return ""
|
||||
shown = sorted(bases, key=lambda base: -bases[base]["runs"])[:MAX_BASES]
|
||||
clients = list(dict.fromkeys(client for base in shown for client in bases[base]["clients"]))
|
||||
return "; ".join(_line(base, bases[base]) for base in shown) + (f" (run by {', '.join(clients[:3])})" if clients else "")
|
||||
|
||||
|
||||
def overview(limit=OVERVIEW_CHARS):
|
||||
"""The ways' and skills' record in a few words for begin_task and get_toolbox, most runs first; ""
|
||||
when nothing ran yet."""
|
||||
found = tally()
|
||||
items = sorted(((name, kind, base, count) for kind in ("way", "skill") for name, bases in found[kind].items()
|
||||
for base, count in bases.items()), key=lambda item: -item[3]["runs"])
|
||||
text = ""
|
||||
for name, kind, base, count in items:
|
||||
part = f"{name} ({kind}) {base or 'other models'}: {count['good']} good of {count['runs']}" + (
|
||||
f", {count['bad']} not good" if count["bad"] else "")
|
||||
if len(text) + len(part) + 2 > limit:
|
||||
break
|
||||
text += ("; " if text else "") + part
|
||||
return (f"Runs here (the user's picks and words; a way or skill not named is untried here): {text}." if text else "")
|
||||
|
||||
|
||||
def order(kind, names, base_of):
|
||||
"""`names` best first by their record on the canvas's base model (`base_of()`, asked only when one of
|
||||
them has a record): good runs up, the user's "not good" and "not chosen" down; untried ones and ties
|
||||
keep their order."""
|
||||
found = tally()[kind]
|
||||
if not any(name in found for name in names):
|
||||
return names
|
||||
base = base_of()
|
||||
|
||||
def score(name):
|
||||
count = found.get(name, {}).get(base)
|
||||
return count["good"] - count["bad"] - count["passed"] / 2 if count else 0
|
||||
return sorted(names, key=lambda name: -score(name))
|
||||
+16
-1
@@ -13,7 +13,8 @@ The same window sets (anomalous/ai_tools.json) how an AI waits for a run (run_wa
|
||||
workbench gives them a line to paste), or "wait", it waits with run_status; how long in all an
|
||||
AI waits for the user's answer to its question (answer_wait, ai_answer_wait.py), and what happens
|
||||
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).
|
||||
"recommended", a choice the AI marked a recommended option for takes it (never a gate's question);
|
||||
and which custom nodes the AI may add itself (node_rule): any, or only those of the ready-made ways.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -26,6 +27,7 @@ from .path_utils import atomic_write_json
|
||||
|
||||
POLICIES = ("auto", "ask", "never")
|
||||
RUN_WAITS = ("call", "wait")
|
||||
NODE_RULES = ("any", "ways")
|
||||
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"}
|
||||
@@ -91,6 +93,19 @@ def set_run_wait(mode):
|
||||
return _set_tool_setting("runs", mode)
|
||||
|
||||
|
||||
def node_rule():
|
||||
"""Which custom nodes an AI adds itself: "any", or "ways": only those the ready-made ways build
|
||||
(reference_blocks.node_types) and those the user picked for the task (mcp_canvas_edit._allowed)."""
|
||||
chosen = _tool_settings().get("nodes")
|
||||
return chosen if chosen in NODE_RULES else "any"
|
||||
|
||||
|
||||
def set_node_rule(mode):
|
||||
if mode not in NODE_RULES:
|
||||
raise ValueError("nodes is any or ways")
|
||||
return _set_tool_setting("nodes", mode)
|
||||
|
||||
|
||||
def answer_wait():
|
||||
"""How long in all an AI waits for the user's answer, in seconds (one of ANSWER_WAITS)."""
|
||||
chosen = _tool_settings().get("answer_wait")
|
||||
|
||||
+3
-1
@@ -25,7 +25,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import ai_marks, ai_tasks
|
||||
from . import ai_evidence, ai_marks, ai_tasks
|
||||
from .ai_panel_parts import ( # noqa: F401 - PanelError and the limits are read as ai_panels.*
|
||||
MAX_ITEMS, MAX_NODES, PanelError, _diagram, _image, _list, _model, _text, _zoom,
|
||||
)
|
||||
@@ -322,6 +322,8 @@ def answer(task_id, step_id, block_id, choice=None, text=None, now=None, skip=Fa
|
||||
_answered.notify_all()
|
||||
answers = dict(step["answers"])
|
||||
ai_tasks._notify(task_id)
|
||||
if block["type"] == "picks" and not skip:
|
||||
ai_evidence.note_picks(block["items"], entry["choice"]) # the runs whose pictures the user picked did well
|
||||
return answers
|
||||
|
||||
|
||||
|
||||
+8
-4
@@ -9,13 +9,14 @@ run_status with it, waiting for the end when asked. tell() puts the same news in
|
||||
other answers: where the run stands while it goes, and once that it ended. The console's lines,
|
||||
the time left and the card's memory join the progress, and the size each sampler works at with
|
||||
its warnings is the step's "estimate" (ai_run_watch.py), the run's shape in boxes its "map" with
|
||||
what changed since the task's run before it (ai_run_map.py); a run the user or the AI stopped
|
||||
(ai_run_control.py) ends "cancelled"."""
|
||||
what changed since the task's run before it (ai_run_map.py), and what it used (its main model, ways,
|
||||
skills and packs) its "used", which goes into the record of what works when it ends (ai_evidence.py);
|
||||
a run the user or the AI stopped (ai_run_control.py) ends "cancelled"."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import ai_gates, ai_run_map, ai_run_watch, ai_tasks
|
||||
from . import ai_evidence, ai_gates, ai_run_map, ai_run_watch, ai_tasks
|
||||
from .mcp_tools import ToolError, _image_content, _resolve_image, retext, tool_text
|
||||
|
||||
RUN_POLL_SECONDS = 2.0
|
||||
@@ -174,6 +175,8 @@ def _finish_run(task_id, step_id, prompts, history, queued=None):
|
||||
task["updated"] = max(task["updated"], time.time())
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
if done >= len(prompts):
|
||||
ai_evidence.note_run(task_id, step_id) # what it used and how it went, in the record of what works
|
||||
return done >= len(prompts)
|
||||
|
||||
|
||||
@@ -259,6 +262,7 @@ def _estimate(task_id, step_id, item):
|
||||
try:
|
||||
found = ai_run_watch.estimate(item[2], (memory or {}).get("total_mb"))
|
||||
shape = ai_run_map.map_of(item[2])
|
||||
ran = ai_evidence.ran(item[2])
|
||||
except Exception: # noqa: BLE001 - an odd prompt leaves the run without an estimate, nothing more
|
||||
return True
|
||||
with ai_tasks._lock:
|
||||
@@ -268,7 +272,7 @@ def _estimate(task_id, step_id, item):
|
||||
if at is None:
|
||||
return True
|
||||
before = next((entry["map"] for entry in reversed(steps[:at]) if entry.get("tool") == "run_workflow" and entry.get("map")), None)
|
||||
steps[at].update(estimate=found, map=shape, changes=ai_run_map.changes(before, shape))
|
||||
steps[at].update(estimate=found, map=shape, changes=ai_run_map.changes(before, shape), used=ai_evidence.used(ran, steps[:at]))
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return True
|
||||
|
||||
@@ -379,6 +379,8 @@ def results_of(tool, arguments, data):
|
||||
out.append(_model_result(arguments["model_id"]))
|
||||
if tool == "place_combo" and arguments.get("name"):
|
||||
out.append({"type": "combo", "name": _short(arguments["name"], 120)})
|
||||
if tool == "add_nodes" and isinstance(data.get("way"), str):
|
||||
out.append({"type": "way", "name": _short(data["way"], 80)}) # a ready-made block: its runs count for it (ai_evidence.py)
|
||||
if isinstance(data.get("_activity"), str):
|
||||
out.append({"type": "canvas", "activity": data["_activity"]})
|
||||
if tool == "speak":
|
||||
|
||||
+26
-7
@@ -1,14 +1,14 @@
|
||||
"""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, answer a gate and
|
||||
set the gates' policies (ai_gates.py), stop a run (ai_run_control.py), hand the AI pictures
|
||||
(ai_pictures.py) - (local_guard.py keeps the writes to this computer)."""
|
||||
set the gates' policies (ai_gates.py), stop a run (ai_run_control.py), say how a run turned out
|
||||
(ai_evidence.py), hand the AI pictures (ai_pictures.py) - (local_guard.py keeps the writes to this computer)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import activity_log, ai_decisions, ai_gates, ai_marks, ai_pictures, ai_privacy, ai_run_control, ai_task_canvas, ai_task_changes, ai_task_links, ai_tasks, ai_toolbox
|
||||
from . import activity_log, ai_decisions, ai_evidence, ai_gates, ai_marks, ai_pictures, ai_privacy, ai_run_control, ai_task_canvas, ai_task_changes, ai_task_links, ai_tasks, ai_toolbox
|
||||
|
||||
|
||||
async def api_list(request):
|
||||
@@ -160,10 +160,12 @@ async def api_gates(request):
|
||||
POST {privacy: bool} whether new tasks are private (ai_privacy.py),
|
||||
POST {runs: call | wait}
|
||||
how the AI waits for a run (ai_gates.run_wait), POST {answer_wait: 60 | 180 | 600} for the user's answer,
|
||||
POST {no_answer: stop | recommended} what happens when that time runs out."""
|
||||
POST {no_answer: stop | recommended} what happens when that time runs out,
|
||||
POST {nodes: any | ways} which custom nodes the AI adds itself (ai_gates.node_rule)."""
|
||||
def settings():
|
||||
return {"policies": ai_gates.policies(), "canvas_tab": ai_task_canvas.default_mode(), "privacy": ai_privacy.default_on(),
|
||||
"runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer()}
|
||||
"runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer(),
|
||||
"nodes": ai_gates.node_rule()}
|
||||
|
||||
if request.method == "GET":
|
||||
return web.json_response(settings())
|
||||
@@ -179,6 +181,8 @@ async def api_gates(request):
|
||||
ai_gates.set_answer_wait(body["answer_wait"])
|
||||
elif "no_answer" in (body or {}):
|
||||
ai_gates.set_no_answer(body["no_answer"])
|
||||
elif "nodes" in (body or {}):
|
||||
ai_gates.set_node_rule(body["nodes"])
|
||||
else:
|
||||
ai_gates.set_policy((body or {}).get("gate"), (body or {}).get("policy"))
|
||||
except ValueError as error:
|
||||
@@ -264,6 +268,19 @@ async def api_stop(request):
|
||||
return web.json_response(answer)
|
||||
|
||||
|
||||
async def api_verdict(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/runs/{step}/verdict {verdict: good | bad | null} - the user's word on a run (ai_evidence.py)."""
|
||||
from . import ai_panels
|
||||
|
||||
body = await _body(request) or {}
|
||||
try:
|
||||
verdict = await asyncio.to_thread(ai_evidence.set_verdict, request.match_info["task_id"], request.match_info["step_id"], body.get("verdict"))
|
||||
except ValueError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
ai_panels.wake()
|
||||
return web.json_response({"verdict": verdict})
|
||||
|
||||
|
||||
async def api_toolbox(request):
|
||||
"""GET /anomalous/ai_toolbox?kind=all: what the picker lists; GET/POST /anomalous/ai_toolbox/favorites."""
|
||||
if request.path.endswith("/favorites"):
|
||||
@@ -284,7 +301,8 @@ async def api_toolbox(request):
|
||||
|
||||
async def api_blocks(request):
|
||||
"""GET /anomalous/ai_blocks: every ready-made block and skill as the 做法 panel shows them
|
||||
(reference_blocks.status, ai_skills.status); POST {name, on} or {skill, on}: the user turns one on or off for the AI."""
|
||||
(reference_blocks.status, ai_skills.status) with how each did in the runs here (ai_evidence.tally);
|
||||
POST {name, on} or {skill, on}: the user turns one on or off for the AI."""
|
||||
from . import ai_skills, mcp_canvas_edit, reference_blocks
|
||||
from .mcp_tools import ToolError
|
||||
|
||||
@@ -302,7 +320,7 @@ async def api_blocks(request):
|
||||
except ToolError as error:
|
||||
return web.json_response({"error": str(error)}, status=502)
|
||||
return web.json_response({"blocks": reference_blocks.status(info), "jobs": list(reference_blocks.JOBS),
|
||||
"skills": ai_skills.status()})
|
||||
"skills": ai_skills.status(), "record": await asyncio.to_thread(ai_evidence.tally)})
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
@@ -319,6 +337,7 @@ def register_routes(app):
|
||||
app.router.add_get("/anomalous/ai_tasks/{task_id}/marks/{name}", api_marked)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/toolbox", api_toolbox_pick)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/stop", api_stop)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/verdict", api_verdict)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/pictures", api_pictures)
|
||||
app.router.add_post("/anomalous/ai_pictures/upload", api_upload)
|
||||
app.router.add_get("/anomalous/ai_toolbox", api_toolbox)
|
||||
|
||||
+38
-5
@@ -10,7 +10,7 @@ import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import ai_pictures, ai_tasks, ai_toolbox, mcp_fill, model_advice, model_download, reference_blocks
|
||||
from . import ai_evidence, ai_gates, ai_pictures, ai_tasks, ai_toolbox, mcp_fill, model_advice, model_download, reference_blocks
|
||||
from .mcp_actions import _action, _local, _page
|
||||
from .media_routes import picture_size
|
||||
from .mcp_tools import ToolError
|
||||
@@ -70,10 +70,22 @@ def _picked(ctx):
|
||||
|
||||
|
||||
def _allowed(ctx, types):
|
||||
"""Refuses node types the user's "only" picks leave out, before the page is asked."""
|
||||
if not ctx.get("task"):
|
||||
"""Refuses node types the user's "only" picks leave out, and, while the user lets custom nodes in only
|
||||
by the ways (ai_gates.node_rule), those of no way the task did not pick either, before the page is asked."""
|
||||
ways_only = ai_gates.node_rule() == "ways"
|
||||
if not ctx.get("task") and not ways_only:
|
||||
return
|
||||
info = object_info(ctx)
|
||||
if ways_only:
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) if ctx.get("task") else None
|
||||
ways = reference_blocks.node_types()
|
||||
outside = sorted({name for name in types if name in info and not ai_toolbox.builtin(info[name])
|
||||
and name not in ways and not ai_toolbox.chosen(box, name, info[name])})
|
||||
if outside:
|
||||
raise ToolError(f"The user lets custom nodes in only by the ready-made ways (add_nodes use=…): not {', '.join(outside)}. "
|
||||
"Use a way or ComfyUI's built-in nodes, or ask the user in the AI workbench (show_panel).")
|
||||
if not ctx.get("task"):
|
||||
return
|
||||
refused = ai_toolbox.refused(ctx["task"], types, info)
|
||||
if refused:
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) or {}
|
||||
@@ -230,8 +242,9 @@ def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, ima
|
||||
if [nodes is not None, block is not None, use is not None].count(True) != 1:
|
||||
raise ToolError("Give one of: use (from begin_task's blocks, with want), block (a ready-made one by name) or nodes (a group in API form).")
|
||||
if use is not None:
|
||||
try: # the use's best block this ComfyUI can run
|
||||
block = reference_blocks.pick(str(use), None if want is None else str(want), object_info(ctx), bool(scene))
|
||||
try: # the use's best block this ComfyUI can run, the one that did best on the canvas's model first
|
||||
block = reference_blocks.pick(str(use), None if want is None else str(want), object_info(ctx), bool(scene),
|
||||
lambda names: ai_evidence.order("way", names, lambda: _main_base(near_node_id)))
|
||||
except reference_blocks.BlockError as error:
|
||||
raise ToolError(str(error))
|
||||
if block is not None:
|
||||
@@ -262,9 +275,29 @@ def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, ima
|
||||
answer["next"] = built["after"]
|
||||
if built["tested"]:
|
||||
answer["tested"] = built["tested"] # so the AI can say how long it will take
|
||||
if isinstance(answer, dict):
|
||||
if block is not None:
|
||||
answer["way"] = str(block) # the step keeps it: the runs with it count for it
|
||||
record = _record(ctx, block, [spec["class_type"] for spec in nodes.values()])
|
||||
if record:
|
||||
answer["record"] = record
|
||||
return answer
|
||||
|
||||
|
||||
def _record(ctx, block, types):
|
||||
"""How block `block`, or the custom node packs of node types `types`, did in this computer's runs (ai_evidence.py)."""
|
||||
if block is not None:
|
||||
words = ai_evidence.words("way", str(block))
|
||||
return f"Your runs with this way: {words}" if words else ""
|
||||
found = ai_evidence.tally()
|
||||
if not found["pack"]:
|
||||
return ""
|
||||
info = object_info(ctx)
|
||||
packs = sorted({ai_toolbox.source_of(info[kind]) for kind in types if kind in info and not ai_toolbox.builtin(info[kind])} - {"api"})
|
||||
lines = [f"{pack}: {words}" for pack in packs if (words := ai_evidence.words("pack", pack, found))]
|
||||
return f"Your runs with these node packs: {'; '.join(lines)}" if lines else ""
|
||||
|
||||
|
||||
def connect_nodes(ctx, from_node_id, to_node_id, input=None, output=None, replace=None):
|
||||
if output is not None and (isinstance(output, bool) or not isinstance(output, (int, str)) or len(str(output)) > 100):
|
||||
raise ToolError("output is the output's slot number or its name.")
|
||||
|
||||
+10
-3
@@ -4,7 +4,7 @@ show_panel / update_panel draw a panel from Anomalous's blocks (ai_panels.py), g
|
||||
reads what the user picked or typed on it, log_outside keeps what the AI did with other tools
|
||||
(ai_outside.py)."""
|
||||
|
||||
from . import ai_answer_wait, ai_decisions, ai_marks, ai_message_refs, ai_outside, ai_panels, ai_repaint, ai_run_control, ai_run_tracking, ai_task_links, ai_task_runs, ai_tasks, ai_toolbox, ai_privacy, ai_skills, reference_blocks
|
||||
from . import ai_answer_wait, ai_decisions, ai_evidence, ai_marks, ai_message_refs, ai_outside, ai_panels, ai_repaint, ai_run_control, ai_run_tracking, ai_task_links, ai_task_runs, ai_tasks, ai_toolbox, ai_privacy, ai_skills, reference_blocks
|
||||
from .mcp_tools import MAX_LIMIT, ToolError, _schema
|
||||
|
||||
PACK_TYPES = 150 # node types listed per picked pack
|
||||
@@ -37,6 +37,9 @@ def begin_task(ctx, title, new_task=False):
|
||||
answer["privacy"] = ai_privacy.NOTE # the user writes the prompts and looks at the pictures: no skill for those
|
||||
elif ai_skills.directory():
|
||||
answer["skills"] = ai_skills.directory()
|
||||
record = ai_evidence.overview()
|
||||
if record:
|
||||
answer["record"] = record # what ran here how often and how it went, by model: any AI's runs
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(task["id"]))
|
||||
if box:
|
||||
answer["toolbox"] = ai_toolbox.words(box)
|
||||
@@ -242,11 +245,15 @@ def get_toolbox(ctx, task=None, skill=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"))
|
||||
record = ai_evidence.words("skill", str(skill))
|
||||
if record:
|
||||
answer["record"] = f"Runs here after it: {record}" # how runs went with this advice, by model
|
||||
return answer
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(task_id)) if task_id else None
|
||||
record = ai_evidence.overview()
|
||||
if not box:
|
||||
return {"picked": False, "note": "The user picked nothing for this task; use what fits."}
|
||||
answer = {"picked": True, "mode": box["mode"], "note": ai_toolbox.words(box)}
|
||||
return {"picked": False, "note": "The user picked nothing for this task; use what fits.", **({"record": record} if record else {})}
|
||||
answer = {"picked": True, "mode": box["mode"], "note": ai_toolbox.words(box), **({"record": record} if record else {})}
|
||||
if box.get("nodes"):
|
||||
found = find_nodes(ctx, types=box["nodes"])
|
||||
answer["nodes"] = found["nodes"]
|
||||
|
||||
+15
-3
@@ -13,7 +13,9 @@ latent), "$save" (the output node its picture reaches), "$loader" (the sampler's
|
||||
name; one that "replaces" a role takes its place, links and settings. A block may have a variant for
|
||||
a model family (on an Anima model an LLLite, or the Turbo LoRA for fewer steps). The directory
|
||||
(directory()) is what the AI is handed when it begins a task: the blocks this ComfyUI can run
|
||||
and the user has not turned off (user/anomalous/ai_blocks.json), a few words each, by job.
|
||||
and the user has not turned off (user/anomalous/ai_blocks.json), a few words each, by job. How each
|
||||
did in this computer's runs, by model, is ai_evidence.py's record; of a use's blocks that can run,
|
||||
the one that did best on the canvas's model is picked.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -193,9 +195,17 @@ def directory(info=None):
|
||||
return out
|
||||
|
||||
|
||||
def pick(use, want=None, info=None, scene=False):
|
||||
def node_types():
|
||||
"""Every node type a block builds (its variants' too): the custom nodes the user may let in only by the blocks (ai_gates.node_rule)."""
|
||||
groups = [block["nodes"] for block in BLOCKS.values()] + [variant["nodes"] for block in BLOCKS.values()
|
||||
for variant in (block.get("variants") or {}).values() if variant.get("nodes")]
|
||||
return {spec["class_type"] for group in groups for spec in group.values()} | {"SetUnionControlNetType"}
|
||||
|
||||
|
||||
def pick(use, want=None, info=None, scene=False, order=None):
|
||||
"""The block for `use` (and `want`): the first by rank this ComfyUI can run, else the first, whose
|
||||
build then says what it needs. Given a second picture (`scene`), the use's block that takes one."""
|
||||
build then says what it needs. Given a second picture (`scene`), the use's block that takes one.
|
||||
`order(names)`: several of them can run, best first by how they did here (ai_evidence.order)."""
|
||||
names = [name for name, block in BLOCKS.items() if block["job"] == use]
|
||||
if not names:
|
||||
raise BlockError(f"No use {use!r}; there are: {', '.join(JOBS)}.")
|
||||
@@ -208,6 +218,8 @@ def pick(use, want=None, info=None, scene=False):
|
||||
off = turned_off()
|
||||
runs = [name for name in chosen if name not in off and (info is None or (
|
||||
all(kind in info for kind in BLOCKS[name]["packs"]) and not _missing_models(BLOCKS[name], info)))]
|
||||
if order and len(runs) > 1:
|
||||
runs = order(runs)
|
||||
return (runs or chosen)[0]
|
||||
|
||||
|
||||
|
||||
@@ -281,6 +281,24 @@ a skill (the `prompt` of `set_prompt` and `run_workflow` at `prompt_pose`: a pos
|
||||
draws well, nonessential hands optionally hidden, the user's own text and poses kept), so the
|
||||
tool list stays within its budget; AMB passes prompt text through unchanged.
|
||||
|
||||
The record of what works (`api/ai_evidence.py`, `user/anomalous/ai_evidence.json`, the last
|
||||
2000 runs) is the ways, skills and custom node packs each run used, by the base model of the
|
||||
run's main model (followed from its first sampler's model input to the loader). A way counts
|
||||
for a run when the task added it (`add_nodes` answers `way`, the step keeps it as a result) and
|
||||
the run still has its nodes; a skill when it was fetched before the run in the task; a pack when
|
||||
one of the run's nodes is from it. Only the run's end is written (a stopped one is not, nor one
|
||||
of ComfyUI's own nodes only); how it went is never asked for: a picture picked in a picks panel
|
||||
(`ai_panels.answer`) or kept in the gallery (`kept_images.kept_keys`, read when the record is
|
||||
summed up) counts it good, its pictures passed over in a picks panel "not chosen", and the
|
||||
user's word under it (`POST /anomalous/ai_tasks/{id}/runs/{step}/verdict`, a user step the AI
|
||||
gets) goes over both. Names only, never prompt text; picture names stay on this computer. The
|
||||
AI reads it in begin_task and get_toolbox (`record`, at most a few hundred characters, nothing
|
||||
while nothing ran), with a way in add_nodes' answer and with a skill in get_toolbox's, with the
|
||||
AI apps that ran it so the next one knows what is untried. `add_nodes(use=…)` puts the use's
|
||||
runnable ways in order by their record on the canvas's base model (asked of the page only when
|
||||
one has a record). With the Gates' "custom nodes: only the ways'" (`ai_tools.json` `nodes`),
|
||||
`mcp_canvas_edit._allowed` refuses a custom node no way builds and the task did not pick.
|
||||
|
||||
`track_run(prompt_ids, task, wait_seconds)` attaches existing submissions from this server to
|
||||
an open task without running anything. IDs must exist in its queue/history; each is owned by
|
||||
one recorded run. Retrying an identical batch returns that run, not the task's newest run.
|
||||
|
||||
@@ -74,6 +74,7 @@ AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏
|
||||
|
||||
- 从上往下读,编号 1、2、3……;AI 连着查资料、连着改画布的几步折成一行(`ui_ai_chain.js`),点开看每一步。
|
||||
- 每一步下面写它做了什么(`ui_ai_step_body.js`),出的东西能直接去:图片进看图器、模型进模型页、画布改动能定位和撤销(`ui_ai_results.js`)。
|
||||
- 跑完的图下面是「这次效果:效果好 / 不太行」(`ui_ai_verdict.js`),再点一下收回。点了记进跑图记录,也告诉 AI;不点也行,挑图、收藏会自己记。跑图那一步还写着这次用到的做法。做法面板里每块做法和 skill 下面是它在这台电脑上按模型分的记录(`api/ai_evidence.py`)。
|
||||
- 图片上鼠标移上去有工具(`ui_ai_images.js`):「图片与参数」、「保存为…」、勾两张对比,还有 ✎ 标注。
|
||||
- 时间线和展台里的图先用缩略图(`/anomalous/output_thumbnail`),显示得比缩略图大时才换原图(`/anomalous/output_preview`:超大图换的是 4096 像素的预览),打开任务不用等大图。
|
||||
|
||||
|
||||
@@ -156,6 +156,8 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
|
||||
|
||||
**AI 改副本**(默认):AI 第一次改画布前,AMB 先把你正开着的工作流复制到一个以任务命名的新标签页(副本),之后 AI 的修改、读画布和运行都只在那一页;你切去别的标签页也没关系,它下次动手前 AMB 会先切回去。你原来的工作流一笔不动。AI 用 `new_workflow` / `open_workflow` 打开的标签页就直接算这个任务的。任务标题栏有 **AI 改副本 ▾**(点开可以换成 **改当前画布**,每种都写着是什么意思)和 **切到副本**(点一下切过去);**关卡** 窗口里的“AI 改画布时”设定新任务的默认做法。
|
||||
|
||||
**跑图记录(哪套组合在哪个模型上好用)**:AI 每次跑图,AMB 都记下这次用了哪些做法、哪些 skill、哪些第三方节点包,主模型是什么底模(比如 Illustrious、Anima)。效果好不好不用你专门去记:在挑图面板选中的图、收藏的图(存成工作流、搭配、提示词)算 **效果好**;挑图时没被选的算 **没被挑中**;跑完的图下面也有 **这次效果:效果好 / 不太行** 两个按钮,你点的那个最算数,还会告诉 AI(点“不太行”它就该换做法、设置或提示词再跑),再点一下收回。做法面板里每块做法和 skill 下面写着它的记录,比如“Illustrious:跑过 4 次,3 次效果好”;没跑过的写“这台电脑上还没跑过”,鼠标停在一行上能看到是哪些 AI 跑的。AI 开工时(`begin_task`)就能看到这份记录的摘要,加做法时也会告诉它这块做法的记录;同一种活有几块做法都能用时,AMB 先给它在画布主模型上记录最好的那块。所以下一个 AI,不管是 Claude、Gemini 还是 GPT,一看就知道什么在这台电脑上试过、效果怎样、哪里还没试。记录只有名字和次数,没有提示词;存在 ComfyUI 的 user 文件夹里(anomalous/ai_evidence.json,最近 2000 次)。**关卡** 窗口里的 **第三方节点** 选“只用做法里的”,AI 自己加节点时就只能用做法里那些搭好的组合,加上你给这个任务挑的;ComfyUI 自带的节点照常能用。
|
||||
|
||||
**隐私模式**:有些图和提示词不方便给 AI 看时,点任务标题栏的 **🔒**,这个任务就成了隐私任务。AMB 一张图都不发给 AI(放大看图、图片信息里的图、跑完附带的预览、你递给它的图、AI 软件里任务卡片上的图都一样),它只知道出了几张、成没成功。提示词原文和图片文件名换成 **[P1]**、**[img1]** 这样的代号:画布上节点的文字输入(按 ComfyUI 自己的节点定义认,第三方节点也一样;模型名、数字、选项照常给)、图片信息、提示词库、搭配、工作流的设置、模型的触发词和介绍、你自己改画布的记录都一样,同一段文字永远是同一个代号。AI 用代号去写提示词、跑图、填加载图片、放挑图面板,AMB 在工具执行前换回原文,所以画布和出图都是原样,你看到的也都是原样;时间线上记的是代号。要新提示词时,AI 会放一个 **提示词框**:写几条提示词(可以再加)、反向提示词,可以从图库选或把图拖进来当参考图,点 **交给 AI(只给代号)**;原文只存在 AMB 里,交出后框里显示代号和你写的原文。框上方按画布主模型的大小提醒你(来自 skill“写提示词·动作适合模型”)。AI 怎么在隐私任务里干活写在 skill“隐私模式·怎么干活”里,开工时会被指过去。隐私任务里 AI 只拿得到这一个 skill,做法面板的 skill 一栏也只显示它;普通任务正好反过来,只显示、只给普通的 skill。**关卡** 窗口里的“隐私模式”设新任务默认开不开;默认改成关时,原来按默认开着的任务仍然保持隐私。AI 的某次调用认不出属于哪个任务时,只要默认开着或者还有没做完的隐私任务,就按隐私处理。注意:任务做到一半才打开时,之前已经给过 AI 的收不回来;你自己在留言或 AI 聊天框里写的文字和贴的图,AMB 不拦(那是你主动给的);模型名字 AI 是看得到的。
|
||||
|
||||
**这次跑的节点结构**:每次运行那一步下面写着一行“底模 · 几个 LoRA · 尺寸 · 步数 · 几个节点”,点开有 **简单** 和 **完整** 两种看法(AMB 记得你上次选的)。简单:按每一段采样列出“用哪个模型加哪些 LoRA、提示词是什么、从空白画布还是从上一段的结果(放大多少)开始”,最后保存在哪;后一段和前一段一样的就写“和第 1 段一样”。完整:方块图,哪个底模、LoRA 和权重、正负提示词开头、尺寸、采样器设置、保存,方块之间的线标着类型。两种看法里点任意一项都能定位到画布上的节点。这张图是 AMB 按 ComfyUI 实际收到的工作流画的,不靠 AI 描述。上面的黄色小块是 **比上次改了什么**(比如“K采样器·步数:20 → 30”“新增「加载LoRA」”)。AI 讲解时可以用 `show_run_map` 一次把同一张图放上展台,不用自己拼。
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""The record of what works (api/ai_evidence.py): a run counts for the ways it still has, the skills
|
||||
fetched before it and the packs of its nodes, by its main model's base model; the user's picks,
|
||||
keeps and word say how it went; the AI is told it in begin_task, get_toolbox and add_nodes, a use's
|
||||
way that did best is picked first, and the user can let custom nodes in only by the ways.
|
||||
Temporary folders only."""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1]), str(PLUGIN / "tests")]
|
||||
|
||||
from api import ai_evidence, ai_gates, ai_panels, ai_skills, ai_task_runs, ai_tasks, mcp_canvas_edit, mcp_task_tools, reference_blocks # noqa: E402
|
||||
from api.mcp_tools import ToolError # noqa: E402
|
||||
from test_ai_panels import OUTPUT # noqa: E402 (a.png and b.png in a temporary output folder)
|
||||
from test_ai_tasks import isolate, ok # noqa: E402
|
||||
|
||||
# A LanPaint repaint on an Illustrious checkpoint, its model through a LoRA.
|
||||
PROMPT = {"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "Illustrious/wai.safetensors"}},
|
||||
"10": {"class_type": "LoraLoader", "inputs": {"model": ["4", 0], "clip": ["4", 1], "lora_name": "x.safetensors",
|
||||
"strength_model": 1.0, "strength_clip": 1.0}},
|
||||
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": "1girl", "clip": ["10", 1]}},
|
||||
"12": {"class_type": "LoadImage", "inputs": {"image": "in.png"}},
|
||||
"13": {"class_type": "LanPaint_ImageEncode", "inputs": {"image": ["12", 0], "vae": ["4", 2]}},
|
||||
"3": {"class_type": "LanPaint_KSampler", "inputs": {"model": ["10", 0], "positive": ["6", 0], "negative": ["6", 0],
|
||||
"latent_image": ["13", 0], "seed": 1, "steps": 20}},
|
||||
"14": {"class_type": "LanPaint_ImageDecode", "inputs": {"samples": ["3", 0], "vae": ["4", 2]}},
|
||||
"9": {"class_type": "SaveImage", "inputs": {"images": ["14", 0], "filename_prefix": "AMB"}}}
|
||||
INFO = {"LanPaint_KSampler": {"python_module": "custom_nodes.LanPaint"}, "KJThing": {"python_module": "custom_nodes.ComfyUI-KJNodes"},
|
||||
"KSampler": {"python_module": "nodes"}}
|
||||
|
||||
|
||||
class EvidenceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
isolate()
|
||||
for patcher in (mock.patch.object(ai_evidence.model_advice, "_main_base", side_effect=lambda name: "Illustrious" if "Illustrious" in name else "Anima"),
|
||||
mock.patch.object(ai_evidence, "_packs", side_effect=lambda types: ["LanPaint"] if "LanPaint_KSampler" in types else []),
|
||||
mock.patch.object(ai_evidence.kept_images, "kept_keys", return_value=set()),
|
||||
mock.patch.object(ai_task_runs.ai_run_watch, "vram", return_value=None), # no card asked
|
||||
mock.patch.object(ai_tasks.folder_paths, "get_output_directory", return_value=OUTPUT),
|
||||
mock.patch.object(ai_gates, "_tools_path", lambda: os.path.join(os.path.dirname(ai_evidence._path()), "ai_tools.json"))):
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
ai_tasks.record("Gemini", "add_nodes", {"use": "repaint", "want": "best"}, ok({"way": "inpaint_lanpaint"}), time.time())
|
||||
self.task = ai_tasks.active_task_id("Gemini")
|
||||
ai_skills.note_use("Gemini", {"skill": "prompt_pose", "for_model": None}, task_id=self.task)
|
||||
|
||||
def run_with(self, prompt, picture="a.png"):
|
||||
"""A run of the task with `prompt`, seen queued and then done with `picture`: its step's id."""
|
||||
step = ai_tasks.record("Gemini", "run_workflow", {}, ok({"queued": 1}), time.time(), task_id=self.task)
|
||||
ai_task_runs._estimate(self.task, step["id"], [0, "p1", prompt, {}, []])
|
||||
ai_task_runs._finish_run(self.task, step["id"], ["p1"], {"p1": {"status": {"completed": True}, "outputs": {
|
||||
"9": {"images": [{"filename": picture, "subfolder": "", "type": "output"}]}}}})
|
||||
return step["id"]
|
||||
|
||||
def test_a_run_counts_for_what_it_used_on_its_model(self):
|
||||
step_id = self.run_with(PROMPT)
|
||||
used = next(step for step in ai_tasks.get_task(self.task)["steps"] if step["id"] == step_id)["used"]
|
||||
self.assertEqual((used["model"], used["base"], used["packs"], used["skills"]), ("wai.safetensors", "Illustrious", ["LanPaint"], ["prompt_pose"]),
|
||||
"the model is followed from the sampler through the LoRA")
|
||||
self.assertEqual([way["name"] for way in used["ways"]], ["inpaint_lanpaint"])
|
||||
found = ai_evidence.tally()
|
||||
self.assertEqual(found["way"]["inpaint_lanpaint"]["Illustrious"]["runs"], 1)
|
||||
self.assertEqual(found["skill"]["prompt_pose"]["Illustrious"]["clients"], ["Gemini"])
|
||||
self.assertEqual(found["pack"]["LanPaint"]["Illustrious"]["good"], 0, "nobody said how it went yet")
|
||||
|
||||
def test_a_way_taken_off_the_canvas_does_not_count(self):
|
||||
plain = {key: node for key, node in PROMPT.items() if not node["class_type"].startswith("LanPaint")}
|
||||
plain["3"] = {"class_type": "KSampler", "inputs": {"model": ["10", 0], "positive": ["6", 0], "negative": ["6", 0], "latent_image": ["6", 0]}}
|
||||
self.run_with(plain)
|
||||
self.assertNotIn("inpaint_lanpaint", ai_evidence.tally()["way"])
|
||||
self.assertIn("prompt_pose", ai_evidence.tally()["skill"], "the advice it went by still counts")
|
||||
|
||||
def test_picks_keeps_and_the_users_word_say_how_it_went(self):
|
||||
first, second = self.run_with(PROMPT, "a.png"), self.run_with(PROMPT, "b.png")
|
||||
shown = ai_panels.show("Gemini", "Which?", [{"type": "picks", "id": "p", "question": "Which?",
|
||||
"items": [{"id": "a", "image": "a.png"}, {"id": "b", "image": "b.png"}]}], task_id=self.task)
|
||||
step = next(item for item in ai_tasks.get_task(self.task)["steps"] if item.get("panel", {}).get("id") == shown["id"])
|
||||
ai_panels.answer(self.task, step["id"], "p", choice="a")
|
||||
count = ai_evidence.tally()["way"]["inpaint_lanpaint"]["Illustrious"]
|
||||
self.assertEqual((count["runs"], count["good"], count["passed"]), (2, 1, 1), "picked: good; passed over: not chosen")
|
||||
ai_evidence.set_verdict(self.task, first, "bad")
|
||||
count = ai_evidence.tally()["way"]["inpaint_lanpaint"]["Illustrious"]
|
||||
self.assertEqual((count["good"], count["bad"]), (0, 1), "the user's own word goes over a pick")
|
||||
told = [item for item in ai_tasks.get_task(self.task)["steps"] if item["tool"] == "user_verdict"]
|
||||
self.assertTrue(told and not told[0]["delivered"] and "did not turn out well" in told[0]["text"], "the AI is told")
|
||||
ai_evidence.set_verdict(self.task, first, None)
|
||||
with mock.patch.object(ai_evidence.kept_images, "kept_keys", return_value={("", "b.png")}):
|
||||
count = ai_evidence.tally()["way"]["inpaint_lanpaint"]["Illustrious"]
|
||||
self.assertEqual((count["good"], count["bad"], count["passed"]), (2, 0, 0), "kept in the gallery: good")
|
||||
with self.assertRaises(ValueError):
|
||||
ai_evidence.set_verdict(self.task, second, "great")
|
||||
|
||||
def test_the_ai_reads_it_at_a_glance(self):
|
||||
self.assertNotIn("record", mcp_task_tools.begin_task({"client": "Gemini", "task": self.task}, "repaint"), "nothing ran yet")
|
||||
step_id = self.run_with(PROMPT)
|
||||
ai_evidence.set_verdict(self.task, step_id, "good")
|
||||
begun = mcp_task_tools.begin_task({"client": "Gemini", "task": self.task}, "repaint")
|
||||
self.assertIn("inpaint_lanpaint (way) Illustrious: 1 good of 1", begun["record"])
|
||||
self.assertLessEqual(len(begun["record"]), ai_evidence.OVERVIEW_CHARS + 100, "a few words in begin_task")
|
||||
with mock.patch.object(mcp_task_tools, "_canvas_model", return_value=None):
|
||||
fetched = mcp_task_tools.get_toolbox({"client": "Gemini", "task": self.task}, skill="prompt_pose")
|
||||
self.assertIn("Illustrious 1 runs: 1 good", fetched["record"])
|
||||
self.assertIn("(run by Gemini)", fetched["record"], "which AI ran it, for the next one to go on from")
|
||||
self.assertIn("record", mcp_task_tools.get_toolbox({"client": "Gemini", "task": self.task}))
|
||||
|
||||
def test_the_way_that_did_best_on_the_model_comes_first(self):
|
||||
asked = []
|
||||
self.assertEqual(ai_evidence.order("way", ["a", "b"], lambda: asked.append(1)), ["a", "b"])
|
||||
self.assertFalse(asked, "nothing ran: the canvas is not even asked")
|
||||
ai_evidence.set_verdict(self.task, self.run_with(PROMPT), "good")
|
||||
self.assertEqual(ai_evidence.order("way", ["inpaint_basic", "inpaint_lanpaint"], lambda: "Illustrious"), ["inpaint_lanpaint", "inpaint_basic"])
|
||||
self.assertEqual(ai_evidence.order("way", ["inpaint_basic", "inpaint_lanpaint"], lambda: "Anima"), ["inpaint_basic", "inpaint_lanpaint"],
|
||||
"on another model it is untried")
|
||||
info = {kind: {} for block in ("inpaint_basic", "inpaint_crop_stitch") for kind in reference_blocks.BLOCKS[block]["packs"]}
|
||||
self.assertEqual(reference_blocks.pick("repaint", None, info), "inpaint_crop_stitch", "by rank")
|
||||
self.assertEqual(reference_blocks.pick("repaint", None, info, order=lambda names: list(reversed(names))), "inpaint_basic")
|
||||
|
||||
def test_custom_nodes_only_by_the_ways_when_the_user_says_so(self):
|
||||
ctx = {"client": "Gemini", "task": self.task}
|
||||
with mock.patch.object(mcp_canvas_edit, "object_info", return_value=INFO):
|
||||
mcp_canvas_edit._allowed(ctx, ["KJThing"])
|
||||
ai_gates.set_node_rule("ways")
|
||||
with self.assertRaisesRegex(ToolError, "only by the ready-made ways"):
|
||||
mcp_canvas_edit._allowed(ctx, ["KJThing"])
|
||||
mcp_canvas_edit._allowed(ctx, ["LanPaint_KSampler", "KSampler"]) # a way's node, ComfyUI's own
|
||||
ai_gates.set_node_rule("any")
|
||||
with self.assertRaises(ValueError):
|
||||
ai_gates.set_node_rule("some")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,10 +14,11 @@ from unittest import mock
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import ai_answer_wait, ai_gates, ai_panels, ai_tasks, mcp_task_tools # noqa: E402
|
||||
from api import ai_answer_wait, ai_evidence, ai_gates, ai_panels, ai_tasks, mcp_task_tools # noqa: E402
|
||||
|
||||
TMP = tempfile.mkdtemp(prefix="amb_ai_panels_")
|
||||
OUTPUT = tempfile.mkdtemp(prefix="amb_ai_panels_out_")
|
||||
EVIDENCE = os.path.join(tempfile.mkdtemp(prefix="amb_ai_panels_evidence_"), "ai_evidence.json")
|
||||
Path(OUTPUT, "a.png").write_bytes(b"png")
|
||||
Path(OUTPUT, "b.png").write_bytes(b"png")
|
||||
|
||||
@@ -29,6 +30,10 @@ def isolate():
|
||||
ai_tasks._waiting[0] = None
|
||||
for name in os.listdir(TMP):
|
||||
os.remove(os.path.join(TMP, name))
|
||||
ai_evidence._path = lambda: EVIDENCE
|
||||
ai_evidence._cache["path"] = ""
|
||||
if os.path.exists(EVIDENCE):
|
||||
os.remove(EVIDENCE)
|
||||
|
||||
|
||||
CHOICES = {"type": "choices", "id": "pick", "question": "选哪张?", "options": [{"id": "a", "label": "左边"}, {"id": "b", "label": "右边"}]}
|
||||
|
||||
@@ -13,13 +13,14 @@ from unittest import mock
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import action_catalog, activity_log, ai_callers, ai_panels, ai_task_changes, ai_task_runs, ai_tasks, ai_tasks_routes, mcp_task_tools # noqa: E402
|
||||
from api import action_catalog, activity_log, ai_callers, ai_evidence, ai_panels, ai_task_changes, ai_task_runs, ai_tasks, ai_tasks_routes, mcp_task_tools # noqa: E402
|
||||
from api.mcp_server import TOOLS # noqa: E402
|
||||
from api.ai_task_handoff import AFTER_DONE, REPLY_FRESH
|
||||
|
||||
TMP = tempfile.mkdtemp(prefix="amb_ai_tasks_")
|
||||
LOG = tempfile.mkdtemp(prefix="amb_ai_activity_")
|
||||
OUTPUT = tempfile.mkdtemp(prefix="amb_ai_output_")
|
||||
EVIDENCE = os.path.join(tempfile.mkdtemp(prefix="amb_ai_evidence_"), "ai_evidence.json")
|
||||
|
||||
|
||||
def isolate():
|
||||
@@ -35,6 +36,10 @@ def isolate():
|
||||
activity_log.add_listener(ai_task_changes.note_activity)
|
||||
if os.path.exists(activity_log.log_path()):
|
||||
os.remove(activity_log.log_path())
|
||||
ai_evidence._path = lambda: EVIDENCE # the record of what works (beside the tasks, which TMP's parent is not)
|
||||
ai_evidence._cache["path"] = ""
|
||||
if os.path.exists(EVIDENCE):
|
||||
os.remove(EVIDENCE)
|
||||
|
||||
|
||||
def ok(data):
|
||||
|
||||
+2
-1
@@ -14,7 +14,7 @@ PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import ( # noqa: E402
|
||||
ai_gates, ai_panels, ai_tasks, lora_info, mcp_actions, mcp_apps, mcp_bridge, mcp_canvas_edit, mcp_model_use, mcp_server,
|
||||
ai_evidence, ai_gates, ai_panels, ai_tasks, lora_info, mcp_actions, mcp_apps, mcp_bridge, mcp_canvas_edit, mcp_model_use, mcp_server,
|
||||
mcp_tools,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ ai_tasks.tasks_dir = lambda: _AI_TASKS
|
||||
ai_tasks._tasks = None
|
||||
_GATES = tempfile.mkdtemp(prefix="amb_mcp_gates_")
|
||||
ai_gates._path = lambda: os.path.join(_GATES, "ai_gates.json")
|
||||
ai_evidence._path = lambda: os.path.join(_GATES, "ai_evidence.json")
|
||||
|
||||
|
||||
def meta(version=MODERN):
|
||||
|
||||
@@ -276,6 +276,8 @@ class AddBlockTests(unittest.TestCase):
|
||||
self.assertEqual(payload["roles"], {"sampler": {"denoise": 0.45}}, "the sampler's strength set with it")
|
||||
self.assertEqual(payload["nodes"]["picture"]["inputs"]["image"], "a.png")
|
||||
self.assertIn("set_prompt it to what belongs in the area", answer["next"], "what to do next with this block")
|
||||
self.assertEqual(answer["way"], "inpaint_crop_stitch", "the step keeps it: its runs count for it (ai_evidence.py)")
|
||||
self.assertNotIn("record", answer, "it never ran here")
|
||||
|
||||
def test_a_control_block_asks_the_page_for_the_model_and_copies_the_picture_to_input(self):
|
||||
answers = {"block_model": {"model": "noob.safetensors", "loras": []}, "add_nodes": {"nodes": {}, "reaches_output": True}}
|
||||
|
||||
@@ -1442,6 +1442,30 @@ export const i18n = {
|
||||
aiSkillsHeading: "Skill(给 AI 的建议)",
|
||||
aiSkillsNote: "做法是 AMB 替 AI 搭节点;skill 是 AI 遇到这类活时自己去取的建议,不搭任何节点,你的原话优先。关掉的 AI 就拿不到。",
|
||||
aiSkillsNotePrivate: "这个任务开着隐私模式:AI 只拿得到下面这个 skill,照它用提示词框和代号干活;普通的 skill 在隐私模式关掉后才会出现。",
|
||||
aiRecordNote: "每块做法和 skill 下面是它在这台电脑上的跑图记录,按模型分:跑过几次、几次效果好。你在挑图面板选图、收藏图,或在跑图下面点“效果好 / 不太行”,都会自动记上;哪个 AI 来干活都能看到,同一种活 AMB 会先给它记录最好的做法。",
|
||||
aiRecordNone: "这台电脑上还没跑过",
|
||||
aiRecordLine: "{base}:跑过 {runs} 次{parts}",
|
||||
aiRecord_good: ",{n} 次效果好",
|
||||
aiRecord_bad: ",{n} 次不太行",
|
||||
aiRecord_passed: ",{n} 次没被挑中",
|
||||
aiRecord_failed: ",{n} 次出错",
|
||||
aiRecordOther: "其他模型",
|
||||
aiRecordBy: "跑过的 AI:{names}",
|
||||
aiRunWays: "这次用到的做法:{names}",
|
||||
aiVerdictAsk: "这次效果:",
|
||||
aiVerdictKept: "已记下:",
|
||||
aiVerdict_good: "效果好",
|
||||
aiVerdict_bad: "不太行",
|
||||
aiVerdictHint_good: "记一笔:这次用的做法、skill 和节点包在这个模型上效果好。也会告诉 AI。",
|
||||
aiVerdictHint_bad: "记一笔:这次效果不好。也会告诉 AI,让它换个做法、设置或提示词再跑。",
|
||||
aiVerdictTakeBack: "再点一下收回",
|
||||
aiVerdictSaid_good: "你说这次跑图效果好",
|
||||
aiVerdictSaid_bad: "你说这次跑图不太行",
|
||||
aiTool_user_verdict: "你评价了一次跑图",
|
||||
aiGate_nodes: "第三方节点",
|
||||
aiGateWhy_nodes: "AI 自己往画布上加节点时,第三方节点包的节点能不能随便用。选“只用做法里的”,它就只能用做法面板里那些搭好的组合,加上你给这个任务挑的;ComfyUI 自带的节点照常能用。",
|
||||
aiNodeRule_any: "随便用",
|
||||
aiNodeRule_ways: "只用做法里的",
|
||||
modelVersionsOpen: "展开这个模型的 {count} 个版本(来自同一个 Civitai 模型页)",
|
||||
modelVersionsFold: "收起,只留图钉那个版本",
|
||||
modelVersionsPin: "收起时显示这个版本",
|
||||
@@ -4951,6 +4975,30 @@ export const i18n = {
|
||||
aiSkillsHeading: "Skills (advice for the AI)",
|
||||
aiSkillsNote: "A way is nodes AMB builds for the AI; a skill is advice the AI fetches when that kind of job comes up. It builds nothing, and your own words come first. The AI can't fetch the ones turned off.",
|
||||
aiSkillsNotePrivate: "This task is in privacy mode: the AI gets only the skill below and works with the prompt box and tokens as it says. The other skills show again once privacy mode is off.",
|
||||
aiRecordNote: "Under each way and skill is how it did in the runs on this computer, by model: how often it ran and how often it turned out well. Picking pictures in a picks panel, keeping pictures, or \"Turned out well / Not quite\" under a run note it for you; any AI that works here sees it, and for a kind of job Anomalous hands it the way with the best record first.",
|
||||
aiRecordNone: "Not run on this computer yet",
|
||||
aiRecordLine: "{base}: {runs} runs{parts}",
|
||||
aiRecord_good: ", {n} turned out well",
|
||||
aiRecord_bad: ", {n} not quite",
|
||||
aiRecord_passed: ", {n} not picked",
|
||||
aiRecord_failed: ", {n} failed",
|
||||
aiRecordOther: "Other models",
|
||||
aiRecordBy: "Run by: {names}",
|
||||
aiRunWays: "Ways used in this run: {names}",
|
||||
aiVerdictAsk: "How did it turn out?",
|
||||
aiVerdictKept: "Noted:",
|
||||
aiVerdict_good: "Turned out well",
|
||||
aiVerdict_bad: "Not quite",
|
||||
aiVerdictHint_good: "Note it: this run's ways, skills and node packs did well on this model. The AI is told too.",
|
||||
aiVerdictHint_bad: "Note it: this run did not turn out well. The AI is told too, to change the way, a setting or the prompt.",
|
||||
aiVerdictTakeBack: "Click again to take it back",
|
||||
aiVerdictSaid_good: "You said this run turned out well",
|
||||
aiVerdictSaid_bad: "You said this run was not quite right",
|
||||
aiTool_user_verdict: "You judged a run",
|
||||
aiGate_nodes: "Custom nodes",
|
||||
aiGateWhy_nodes: "Whether the AI may add nodes of any custom node pack when it adds nodes itself. With \"Only the ways'\" it may use only the ready-made combinations of the Ways panel and what you picked for the task; ComfyUI's built-in nodes stay allowed.",
|
||||
aiNodeRule_any: "Any",
|
||||
aiNodeRule_ways: "Only the ways'",
|
||||
modelVersionsOpen: "Show this model's {count} versions (from one Civitai model page)",
|
||||
modelVersionsFold: "Fold, keeping the pinned version",
|
||||
modelVersionsPin: "Show this version when folded",
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* with, so the AI is not offered it; how it was tried (the card, a run's time) and the models it recommends
|
||||
* (api/block_models.json), each here or a button downloading it, link and SHA256 from the list.
|
||||
* Under them the skills (api/ai_skills.py): advice the AI fetches for a kind of job, each with its
|
||||
* switch too; for a private task only the private task's skill, as that is all its AI gets. GET /anomalous/ai_blocks, POST {name, on} or {skill, on}. Drawn when shown,
|
||||
* switch too; for a private task only the private task's skill, as that is all its AI gets. Each way
|
||||
* and skill says how it did in the runs here, by model (api/ai_evidence.py), or that it never ran here.
|
||||
* GET /anomalous/ai_blocks, POST {name, on} or {skill, on}. Drawn when shown,
|
||||
* the list read again each time the view is opened.
|
||||
*/
|
||||
|
||||
@@ -60,6 +62,26 @@ function tested(block) {
|
||||
return runs.length ? el('div', 'anomalous-ai-block-source', t('aiBlocksTested', { gpu: record.gpu, vram: record.vram_gb, runs: runs.join(' · ') })) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a way or skill did in the runs here (api/ai_evidence.py), by the base model of the run's model:
|
||||
* how often it ran, how many turned out well or not, which AI apps ran it; or that it never ran here.
|
||||
*/
|
||||
function record(bases) {
|
||||
const box = el('div', 'anomalous-ai-block-record');
|
||||
const lines = Object.entries(bases || {}).sort((a, b) => b[1].runs - a[1].runs);
|
||||
if (!lines.length) {
|
||||
box.append(el('div', 'anomalous-ai-block-record-none', t('aiRecordNone')));
|
||||
return box;
|
||||
}
|
||||
for (const [base, count] of lines) {
|
||||
const parts = ['good', 'bad', 'passed', 'failed'].filter(key => count[key]).map(key => t(`aiRecord_${key}`, { n: count[key] }));
|
||||
const line = el('div', 'anomalous-ai-block-record-line', t('aiRecordLine', { base: base || t('aiRecordOther'), runs: count.runs, parts: parts.join('') }));
|
||||
if (count.clients?.length) line.title = t('aiRecordBy', { names: count.clients.join(', ') });
|
||||
box.append(line);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** The models the block recommends: each one here, or a button that downloads it (its link and SHA256 from the list). */
|
||||
function recommended(block, owner) {
|
||||
if (!block.recommended?.length) return null;
|
||||
@@ -118,7 +140,7 @@ function blockRow(block, owner, redraw) {
|
||||
const head = el('div', 'anomalous-ai-block-head');
|
||||
head.append(el('span', 'anomalous-ai-block-title', words(block.title)),
|
||||
el('span', `anomalous-ai-block-state${block.usable ? ' is-ready' : ''}`, t(block.usable ? 'aiBlocksReady' : 'aiBlocksMissing')));
|
||||
body.append(head, el('div', 'anomalous-ai-block-about', words(block.about)));
|
||||
body.append(head, el('div', 'anomalous-ai-block-about', words(block.about)), record(shown.record?.way?.[block.name]));
|
||||
if (!block.usable) body.append(lacks(block, owner));
|
||||
// Where it came from, how it was tried, its models: folded, the row is a line to read.
|
||||
const more = el('details', 'anomalous-ai-block-more');
|
||||
@@ -141,7 +163,7 @@ function skillsGroup(skills, redraw, isPrivate) {
|
||||
const body = el('div', 'anomalous-ai-block-body');
|
||||
const head = el('div', 'anomalous-ai-block-head');
|
||||
head.append(el('span', 'anomalous-ai-block-title', words(skill.title)));
|
||||
body.append(head, el('div', 'anomalous-ai-block-about', words(skill.about)));
|
||||
body.append(head, el('div', 'anomalous-ai-block-about', words(skill.about)), record(shown.record?.skill?.[skill.name]));
|
||||
row.append(body, onSwitch(skill, { skill: skill.name }, redraw));
|
||||
group.append(row);
|
||||
}
|
||||
@@ -159,7 +181,8 @@ function listBox(owner, redraw, isPrivate) {
|
||||
return body;
|
||||
}
|
||||
const offered = shown.blocks.filter(block => block.usable && block.on).length;
|
||||
body.append(el('div', 'anomalous-ai-blocks-note', t('aiBlocksNote', { count: offered, total: shown.blocks.length })));
|
||||
body.append(el('div', 'anomalous-ai-blocks-note', t('aiBlocksNote', { count: offered, total: shown.blocks.length })),
|
||||
el('div', 'anomalous-ai-blocks-note', t('aiRecordNote')));
|
||||
if (shown.note) body.append(el('div', 'anomalous-ai-blocks-note is-error', shown.note));
|
||||
for (const job of [...shown.jobs, ...new Set(shown.blocks.map(block => block.job))]) {
|
||||
const blocks = shown.blocks.filter(block => block.job === job);
|
||||
|
||||
@@ -147,6 +147,8 @@ export async function openGates() {
|
||||
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),
|
||||
el('p', 'anomalous-ai-gates-note', t('aiGatesNote')));
|
||||
aiWindow(t('aiGates'), body);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* What a step of an AI task made, and where it leads (ui_ai_workbench.js draws the task): the
|
||||
* pictures (the viewer), its models (their page), the combo, the spoken audio (▶), the canvas
|
||||
* pictures (the viewer) and, under a finished run's, the user's word on it (ui_ai_verdict.js),
|
||||
* its models (their page), the combo, the spoken audio (▶), the canvas
|
||||
* change (found on the canvas, undone while it can be), the scan; and a run's progress while it
|
||||
* goes with its stop button and size warnings (ui_ai_run_live.js, kept live by
|
||||
* updateRunProgress from the server's events). The task's Results view (ui_ai_outputs.js) uses
|
||||
@@ -22,6 +23,7 @@ import { canvasChanges } from './ui_canvas_changes.js';
|
||||
import { estimateBox, runProgress } from './ui_ai_run_live.js';
|
||||
import { runMapBox } from './ui_ai_run_map.js';
|
||||
import { iconButton } from './ui_ai_icons.js';
|
||||
import { verdictRow } from './ui_ai_verdict.js';
|
||||
|
||||
export const shared = { owner: null, taskId: null, entries: new Map(), player: null, openMap: null }; // openMap: ui_ai_workbench.js
|
||||
|
||||
@@ -115,6 +117,7 @@ export function resultLinks(step, changed = () => {}) {
|
||||
if (shape) box.append(shape);
|
||||
const pictures = (step.results || []).filter(result => result.type === 'image');
|
||||
if (pictures.length) box.append(imageResults(pictures, shared.owner));
|
||||
if (step.tool === 'run_workflow' && step.state === 'done' && pictures.length && shared.taskId) box.append(verdictRow(step, shared.taskId));
|
||||
const links = el('div', 'anomalous-ai-links');
|
||||
const note = el('span', 'anomalous-ai-link-note');
|
||||
for (const result of step.results || []) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* What a step of the AI workbench's timeline says under its title, by what it is (ui_ai_workbench.js
|
||||
* 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, privacy 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),
|
||||
* Anomalous, pictures handed over, a stop, the canvas mode, privacy mode, their word on a run, 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), a
|
||||
* skill the AI went by (api/ai_skills.py) and, under a run, the skills fetched before it, or
|
||||
* skill the AI went by (api/ai_skills.py) and, under a run, the ways it used (api/ai_evidence.py)
|
||||
* and the skills fetched before it, or
|
||||
* the AI's note laid out (a long one folded until asked).
|
||||
*/
|
||||
|
||||
@@ -115,10 +116,13 @@ export function stepBody(step, ctx) {
|
||||
case 'user_privacy': return [said(t(step.privacy ? 'aiPrivacyChoseOn' : 'aiPrivacyChoseOff')), deliveryState(step)];
|
||||
case 'outside_files': return outsideFiles(step, ctx.owner);
|
||||
case 'log_outside': return outsideLog(step);
|
||||
case 'user_verdict': return [said(t(`aiVerdictSaid_${step.verdict}`)), deliveryState(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(' · ') }))] : []),
|
||||
const ways = (step.used?.ways || []).map(way => words(way.title));
|
||||
return [...(ways.length ? [el('div', 'anomalous-ai-skill-used', t('aiRunWays', { names: ways.join(' · ') }))] : []),
|
||||
...(used.length ? [el('div', 'anomalous-ai-skill-used', t('aiRunSkills', { names: [...new Set(used)].join(' · ') }))] : []),
|
||||
...(step.text ? [note(step.text)] : [])];
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The user's word on a finished run (api/ai_evidence.py), under its pictures: turned out well, or
|
||||
* not; a click again takes it back. It goes into the record of what works here (the ways, skills and
|
||||
* node packs the run used, by its model, shown in the Ways view) and to the AI as their message.
|
||||
* POST /anomalous/ai_tasks/{task}/runs/{step}/verdict {verdict: good | bad | null}; the task's update
|
||||
* draws it chosen.
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
import { icon } from './ui_ai_icons.js';
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The two buttons of run `step` in task `taskId`, the one the user chose marked. */
|
||||
export function verdictRow(step, taskId) {
|
||||
const row = el('div', 'anomalous-ai-verdict');
|
||||
row.append(el('span', 'anomalous-ai-verdict-ask', t(step.verdict ? 'aiVerdictKept' : 'aiVerdictAsk')));
|
||||
for (const verdict of ['good', 'bad']) {
|
||||
const chosen = step.verdict === verdict;
|
||||
const node = el('button', `anomalous-ai-verdict-pick is-${verdict}${chosen ? ' is-chosen' : ''}`);
|
||||
node.type = 'button';
|
||||
node.setAttribute('aria-pressed', String(chosen));
|
||||
node.title = t(chosen ? 'aiVerdictTakeBack' : `aiVerdictHint_${verdict}`);
|
||||
node.append(icon(verdict === 'good' ? 'check' : 'close', 13), el('span', '', t(`aiVerdict_${verdict}`)));
|
||||
node.onclick = async () => {
|
||||
row.querySelectorAll('button').forEach(item => { item.disabled = true; });
|
||||
try {
|
||||
const response = await fetch(`/anomalous/ai_tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(step.id)}/verdict`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ verdict: chosen ? null : verdict }),
|
||||
});
|
||||
const answer = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(answer.error || response.statusText);
|
||||
} catch (error) {
|
||||
row.querySelectorAll('button').forEach(item => { item.disabled = false; });
|
||||
await anomalousAlert(error.message);
|
||||
}
|
||||
};
|
||||
row.append(node);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
@@ -474,6 +474,27 @@
|
||||
.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; }
|
||||
|
||||
/* The user's word on a finished run (ui_ai_verdict.js), under its pictures. */
|
||||
.anomalous-ai-verdict { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; }
|
||||
.anomalous-ai-verdict-ask { color: var(--amb-text-soft); font-size: 12.5px; }
|
||||
.anomalous-ai-verdict-pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 9px;
|
||||
border: 1px solid var(--amb-border-strong);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--amb-text-soft);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anomalous-ai-verdict-pick:hover { color: var(--amb-text-main); }
|
||||
.anomalous-ai-verdict-pick:disabled { cursor: progress; opacity: 0.7; }
|
||||
.anomalous-ai-verdict-pick.is-good.is-chosen { border-color: var(--amb-ok); background: color-mix(in srgb, var(--amb-ok) 16%, transparent); color: var(--amb-text-main); }
|
||||
.anomalous-ai-verdict-pick.is-bad.is-chosen { border-color: var(--amb-warn); background: color-mix(in srgb, var(--amb-warn) 16%, transparent); color: var(--amb-text-main); }
|
||||
.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; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* The side column's Ways view (ui_ai_blocks.js): the ready-made blocks by job, each with its
|
||||
words, what it lacks (greyed) and its switch for the AI. */
|
||||
words, its record here, what it lacks (greyed) and its switch for the AI. */
|
||||
|
||||
.anomalous-ai-blocks { display: flex; flex-direction: column; gap: 14px; }
|
||||
.anomalous-ai-blocks-note { color: var(--amb-text-soft); font-size: 12.5px; line-height: 1.5; }
|
||||
@@ -34,6 +34,9 @@
|
||||
.anomalous-ai-block-state.is-ready { background: color-mix(in srgb, var(--amb-ok) 18%, transparent); }
|
||||
.anomalous-ai-block-about { color: var(--amb-text-main); font-size: 12.5px; line-height: 1.5; }
|
||||
.anomalous-ai-block-source { color: var(--amb-text-soft); font-size: 11.5px; }
|
||||
/* How a way or skill did in the runs here (api/ai_evidence.py), a line a base model. */
|
||||
.anomalous-ai-block-record { display: flex; flex-direction: column; gap: 1px; color: var(--amb-text-main); font-size: 12px; line-height: 1.5; }
|
||||
.anomalous-ai-block-record-none { color: var(--amb-text-dim); }
|
||||
.anomalous-ai-block-lacks { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.anomalous-ai-block-lack {
|
||||
padding: 2px 8px;
|
||||
|
||||
Reference in New Issue
Block a user