Make the training network a full view over the AI page
The side switch's training network now opens over the whole AI workbench: numbers on top, a filter by kind, a graph or a table with a cell per pair, and a detail column listing each run of a pair (model, AI app, judgement, pictures, task), a base model's models and the training rounds. New GET /anomalous/ai_training/runs; the network data adds each model's runs and the rounds. Not yet verified in the browser: Esc after routing it through the browser's back buttons. Full test run pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6647b059e4
commit
49d05cfa69
+12
-6
@@ -433,12 +433,18 @@ covered by the `styles.css` manifest.
|
||||
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. `ui_ai_network.js` is the
|
||||
side column's training network (GET /anomalous/ai_training): base models left, ways, skills and
|
||||
node packs right, a line for each pair that ran, coloured by how it turned out (the user's word
|
||||
first, else the AI's, dashed), drawn over the two columns from where their nodes are (a
|
||||
ResizeObserver redraws it); a node or line picked shows its record, last pictures and untried
|
||||
pairs, and `ui_ai_training_setup.js` is the form starting a round (POST), ending in the sentence to
|
||||
paste to the AI app. For a run the AI did not wait
|
||||
training network (GET /anomalous/ai_training), opened by the side switch over the whole AI page
|
||||
(closed by its back button, Esc or another view): its head, the numbers (runs, judged, pairs that
|
||||
could be tried, rounds), which items to show, the drawing and the detail column. Its data
|
||||
(`ui_ai_network_data.js`): the items, base models, pairs that ran and how each turned out (the
|
||||
user's word first, else the AI's), the pairs that could be tried. `ui_ai_network_graph.js` draws it
|
||||
as a graph (base models left, items right, a line per pair over the two columns from where their
|
||||
nodes are, redrawn by a ResizeObserver released on close) or as a table (a cell per pair, "+"
|
||||
where it could be tried); `ui_ai_network_detail.js` shows what is picked: the rounds when nothing
|
||||
is (each opens its task), a base model's models here with their runs, an item's base models, a
|
||||
pair's counts and each run (GET /anomalous/ai_training/runs: when, model, AI app, how it was judged,
|
||||
pictures, task). `ui_ai_training_setup.js` is the form starting a round (POST), in place of the
|
||||
drawing, ending in the sentence to paste to the AI app. For a run the AI did not wait
|
||||
for (`ai_gates.run_wait` "call") the server marks the ended run `call_ai` until the AI is told,
|
||||
and the lines over the message box are about it meanwhile. `ui_ai_side.js` is the
|
||||
stage, the column on the right where the AI's panels are shown (the timeline keeps a card per
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
## v1.63.0 — unreleased
|
||||
|
||||
- 训练网也能认识 AI 实际使用的外部 skill:AI 先用 `log_outside` 报出 skill 短名称,同一任务之后的运行会关联到它,图上标明“外部 skill · AI 自报”。这不把外部 skill 当作 AMB 自己能调用的做法,也不代替你的评分。
|
||||
- The training network can show an external skill an AI actually used: the AI declares its short identifier with `log_outside` before its runs; later runs of that task link to it, labelled as AI-reported. It is not a runnable AMB skill or a user rating.
|
||||
|
||||
- 新增 **训练网络**:AI 工作台右栏多了一个视图,把跑图记录画成一张网:左边是你的模型(按底模分),右边是做法、skill 和跑过的节点包,一起跑过的连一条线,颜色就是效果(绿好、黄一般、红不太行、灰还没人评,短虚线是只有 AI 评过),线越粗跑得越多;点一个点或一条线看详情和最近的图,选中一个点时虚线连着还没试过的组合。**开一轮训练**:选一个 skill(AI 自己写和照建议写,并排比)或一种活的几块做法,选最多 4 个模型和每组几张,交给 AI;AI 按计划一组组跑,AMB 自己对上每次跑图属于哪一组、告诉它下一组,一个模型跑完它就把图并排放进挑图面板、先推荐一张再让你挑。AI 的推荐和你的挑选分开记进网络,你的算数在先;你没空挑时,关卡里设成“按推荐的继续”,它的推荐就作数(记成 AI 判的)。
|
||||
- New: **the training network**, a new view in the AI workbench's side column that draws the run record as a graph: your models on the left (by base model), the ways, skills and node packs that ran on the right, a line for each pair that ran together, its colour how it turned out (green good, yellow so-so, red not quite, grey not judged yet, short dashes when only the AI judged it) and its width how often. A click on a node or a line shows the details and the last pictures; with a node picked, dashed lines lead to what it has not tried yet. **Start a training round**: pick a skill (the AI's own prompts and those written with the advice, side by side) or the ways of one job, up to 4 models and how many pictures each, and hand it to the AI; it runs the trials in turn, Anomalous works out which trial each run was and tells it the next, and once a model's trials are done it puts them side by side in a picks panel, picks one itself, and you pick. Its pick and yours are recorded apart, yours counted first; if you have no time to pick, the Gates window's "go on with the recommended" lets its pick stand (recorded as the AI's).
|
||||
- 训练网络的记录默认 **不给 AI 看**:AI 只按现成的做法和 skill 做(AMB 照样先给它效果最好的那块做法),省得小模型被数字带偏。想让 AI 看,就在关卡窗口把 **训练网记录** 改成“给看”;你自己开的训练轮里,它总能看到。训练网络视图上方写着 AI 现在看不看得到。
|
||||
- The training network's record is **hidden from the AI** by default: it goes by the ready-made ways and skills alone (Anomalous still gives it the way that did best), so a small model is not led astray by numbers. To let the AI see it, set **Training network record** to "Shown" in the Gates window; in a training round you start it always sees it. The top of the training network view says whether the AI sees it now.
|
||||
- **训练网络改成大视图**:右栏的“训练网络”按钮现在打开一个铺满整个 AI 工作台的页面。上面一排数字(跑过几次、你评过、AI 评过、还能试几组、几轮训练),可以只看做法、skill 或节点包;有 **连线图** 和 **表格** 两种看法,表格里每格一对,3/4 是跑了 4 次 3 次效果好,+ 是还能试的组合。右边详情更全:选一个底模,列出你这种模型各跑过几次;选一对,列出每次跑图的时间、模型、哪个 AI、你和 AI 怎么评的、图,还能打开当时的任务;什么都没选时列出开过的训练轮和进度。
|
||||
- **The training network is now a full view**: its button in the side switch opens a page over the whole AI workbench, with the numbers on top (runs, judged by you, judged by the AI, pairs to try, training rounds), a filter for ways, skills or node packs, and two drawings: the **graph** and a **table** with a cell per pair (3/4: 4 runs, 3 turned out well; +: a pair to try). The detail column shows more: a base model lists your models of that kind and how often each ran; a pair lists each run with when, which model and AI, how you and the AI judged it, its pictures and a link to its task; with nothing picked it lists the training rounds and how far they got.
|
||||
- 新增 **跑图记录**: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。所有工具的结果出门前都过同一道关,测试会在每个工具的结果里埋“暗号”提示词和图片名,确认一个都漏不出去。
|
||||
|
||||
+72
-14
@@ -29,6 +29,7 @@ MAX_RUNS = 2000
|
||||
MAX_PICTURES = 8
|
||||
MAX_BASES = 4 # base models in one item's line for the AI
|
||||
MAX_SHOWN = 6 # pictures of one item on one base model the network shows
|
||||
MAX_LISTED = 40 # runs of one pair the network lists
|
||||
OVERVIEW_CHARS = 300
|
||||
KINDS = ("way", "skill", "pack")
|
||||
VERDICTS = ("good", "bad")
|
||||
@@ -114,7 +115,10 @@ def used(found, steps):
|
||||
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")))}
|
||||
"skills": list(dict.fromkeys(
|
||||
[step["skill"] for step in steps if step.get("tool") == "use_skill" and step.get("skill")]
|
||||
+ ["external:" + step["external_skill"] for step in steps
|
||||
if step.get("tool") == "log_outside" and step.get("external_skill")]))}
|
||||
|
||||
|
||||
def note_run(task_id, step_id):
|
||||
@@ -202,6 +206,29 @@ def set_verdict(task_id, step_id, verdict):
|
||||
return verdict
|
||||
|
||||
|
||||
def _kept():
|
||||
try:
|
||||
return kept_images.kept_keys()
|
||||
except (OSError, ValueError): # a store unreadable: the gallery's keeps are left out of the count
|
||||
return set()
|
||||
|
||||
|
||||
def _judge(entry, kept):
|
||||
"""How a run turned out for the user, and by what: ("good" | "bad" | "passed" | "", "word" | "pick" |
|
||||
"keep" | ""): their word first, then a pick in a picks panel or a keep in the gallery, then passed over."""
|
||||
if entry.get("verdict") in VERDICTS:
|
||||
return entry["verdict"], "word"
|
||||
if entry.get("picked"):
|
||||
return "good", "pick"
|
||||
if {tuple(picture) for picture in entry.get("pictures") or []} & kept:
|
||||
return "good", "keep"
|
||||
return ("passed", "pick") if entry.get("passed") else ("", "")
|
||||
|
||||
|
||||
def _ai(entry):
|
||||
return "picked" if entry.get("ai_picked") else "passed" if entry.get("ai_passed") else ""
|
||||
|
||||
|
||||
def tally():
|
||||
"""The record summed up: {kind: {name: {base: {runs, good, bad, passed, failed, ai_good, ai_passed, clients,
|
||||
pictures (the last few, [subfolder, filename])}}}}."""
|
||||
@@ -210,31 +237,62 @@ def tally():
|
||||
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()
|
||||
kept = _kept()
|
||||
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)
|
||||
judged = _judge(entry, kept)[0]
|
||||
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, "ai_good": 0, "ai_passed": 0, "clients": [], "pictures": []})
|
||||
count["runs"] += 1
|
||||
count["good"] += good
|
||||
count["bad"] += verdict == "bad"
|
||||
count["passed"] += not good and verdict != "bad" and bool(entry.get("passed"))
|
||||
count["good"] += judged == "good"
|
||||
count["bad"] += judged == "bad"
|
||||
count["passed"] += judged == "passed"
|
||||
count["failed"] += entry.get("state") == "error"
|
||||
count["ai_good"] += bool(entry.get("ai_picked"))
|
||||
count["ai_passed"] += bool(entry.get("ai_passed")) and not entry.get("ai_picked")
|
||||
count["pictures"] = (count["pictures"] + [list(picture) for picture in entry.get("pictures") or []][:2])[-MAX_SHOWN:]
|
||||
count["ai_good"] += _ai(entry) == "picked"
|
||||
count["ai_passed"] += _ai(entry) == "passed"
|
||||
count["pictures"] = (count["pictures"] + [list(picture) for picture in entry.get("pictures") or []])[-MAX_SHOWN:]
|
||||
if entry.get("client") and entry["client"] not in count["clients"]:
|
||||
count["clients"].append(entry["client"])
|
||||
return out
|
||||
|
||||
|
||||
def by_model():
|
||||
"""Each main model's runs: {model file: {runs, good, bad, passed, ai (the AI judged it)}}."""
|
||||
with _lock:
|
||||
entries = list(_runs().values())
|
||||
kept = _kept() if entries else set()
|
||||
out = {}
|
||||
for entry in entries:
|
||||
if entry.get("model"):
|
||||
count = out.setdefault(entry["model"], {"runs": 0, "good": 0, "bad": 0, "passed": 0, "ai": 0})
|
||||
judged = _judge(entry, kept)[0]
|
||||
count["runs"] += 1
|
||||
count["ai"] += bool(_ai(entry))
|
||||
if judged:
|
||||
count[judged] += 1
|
||||
return out
|
||||
|
||||
|
||||
def runs_of(kind, name, base=None, limit=MAX_LISTED):
|
||||
"""The runs with item `name` (a way, skill or pack), on base model `base` when it is given, newest first:
|
||||
[{id, at, task, client, model, base, state, judged, by, ai, pictures}] (judged, by: _judge; ai: the AI's pick)."""
|
||||
if kind not in KINDS:
|
||||
raise ValueError("kind is way, skill or pack")
|
||||
with _lock:
|
||||
found = [(key, dict(entry)) for key, entry in _runs().items() if name in (entry.get(f"{kind}s") or [])
|
||||
and (base is None or (entry.get("base") or "") == base)]
|
||||
found.sort(key=lambda item: -item[1].get("at", 0))
|
||||
kept = _kept() if found else set()
|
||||
out = []
|
||||
for key, entry in found[:limit]:
|
||||
judged, by = _judge(entry, kept)
|
||||
out.append({"id": key, "at": entry.get("at", 0), "task": entry.get("task") or "", "client": entry.get("client") or "",
|
||||
"model": entry.get("model") or "", "base": entry.get("base") or "", "state": entry.get("state") or "",
|
||||
"judged": judged, "by": by, "ai": _ai(entry), "pictures": entry.get("pictures") or []})
|
||||
return out
|
||||
|
||||
|
||||
def _line(base, count):
|
||||
parts = [f"{count[key]} {label}" for key, label in COUNTS if count[key]]
|
||||
ai = [f"{count[key]} {label}" for key, label in (("ai_good", "picked"), ("ai_passed", "passed over")) if count[key]]
|
||||
|
||||
+7
-1
@@ -16,6 +16,7 @@ log() is the AI's own word: what it ran or changed outside Anomalous, why, and w
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -177,7 +178,7 @@ def _line(value, limit, what):
|
||||
return value
|
||||
|
||||
|
||||
def log(client, what, why="", files=None, now=None, session="", task_id=None):
|
||||
def log(client, what, why="", files=None, now=None, session="", task_id=None, external_skill=None):
|
||||
"""The AI's word on what it did outside Anomalous, as a step of its task. Returns the step."""
|
||||
what = _line(what, MAX_TEXT, "what")
|
||||
if not what:
|
||||
@@ -185,10 +186,15 @@ def log(client, what, why="", files=None, now=None, session="", task_id=None):
|
||||
if files is not None and not isinstance(files, list):
|
||||
raise ValueError("files is a list of the files you read, wrote or changed.")
|
||||
named = [_line(item, MAX_FILE, "a file") for item in (files or [])[:MAX_FILES] if str(item or "").strip()]
|
||||
if external_skill is not None and (not isinstance(external_skill, str) or
|
||||
not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,79}", external_skill)):
|
||||
raise ValueError("external_skill must be a short lowercase skill identifier, not a path.")
|
||||
now = time.time() if now is None else now
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "log_outside", "kind": "outside", "ok": True,
|
||||
"state": "done", "summary": f"log_outside: {what[:80]}", "what": what, "why": _line(why, MAX_TEXT, "why"),
|
||||
"files": named, "results": []}
|
||||
if external_skill:
|
||||
step["external_skill"] = external_skill
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks.current_task(client, now, session=session, task_id=task_id)
|
||||
ai_tasks._add_step(task, step)
|
||||
|
||||
+14
-1
@@ -328,7 +328,7 @@ async def api_blocks(request):
|
||||
|
||||
async def api_training(request):
|
||||
"""GET /anomalous/ai_training: the training network (ai_training.py): the record summed up by base model,
|
||||
the main models here, the ways and skills, whether the AI is told it; POST {title, skill | use, models, each, subject?, picture?}
|
||||
the main models here and each one's runs, the ways and skills, the rounds, whether the AI is told it; POST {title, skill | use, models, each, subject?, picture?}
|
||||
starts a training round, a task for the next AI that calls."""
|
||||
from . import ai_skills, ai_training, mcp_canvas_edit, reference_blocks
|
||||
from .mcp_tools import ToolError
|
||||
@@ -350,9 +350,21 @@ async def api_training(request):
|
||||
skills = [skill for skill in ai_skills.status() if not skill["private_only"]]
|
||||
return web.json_response({"record": await asyncio.to_thread(ai_evidence.tally), "models": await asyncio.to_thread(ai_training.models),
|
||||
"blocks": blocks, "skills": skills, "jobs": list(reference_blocks.JOBS),
|
||||
"by_model": await asyncio.to_thread(ai_evidence.by_model), "rounds": await asyncio.to_thread(ai_training.rounds),
|
||||
"shown_to_ai": ai_gates.record_view() == "shown"})
|
||||
|
||||
|
||||
async def api_training_runs(request):
|
||||
"""GET /anomalous/ai_training/runs?kind=way|skill|pack&name=…[&base=…]: each run of one item (on one base
|
||||
model) in the training network, newest first (ai_evidence.runs_of)."""
|
||||
query = request.query
|
||||
try:
|
||||
runs = await asyncio.to_thread(ai_evidence.runs_of, query.get("kind", ""), query.get("name", ""), query.get("base"))
|
||||
except ValueError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
return web.json_response({"runs": runs})
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
activity_log.add_listener(ai_task_changes.note_activity) # the user's changes while an AI works
|
||||
app.router.add_get("/anomalous/ai_tasks", api_list)
|
||||
@@ -377,5 +389,6 @@ def register_routes(app):
|
||||
app.router.add_post("/anomalous/ai_blocks", api_blocks)
|
||||
app.router.add_get("/anomalous/ai_training", api_training)
|
||||
app.router.add_post("/anomalous/ai_training", api_training)
|
||||
app.router.add_get("/anomalous/ai_training/runs", api_training_runs)
|
||||
app.router.add_get("/anomalous/ai_gates", api_gates)
|
||||
app.router.add_post("/anomalous/ai_gates", api_gates)
|
||||
|
||||
@@ -21,6 +21,7 @@ MAX_MODELS = 4
|
||||
MAX_EACH = 3 # pictures of one trial set (seeds)
|
||||
MAX_LISTED = 400 # main models the network lists
|
||||
MAX_SUBJECT = 300
|
||||
MAX_ROUNDS = 30 # rounds the network lists
|
||||
WITHOUT = "" # a skill's trials without it
|
||||
|
||||
|
||||
@@ -43,6 +44,20 @@ def models():
|
||||
return out
|
||||
|
||||
|
||||
def rounds(limit=MAX_ROUNDS):
|
||||
"""The training rounds started here, newest first: [{id, title, started, kind, target, models (names),
|
||||
done, total (trials), taken (an AI took it), ended}]."""
|
||||
with ai_tasks._lock:
|
||||
tasks = sorted((task for task in ai_tasks._load().values() if isinstance(task.get("training"), dict)),
|
||||
key=lambda task: -(task.get("started") or 0))[:limit]
|
||||
return [{"id": task["id"], "title": task.get("title") or "", "started": task.get("started") or 0,
|
||||
"kind": task["training"].get("kind") or "", "target": task["training"].get("skill") or task["training"].get("use") or "",
|
||||
"models": [model.get("name") or "" for model in task["training"].get("models") or []],
|
||||
"done": sum(1 for trial in task["training"].get("trials") or [] if trial.get("run")),
|
||||
"total": len(task["training"].get("trials") or []), "taken": not task.get("waiting"), "ended": bool(task.get("ended"))}
|
||||
for task in tasks]
|
||||
|
||||
|
||||
def _model(model_id):
|
||||
try:
|
||||
folder_type, _path_idx, _base_dir, file_path = _resolve_model(model_id)
|
||||
|
||||
@@ -90,10 +90,10 @@ def report(ctx, text="", images=None, done=False, reply_to=None, task=None):
|
||||
return answer
|
||||
|
||||
|
||||
def log_outside(ctx, what, why="", files=None, task=None):
|
||||
def log_outside(ctx, what, why="", files=None, task=None, external_skill=None):
|
||||
try:
|
||||
step = ai_outside.log(ctx.get("client") or "AI", what, why, files, session=ctx.get("session") or "",
|
||||
task_id=task or ctx.get("task"))
|
||||
task_id=task or ctx.get("task"), external_skill=external_skill)
|
||||
except ValueError as error:
|
||||
raise ToolError(str(error))
|
||||
return {"logged": True, "step": step["id"]}
|
||||
@@ -372,9 +372,11 @@ TASK_TOOLS = dict([
|
||||
"task": _TASK_PROP}),
|
||||
_task_tool("log_outside", "Say what you did outside Anomalous",
|
||||
"Right after using a tool that is not Anomalous's (a command, a script, files, another server): what "
|
||||
"you ran, why, and the files it touched, for the task's timeline. Prefer Anomalous's tools.",
|
||||
"you ran, why, and the files it touched, for the task's timeline. If you actually used an external "
|
||||
"agent skill, set external_skill to its identifier before the runs it guides; this is self-reported.",
|
||||
log_outside, {"what": {"type": "string"}, "why": {"type": "string"},
|
||||
"files": {"type": "array", "items": {"type": "string"}, "maxItems": ai_outside.MAX_FILES},
|
||||
"external_skill": {"type": "string", "description": "Optional external skill identifier, lowercase letters, digits, dot, underscore or hyphen; not a path."},
|
||||
"task": _TASK_PROP},
|
||||
["what"]),
|
||||
_task_tool("log_decision", "Record a decision",
|
||||
|
||||
@@ -304,10 +304,19 @@ its pick (`ai_picked`), the others shown as passed over by it; when the user lef
|
||||
AI (`no_answer` "recommended", `ai_answer_wait.take_recommended`, now for picks blocks too) the
|
||||
choice is the AI's only, and nothing is counted as the user's.
|
||||
|
||||
An agent may identify a skill it actually used outside AMB with `log_outside(external_skill=…)`
|
||||
before the runs it guides. The identifier is a bounded slug, never a path; the task step is the
|
||||
agent's declaration, and later runs in that task count it under `external:<identifier>`.
|
||||
The network shows these as AI-reported external skills, not runnable AMB skills or user ratings.
|
||||
|
||||
Training rounds (`api/ai_training.py`, `GET/POST /anomalous/ai_training`): the network's data is
|
||||
the record summed up (`ai_evidence.tally`, with each pair's last pictures), the main models here
|
||||
with their base models (`models()`, at most 400, read from their metadata), the ways with whether
|
||||
they take a picture, and the skills. A round is a skill (trials without it first, as a skill
|
||||
the record summed up (`ai_evidence.tally`, with each pair's last pictures), each main model's runs
|
||||
(`ai_evidence.by_model`, by file name), the main models here with their base models (`models()`, at
|
||||
most 400, read from their metadata), the ways with whether they take a picture, the skills and the
|
||||
rounds started here (`rounds()`, the newest 30, with how many trials ran). `GET
|
||||
/anomalous/ai_training/runs?kind&name&base` lists one pair's runs, newest first, at most 40
|
||||
(`ai_evidence.runs_of`: model, AI app, how the user judged it and by what, the AI's pick, pictures,
|
||||
task). A round is a skill (trials without it first, as a skill
|
||||
counts for every run after it is fetched, then with it) or the ways of one use that run here,
|
||||
on 1-4 main models, 1-3 pictures each with the same seeds; it is started as a waiting task
|
||||
(`ai_tasks.start_task`, the models as `about`) carrying `training` {kind, items, models, seeds,
|
||||
|
||||
@@ -75,7 +75,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_network.js`):左模型、右做法 / skill / 节点包,连线颜色是效果;点点或线看详情。「开一轮训练」(`ui_ai_training_setup.js`)选练什么、哪些模型、每组几张,「交给 AI 开练」建一个等 AI 接的任务并给出要贴的那句话。上方一行写着 AI 现在看不看得到这份记录:默认不给看,关卡里「训练网记录」可以打开,训练轮里总给看。
|
||||
- 右栏开关里的「训练网络」(`ui_ai_network.js`)铺满整个 AI 工作台,「回到任务」、Esc 或点别的视图关掉。上方:AI 看不看得到这些、「连线图」「表格」、「开一轮训练」;下面一排数字(跑图次数、你评过、AI 评过、还能试几组、几轮训练)和筛选(全部 / 做法 / Skill / 节点包、只看跑过的)。左边是图(`ui_ai_network_graph.js`):连线图是左模型、右做法 / skill / 节点包,连线颜色是效果;表格是每格一对,3/4 是跑了 4 次 3 次好,+ 是还能试。右边是详情(`ui_ai_network_detail.js`):没选时列出训练轮;选底模列出这种模型和各跑了几次;选一对列出每次跑图(时间、模型、哪个 AI、怎么评的、图、打开任务)。「开一轮训练」(`ui_ai_training_setup.js`)选练什么、哪些模型、每组几张,「交给 AI 开练」建一个等 AI 接的任务并给出要贴的那句话。AI 默认看不到这份记录,关卡里「训练网记录」可以打开,训练轮里总给看。
|
||||
- 图片上鼠标移上去有工具(`ui_ai_images.js`):「图片与参数」、「保存为…」、勾两张对比,还有 ✎ 标注。
|
||||
- 时间线和展台里的图先用缩略图(`/anomalous/output_thumbnail`),显示得比缩略图大时才换原图(`/anomalous/output_preview`:超大图换的是 4096 像素的预览),打开任务不用等大图。
|
||||
|
||||
|
||||
+2
-2
@@ -120,7 +120,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
|
||||
| run_workflow 的填空 | 日常出图一步完成:带上 `workflow`(存好的工作流名),第一次在任务的副本标签页里打开它,之后接着用这个标签页;`prompt` / `negative` 填正、反向提示词,`seed` 填所有采样器的种子,`width` / `height` 填空白潜空间的尺寸,`image` 填“加载图片”读的图(输出或输入文件夹里的,比如标注后的重绘图;有几个“加载图片”时用 `image_node` 指定);其他任何参数用 `values` 按名字改,比如 `{"steps": 30}`,第三方节点的参数也一样:只有一个节点有这个参数就直接改,几个节点都有(比如高清修复的两个采样器都有 `denoise`)就要写节点号 `{"12": {"denoise": 0.5}}`,否则什么都不改并列出是哪几个。几个正向提示词框接进同一个采样器(分区提示词)时,`prompt` 也会停下来,让 AI 用 `values` 分别写。其余照存好的样子不动。不带 `workflow` 时填到当前画布上。要一次跑好几条提示词,用 `prompts`(最多 8 条,代替 `prompt`):AMB 按顺序一条条填进去排队,别的填空(比如同一个种子)每条都一样,全部算同一次跑图,图都在时间线的同一步里;跑完画布上留的是最后一条。哪一条排不上就停在那里,前面排上的照常跑完,AI 会知道停在第几条。填空和出图一起算一步,改画布那部分也能 Ctrl+Z;画布关卡比显卡关卡严时,按画布关卡问你 |
|
||||
| 工具目录 | 不是一个工具:AI 开始任务时(还没有任务时调现状一览也一样),回复里附带一张按“想做的事”排好的工具目录(出图、画布、模型、提示词、图片……每个工具一行标题),AI 看一眼就直接去用对的工具,不用到处试。目录从 AMB 的工具登记表自动生成,只列当前给 AI 的工具,加了新工具会自己出现 |
|
||||
| get_situation | 一次拿到任务、流程链、少量节点摘要、缺失输入和可匹配输出、错误及最近运行。每个回复的 `next` 提示下一步。新开对话或拿不准时先调它;简报预算约 3600 字,减少 Antigravity 将大回复转存为文件的情况 |
|
||||
| log_outside | AI 用了 AMB 以外的工具(命令、脚本、读写文件)后,说清做了什么、为什么、动了哪些文件;显示在时间线上(见第 5 节) |
|
||||
| log_outside | AI 用了 AMB 以外的工具(命令、脚本、读写文件)后,说清做了什么、为什么、动了哪些文件;显示在时间线上(见第 5 节)。实际使用外部 skill 时可在受其指导的运行前带 `external_skill` 短名称,训练网会标为 AI 自报;它不是 AMB 内置 skill,也不代表用户评分 |
|
||||
| ask_user | 在 AI 工作台里问你一个问题:几个选项(AI 推荐的那个标着“推荐”),加一行“都不是?写下你的想法”;AI 在同一步里等你回答,至少等 30 秒(就算它自己说不等),你在 AMB 里点了,它直接接着做 |
|
||||
| show_panel / update_panel / get_answers | 在 AI 工作台里画一个面板(对比图片、模型卡片、表格、按钮和输入框);改它或关掉它的问题;读你的回答(见第 5 节) |
|
||||
|
||||
@@ -158,7 +158,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
|
||||
|
||||
**跑图记录(哪套组合在哪个模型上好用)**:AI 每次跑图,AMB 都记下这次用了哪些做法、哪些 skill、哪些第三方节点包,主模型是什么底模(比如 Illustrious、Anima)。效果好不好不用你专门去记:在挑图面板选中的图、收藏的图(存成工作流、搭配、提示词)算 **效果好**;挑图时没被选的算 **没被挑中**;跑完的图下面也有 **这次效果:效果好 / 不太行** 两个按钮,你点的那个最算数,还会告诉 AI(点“不太行”它就该换做法、设置或提示词再跑),再点一下收回。做法面板里每块做法和 skill 下面写着它的记录,比如“Illustrious:跑过 4 次,3 次效果好”;没跑过的写“这台电脑上还没跑过”,鼠标停在一行上能看到是哪些 AI 跑的。同一种活有几块做法都能用时,AMB 先给 AI 在画布主模型上记录最好的那块。记录本身默认不给 AI 看:它只按现成的做法和 skill 做,省得小模型被一堆数字带偏。**关卡** 窗口里的 **训练网记录** 选“给看”,AI 开工时(`begin_task`)就能看到这份记录的摘要,加做法、取 skill 时也会告诉它这块做法、这个 skill 的记录,下一个 AI,不管是 Claude、Gemini 还是 GPT,一看就知道什么在这台电脑上试过、效果怎样、哪里还没试;你自己开的训练轮里,它总能看到。记录只有名字和次数,没有提示词;存在 ComfyUI 的 user 文件夹里(anomalous/ai_evidence.json,最近 2000 次)。**关卡** 窗口里的 **第三方节点** 选“只用做法里的”,AI 自己加节点时就只能用做法里那些搭好的组合,加上你给这个任务挑的;ComfyUI 自带的节点照常能用。
|
||||
|
||||
**训练网络**:AI 工作台右栏的 **训练网络**(脉搏图标)把这份记录画成一张网:左边是你的模型(按底模分,写着各有几个),右边是做法、skill 和跑过的节点包,一起跑过的连一条线,颜色是效果(绿好、黄一般、红不太行、灰跑过还没人评),短虚线表示只有 AI 评过,线越粗跑得越多。点一个模型、一块做法或一条线,下面写着它的记录:跑过几次、你怎么判的、AI 怎么判的、哪些 AI 跑过、最近的几张图;选中一个点时,浅虚线连着它还没试过的组合。**开一轮训练**:选练什么(一个 skill:同一个题材,AI 自己写的提示词和照 skill 建议写的并排比;或者一种活的几块做法并排比)、在哪几个模型上(最多 4 个)、每组几张(同样的种子),点 **交给 AI 开练**,就建好一个等 AI 来接的任务,把显示的那句话贴给你的 AI。AI 按计划一组组跑,AMB 自己认出每次跑图对应计划里哪一组,并在回答里告诉它下一组;一个模型的几组跑完,AI 会把这几张并排放进挑图面板,先推荐一张,再由你挑。你的挑选和 AI 的推荐都记进网络,分开记、你的算数在先;你没空挑时,关卡里“等不到回答时”设成按推荐的继续,AI 的推荐就作数(只记成 AI 判的)。
|
||||
**训练网络**:AI 工作台右栏开关里的 **训练网络**(脉搏图标)铺满整个工作台,把这份记录画出来,**回到任务** 或 Esc 关掉。最上面一排数字:跑过几次、你评过几次、AI 评过几次、还能试几组、开过几轮训练。可以只看做法、skill 或节点包,或只看跑过的。两种看法:**连线图**,左边是你的模型(按底模分,写着各有几个),右边是做法、skill 和跑过的节点包,一起跑过的连一条线,颜色是效果(绿好、黄一般、红不太行、灰跑过还没人评),短虚线表示只有 AI 评过,线越粗跑得越多;**表格**,每格是一对,3/4 是跑了 4 次、3 次效果好,+ 是还没试过、可以开一轮训练的组合。点一个模型、一块做法、一条线或一个格子,右边写着它的记录:选底模,列出这台电脑上的这种模型和各跑了几次;选一对,列出每次跑图(什么时候、哪个模型、哪个 AI、你和 AI 怎么评的、图,还能打开当时的任务)。什么都没选时,右边列出开过的训练轮和进度。**开一轮训练**:选练什么(一个 skill:同一个题材,AI 自己写的提示词和照 skill 建议写的并排比;或者一种活的几块做法并排比)、在哪几个模型上(最多 4 个)、每组几张(同样的种子),点 **交给 AI 开练**,就建好一个等 AI 来接的任务,把显示的那句话贴给你的 AI。AI 按计划一组组跑,AMB 自己认出每次跑图对应计划里哪一组,并在回答里告诉它下一组;一个模型的几组跑完,AI 会把这几张并排放进挑图面板,先推荐一张,再由你挑。你的挑选和 AI 的推荐都记进网络,分开记、你的算数在先;你没空挑时,关卡里“等不到回答时”设成按推荐的继续,AI 的推荐就作数(只记成 AI 判的)。
|
||||
|
||||
**隐私模式**:有些图和提示词不方便给 AI 看时,点任务标题栏的 **🔒**,这个任务就成了隐私任务。AMB 一张图都不发给 AI(放大看图、图片信息里的图、跑完附带的预览、你递给它的图、AI 软件里任务卡片上的图都一样),它只知道出了几张、成没成功。提示词原文和图片文件名换成 **[P1]**、**[img1]** 这样的代号:画布上节点的文字输入(按 ComfyUI 自己的节点定义认,第三方节点也一样;模型名、数字、选项照常给)、图片信息、提示词库、搭配、工作流的设置、模型的触发词和介绍、你自己改画布的记录都一样,同一段文字永远是同一个代号。AI 用代号去写提示词、跑图、填加载图片、放挑图面板,AMB 在工具执行前换回原文,所以画布和出图都是原样,你看到的也都是原样;时间线上记的是代号。要新提示词时,AI 会放一个 **提示词框**:写几条提示词(可以再加)、反向提示词,可以从图库选或把图拖进来当参考图,点 **交给 AI(只给代号)**;原文只存在 AMB 里,交出后框里显示代号和你写的原文。框上方按画布主模型的大小提醒你(来自 skill“写提示词·动作适合模型”)。AI 怎么在隐私任务里干活写在 skill“隐私模式·怎么干活”里,开工时会被指过去。隐私任务里 AI 只拿得到这一个 skill,做法面板的 skill 一栏也只显示它;普通任务正好反过来,只显示、只给普通的 skill。**关卡** 窗口里的“隐私模式”设新任务默认开不开;默认改成关时,原来按默认开着的任务仍然保持隐私。AI 的某次调用认不出属于哪个任务时,只要默认开着或者还有没做完的隐私任务,就按隐私处理。注意:任务做到一半才打开时,之前已经给过 AI 的收不回来;你自己在留言或 AI 聊天框里写的文字和贴的图,AMB 不拦(那是你主动给的);模型名字 AI 是看得到的。
|
||||
|
||||
|
||||
@@ -68,6 +68,29 @@ class EvidenceTests(unittest.TestCase):
|
||||
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_external_skill_is_self_reported_for_later_runs_only(self):
|
||||
before = self.run_with(PROMPT, "a.png")
|
||||
mcp_task_tools.log_outside({"client": "Gemini"}, "Used the external repair skill", task=self.task,
|
||||
external_skill="comfyui-flux-hand-repair")
|
||||
after = self.run_with(PROMPT, "b.png")
|
||||
steps = {step["id"]: step for step in ai_tasks.get_task(self.task)["steps"]}
|
||||
name = "external:comfyui-flux-hand-repair"
|
||||
self.assertNotIn(name, steps[before]["used"]["skills"])
|
||||
self.assertIn(name, steps[after]["used"]["skills"])
|
||||
count = ai_evidence.tally()["skill"][name]["Illustrious"]
|
||||
self.assertEqual((count["runs"], count["good"], count["bad"]), (1, 0, 0))
|
||||
with self.assertRaises(ToolError):
|
||||
mcp_task_tools.log_outside({"client": "Gemini"}, "x", task=self.task, external_skill="../not-a-skill")
|
||||
|
||||
def test_network_keeps_before_after_and_full_image(self):
|
||||
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, {}, []])
|
||||
outputs = {str(i): {"images": [{"filename": name, "subfolder": "repair", "type": "output"}]}
|
||||
for i, name in enumerate(("before.png", "after.png", "full.png"))}
|
||||
ai_task_runs._finish_run(self.task, step["id"], ["p1"], {"p1": {"status": {"completed": True}, "outputs": outputs}})
|
||||
pictures = ai_evidence.tally()["skill"]["prompt_pose"]["Illustrious"]["pictures"]
|
||||
self.assertEqual(pictures, [["repair", "before.png"], ["repair", "after.png"], ["repair", "full.png"]])
|
||||
|
||||
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]}}
|
||||
|
||||
+114
-6
@@ -1472,15 +1472,15 @@ export const i18n = {
|
||||
aiRecordView_shown: "给看",
|
||||
aiRecord_ai_good: ",AI 推荐 {n} 次",
|
||||
aiSideNetwork: "训练网络",
|
||||
aiNetIntro: "左边是你的模型(按底模分),右边是做法、skill 和跑过的节点包。连线是一起跑过的,颜色是效果:绿=好,黄=一般,红=不太行,灰=跑过还没人评;短虚线是只有 AI 评过。线越粗跑得越多。",
|
||||
aiNetSeen_false: "AI 平时看不到这些,只按现成的做法和 skill 做;关卡里的“训练网记录”可以打开。你开的训练轮里它总能看到。",
|
||||
aiNetSeen_true: "AI 开工时会看到这份记录的摘要;关卡里的“训练网记录”可以关掉。",
|
||||
aiNetTrain: "开一轮训练",
|
||||
aiNetModels: "模型",
|
||||
aiNetItems: "做法 / skill",
|
||||
aiNetItems: "做法 / skill / 节点包",
|
||||
aiNetModelsHere: "{count} 个模型",
|
||||
aiNetNotHere: "这里没有这种模型",
|
||||
aiNetPack: "节点包",
|
||||
aiNetExternalSkill: "外部 skill · AI 自报",
|
||||
aiNetHint: "点一个模型、一块做法或一条线看它的记录。选中一个点后,浅虚线是它还没试过的组合。",
|
||||
aiNetEmpty: "还没有记录。开一轮训练,或者让 AI 照常干活,记录会自己长出来。",
|
||||
aiNetReading: "正在读取训练网络…",
|
||||
@@ -1497,10 +1497,64 @@ export const i18n = {
|
||||
aiNetCount_ai_passed: "没推荐 {n} 次",
|
||||
aiNetShort: "跑过 {runs} 次 · {good} 次好",
|
||||
aiNetShortAi: "跑过 {runs} 次 · AI 推荐 {good} 次",
|
||||
aiNetUntried: "还没试过:{names}",
|
||||
aiNetTrainThis: "在这一对上练一轮",
|
||||
aiNetTrainBase: "在这个底模上练一轮",
|
||||
aiNetTrainItem: "练一轮",
|
||||
aiNetRunsShort: "{runs} 次",
|
||||
aiNetKind_way: "做法",
|
||||
aiNetKind_skill: "Skill",
|
||||
aiNetKind_pack: "节点包",
|
||||
aiNetKindAll: "全部",
|
||||
aiNetOnlyRan: "只看跑过的",
|
||||
aiNetCellOpen: "{base} × {item}:还没一起跑过,点一下可以开一轮训练",
|
||||
aiNetBack: "回到任务",
|
||||
aiNetSeenChip_false: "AI 看不到这些",
|
||||
aiNetSeenChip_true: "AI 看得到这些",
|
||||
aiNetMode_graph: "连线图",
|
||||
aiNetMode_table: "表格",
|
||||
aiNetTile_runs: "次跑图",
|
||||
aiNetTile_judged: "次你评过",
|
||||
aiNetTile_ai: "次 AI 评过",
|
||||
aiNetTile_untried: "组还能试",
|
||||
aiNetTile_rounds: "轮训练",
|
||||
aiNetKey_good: "效果好",
|
||||
aiNetKey_fair: "一般",
|
||||
aiNetKey_bad: "不太行",
|
||||
aiNetKey_ran: "跑过,还没人评",
|
||||
aiNetKey_ai: "只有 AI 评过",
|
||||
aiNetKey_untried: "还能试",
|
||||
aiNetKeyNote_graph: "一起跑过的连一条线,线越粗跑得越多。点模型、做法或一条线,右边看详情。",
|
||||
aiNetKeyNote_table: "格子里的 3/4 是“跑了 4 次,3 次效果好”;+ 是还没试过、可以开一轮训练的组合。点格子或表头,右边看详情。",
|
||||
aiNetModelUnknown: "没认出的模型",
|
||||
aiNetJudged_good_word: "你说效果好",
|
||||
aiNetJudged_good_pick: "你挑中了",
|
||||
aiNetJudged_good_keep: "你收藏了",
|
||||
aiNetJudged_bad_word: "你说不太行",
|
||||
aiNetJudged_passed_pick: "没被挑中",
|
||||
aiNetJudged_error: "出错了",
|
||||
aiNetJudged_none: "还没人评",
|
||||
aiNetAiMark_picked: "AI 推荐了",
|
||||
aiNetAiMark_passed: "AI 没推荐",
|
||||
aiNetOpenTask: "打开任务",
|
||||
aiNetRunsReading: "正在读取每次跑图…",
|
||||
aiNetRounds: "训练轮",
|
||||
aiNetRoundsNone: "还没开过训练轮。",
|
||||
aiNetRound_waiting: "等 AI 来接",
|
||||
aiNetRound_going: "进行中",
|
||||
aiNetRound_ended: "没跑完就结束了",
|
||||
aiNetRound_done: "跑完了",
|
||||
aiNetRoundLine: "{when} · 跑完 {done}/{total} 组 · {models}",
|
||||
aiNetHow: "怎么看",
|
||||
aiNetTheseModels: "这台电脑上的这种模型",
|
||||
aiNetNeverRan: "还没跑过",
|
||||
aiNetMoreModels: "还有 {count} 个",
|
||||
aiNetRanWith: "一起跑过的",
|
||||
aiNetRanOn: "在这些底模上跑过",
|
||||
aiNetNotTried: "还没试过(点一个看看)",
|
||||
aiNetPairUntried: "这一对还没一起跑过。",
|
||||
aiNetEachRun: "每次跑图(新的在上)",
|
||||
aiNetUnpick: "← 取消选中",
|
||||
aiNetGone: "选中的这一项现在不在图里了(可能被筛掉了)。",
|
||||
aiTrainHead: "开一轮训练",
|
||||
aiTrainBack: "← 回到网络",
|
||||
aiTrainWhat: "练什么",
|
||||
@@ -5064,15 +5118,15 @@ export const i18n = {
|
||||
aiRecordView_shown: "Shown",
|
||||
aiRecord_ai_good: ", the AI's pick {n} times",
|
||||
aiSideNetwork: "Training network",
|
||||
aiNetIntro: "Left are your models (by base model), right the ways, skills and the node packs that ran. A line joins what ran together, its colour how it turned out: green good, yellow so-so, red not quite, grey ran but nobody judged it; short dashes mean only the AI judged it. The thicker, the more it ran.",
|
||||
aiNetSeen_false: "The AI does not see this as a rule: it goes by the ready-made ways and skills. Show it in the Gates window (\"Training network record\"); in a training round you start it always sees it.",
|
||||
aiNetSeen_true: "The AI is told a summary of this record when it starts; hide it in the Gates window (\"Training network record\").",
|
||||
aiNetTrain: "Start a training round",
|
||||
aiNetModels: "Models",
|
||||
aiNetItems: "Ways / skills",
|
||||
aiNetItems: "Ways / skills / node packs",
|
||||
aiNetModelsHere: "{count} models",
|
||||
aiNetNotHere: "No such model here",
|
||||
aiNetPack: "Node pack",
|
||||
aiNetExternalSkill: "External skill · AI reported",
|
||||
aiNetHint: "Click a model, a way or a line to see its record. With a node picked, faint dashes are what it has not tried yet.",
|
||||
aiNetEmpty: "No record yet. Start a training round, or let the AI work as usual: the record grows by itself.",
|
||||
aiNetReading: "Reading the training network…",
|
||||
@@ -5089,10 +5143,64 @@ export const i18n = {
|
||||
aiNetCount_ai_passed: "passed over {n} times",
|
||||
aiNetShort: "{runs} runs · {good} good",
|
||||
aiNetShortAi: "{runs} runs · the AI picked {good}",
|
||||
aiNetUntried: "Not tried yet: {names}",
|
||||
aiNetTrainThis: "Train this pair",
|
||||
aiNetTrainBase: "Train on this base model",
|
||||
aiNetTrainItem: "Train it",
|
||||
aiNetRunsShort: "{runs}×",
|
||||
aiNetKind_way: "Ways",
|
||||
aiNetKind_skill: "Skills",
|
||||
aiNetKind_pack: "Node packs",
|
||||
aiNetKindAll: "All",
|
||||
aiNetOnlyRan: "Only what ran",
|
||||
aiNetCellOpen: "{base} × {item}: never ran together; click to start a training round on it",
|
||||
aiNetBack: "Back to the tasks",
|
||||
aiNetSeenChip_false: "Hidden from the AI",
|
||||
aiNetSeenChip_true: "Shown to the AI",
|
||||
aiNetMode_graph: "Graph",
|
||||
aiNetMode_table: "Table",
|
||||
aiNetTile_runs: "runs",
|
||||
aiNetTile_judged: "judged by you",
|
||||
aiNetTile_ai: "judged by the AI",
|
||||
aiNetTile_untried: "pairs to try",
|
||||
aiNetTile_rounds: "training rounds",
|
||||
aiNetKey_good: "Turned out well",
|
||||
aiNetKey_fair: "So-so",
|
||||
aiNetKey_bad: "Not quite",
|
||||
aiNetKey_ran: "Ran, not judged yet",
|
||||
aiNetKey_ai: "Only the AI judged it",
|
||||
aiNetKey_untried: "Could be tried",
|
||||
aiNetKeyNote_graph: "A line joins what ran together, the thicker the more often. Click a model, a way or a line to see it on the right.",
|
||||
aiNetKeyNote_table: "3/4 in a cell means 4 runs, 3 turned out well; + is a pair not tried yet that a training round can try. Click a cell or a heading to see it on the right.",
|
||||
aiNetModelUnknown: "Unknown model",
|
||||
aiNetJudged_good_word: "You said it turned out well",
|
||||
aiNetJudged_good_pick: "You picked it",
|
||||
aiNetJudged_good_keep: "You kept it",
|
||||
aiNetJudged_bad_word: "You said not quite",
|
||||
aiNetJudged_passed_pick: "Not picked",
|
||||
aiNetJudged_error: "Failed",
|
||||
aiNetJudged_none: "Not judged yet",
|
||||
aiNetAiMark_picked: "The AI picked it",
|
||||
aiNetAiMark_passed: "The AI passed it over",
|
||||
aiNetOpenTask: "Open the task",
|
||||
aiNetRunsReading: "Reading each run…",
|
||||
aiNetRounds: "Training rounds",
|
||||
aiNetRoundsNone: "No training round yet.",
|
||||
aiNetRound_waiting: "Waiting for an AI",
|
||||
aiNetRound_going: "Going",
|
||||
aiNetRound_ended: "Ended before all ran",
|
||||
aiNetRound_done: "All ran",
|
||||
aiNetRoundLine: "{when} · {done}/{total} trials ran · {models}",
|
||||
aiNetHow: "How to read it",
|
||||
aiNetTheseModels: "Models of this kind here",
|
||||
aiNetNeverRan: "Never ran",
|
||||
aiNetMoreModels: "{count} more",
|
||||
aiNetRanWith: "Ran with",
|
||||
aiNetRanOn: "Ran on these base models",
|
||||
aiNetNotTried: "Not tried yet (click one)",
|
||||
aiNetPairUntried: "These two never ran together.",
|
||||
aiNetEachRun: "Each run (newest first)",
|
||||
aiNetUnpick: "← Unpick",
|
||||
aiNetGone: "What was picked is not shown now (a filter may hide it).",
|
||||
aiTrainHead: "Start a training round",
|
||||
aiTrainBack: "← Back to the network",
|
||||
aiTrainWhat: "What to train",
|
||||
|
||||
+182
-217
@@ -1,29 +1,36 @@
|
||||
/**
|
||||
* The side column's training network (训练网络, ui_ai_side_views.js): what the runs here showed, as a
|
||||
* graph. Left the base models (those of the main models here, and any the record names), right the
|
||||
* ready-made ways, the skills and the node packs that ran; a line joins a model and an item that ran
|
||||
* together, its colour how that turned out (api/ai_evidence.py: the user's picks, keeps and word,
|
||||
* else, dashed, the AI's own pick), its width how often. A click on a node or a line shows it below:
|
||||
* the counts, who judged, which AI apps ran it, its last pictures, and, for a node, the pairs not
|
||||
* tried yet (dashed in the graph); a training round (ui_ai_training_setup.js) starts from there or
|
||||
* from the top. GET /anomalous/ai_training, read again each time the view is opened.
|
||||
* The training network (训练网络) at the size of the AI page: what the runs here showed and the training
|
||||
* rounds that add to it. The side switch of the AI workbench opens it (ui_ai_side_views.js) over the
|
||||
* tasks, the timeline and the side column; its back button (Esc presses it, as the browser's pages one
|
||||
* level down) or another view of the switch closes it. Its head: whether the AI is told the record (ai_gates.record_view), the graph or the table
|
||||
* (ui_ai_network_graph.js, kept on this browser) and a training round to start; under it the numbers
|
||||
* (runs, judged by the user, by the AI, pairs that could be tried, rounds) and which items to show.
|
||||
* Beside the drawing the detail column (ui_ai_network_detail.js); a training round is set up in
|
||||
* place of both (ui_ai_training_setup.js). GET /anomalous/ai_training, read again each time it opens.
|
||||
*/
|
||||
|
||||
import { resolveLocale, translate as t } from './locales.js';
|
||||
import { sideHead } from './ui_ai_side.js';
|
||||
import { imageResults } from './ui_ai_images.js';
|
||||
import { translate as t } from './locales.js';
|
||||
import { icon, iconButton } from './ui_ai_icons.js';
|
||||
import { trainingSetup } from './ui_ai_training_setup.js';
|
||||
import { KINDS, networkOf, same, totals } from './ui_ai_network_data.js';
|
||||
import { graphBox, matrixBox, releaseGraph } from './ui_ai_network_graph.js';
|
||||
import { detailBox, forgetRuns } from './ui_ai_network_detail.js';
|
||||
|
||||
const SVG = 'http://www.w3.org/2000/svg';
|
||||
const COUNTS = ['good', 'bad', 'passed', 'failed'];
|
||||
let shown = null; // the network last read, or {error}
|
||||
let reading = null;
|
||||
let chosen = null; // {base} | {kind, name} | {base, kind, name}
|
||||
let setup = null; // the training round being set up: its preset, or null
|
||||
let watcher = null; // redraws the lines when the graph's size changes
|
||||
|
||||
const words = value => (resolveLocale() === 'zh' ? value?.zh : value?.en) || value?.en || value?.zh || '';
|
||||
const baseLabel = base => base || t('aiRecordOther');
|
||||
const MODE_KEY = 'anomalous.aiNetMode';
|
||||
const MODES = ['graph', 'table'];
|
||||
const TILES = ['runs', 'judged', 'ai', 'untried', 'rounds'];
|
||||
const TONES = ['good', 'fair', 'bad', 'ran', 'ai', 'untried'];
|
||||
const state = {
|
||||
ctx: null, // {owner, openTask(id)}: the workbench's, set each time its side column is drawn
|
||||
root: null,
|
||||
data: null, // the network last read, or {error}
|
||||
reading: null,
|
||||
chosen: null, // {base} | {kind, name} | {base, kind, name}
|
||||
drawnPick: null, // what the detail column showed last (its scroll goes back to the top when it changes)
|
||||
setup: null, // the training round being set up: its preset, or null
|
||||
filter: { kind: 'all', ran: false },
|
||||
mode: null,
|
||||
};
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -39,219 +46,177 @@ function button(className, label, onClick) {
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The items: the ways and skills on (and any with a record) and the node packs that ran, those that ran most first. */
|
||||
function itemsOf(data) {
|
||||
const record = data.record || {};
|
||||
const out = [];
|
||||
for (const block of data.blocks) {
|
||||
const usable = block.usable && block.on;
|
||||
if (usable || record.way?.[block.name]) out.push({ kind: 'way', name: block.name, title: words(block.title), sub: t(`aiBlocksJob_${block.job}`), usable });
|
||||
}
|
||||
for (const skill of data.skills) {
|
||||
if (skill.on || record.skill?.[skill.name]) out.push({ kind: 'skill', name: skill.name, title: words(skill.title), sub: 'Skill', usable: skill.on });
|
||||
}
|
||||
for (const pack of Object.keys(record.pack || {}).sort()) out.push({ kind: 'pack', name: pack, title: pack, sub: t('aiNetPack'), usable: false });
|
||||
const runs = item => Object.values(record[item.kind]?.[item.name] || {}).reduce((sum, count) => sum + count.runs, 0);
|
||||
return out.map(item => ({ ...item, key: `${item.kind}:${item.name}`, runs: runs(item) })).sort((a, b) => b.runs - a.runs);
|
||||
}
|
||||
|
||||
/** The base models with how many main models here have each and how often they ran, most run first. */
|
||||
function basesOf(data, edges) {
|
||||
const bases = new Map(data.models.map(model => [model.base, 0]));
|
||||
for (const model of data.models) bases.set(model.base, bases.get(model.base) + 1);
|
||||
const runs = new Map();
|
||||
for (const edge of edges) {
|
||||
runs.set(edge.base, (runs.get(edge.base) || 0) + edge.count.runs);
|
||||
if (!bases.has(edge.base)) bases.set(edge.base, 0);
|
||||
}
|
||||
return [...bases].map(([base, models]) => ({ base, models, runs: runs.get(base) || 0 }))
|
||||
.sort((a, b) => b.runs - a.runs || b.models - a.models || a.base.localeCompare(b.base));
|
||||
}
|
||||
|
||||
/** How a pair turned out: the user's judgement first, else the AI's (`ai`), else only that it ran. */
|
||||
function look(count) {
|
||||
const share = (good, all) => (good / all >= 0.6 ? 'good' : good / all >= 0.3 ? 'fair' : 'bad');
|
||||
const user = count.good + count.bad + count.passed;
|
||||
const ai = count.ai_good + count.ai_passed;
|
||||
if (user) return { tone: share(count.good, user), ai: false };
|
||||
if (ai) return { tone: share(count.ai_good, ai), ai: true };
|
||||
return { tone: 'ran', ai: false };
|
||||
}
|
||||
|
||||
function edgesOf(data, items) {
|
||||
return items.flatMap(item => Object.entries(data.record?.[item.kind]?.[item.name] || {})
|
||||
.map(([base, count]) => ({ base, item, count, look: look(count) })));
|
||||
}
|
||||
|
||||
const isBase = pick => pick && pick.base !== undefined && !pick.kind;
|
||||
const isItem = pick => pick && pick.kind && pick.base === undefined;
|
||||
const touches = (edge, pick) => !pick || (pick.base === undefined || pick.base === edge.base) && (!pick.kind || pick.kind === edge.item.kind && pick.name === edge.item.name);
|
||||
|
||||
/** The pairs of the chosen node not tried yet: [base, item], items that can run, bases with models here. */
|
||||
function untried(bases, items, edges) {
|
||||
const tried = new Set(edges.map(edge => `${edge.base}|${edge.item.key}`));
|
||||
if (isBase(chosen)) return items.filter(item => item.usable && !tried.has(`${chosen.base}|${item.key}`)).map(item => [chosen.base, item]);
|
||||
const item = isItem(chosen) && items.find(entry => entry.kind === chosen.kind && entry.name === chosen.name);
|
||||
return item?.usable ? bases.filter(base => base.models && !tried.has(`${base.base}|${item.key}`)).map(base => [base.base, item]) : [];
|
||||
}
|
||||
|
||||
function choose(pick, redraw) {
|
||||
chosen = JSON.stringify(chosen) === JSON.stringify(pick) ? null : pick;
|
||||
redraw();
|
||||
}
|
||||
|
||||
/** The lines between the columns, drawn from where the nodes are now. */
|
||||
function drawLines(svg, graph, nodes, edges, open) {
|
||||
const box = graph.getBoundingClientRect();
|
||||
svg.setAttribute('width', String(box.width));
|
||||
svg.setAttribute('height', String(box.height));
|
||||
svg.replaceChildren();
|
||||
const at = (node, right) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return [(right ? rect.right : rect.left) - box.left, rect.top + rect.height / 2 - box.top];
|
||||
};
|
||||
const line = (base, item, className, width, onClick, title) => {
|
||||
const from = nodes.get(`base:${base}`);
|
||||
const to = nodes.get(item.key);
|
||||
if (!from || !to) return;
|
||||
const [x1, y1] = at(from, true);
|
||||
const [x2, y2] = at(to, false);
|
||||
const middle = (x1 + x2) / 2;
|
||||
const shape = `M${x1} ${y1} C${middle} ${y1} ${middle} ${y2} ${x2} ${y2}`;
|
||||
const path = document.createElementNS(SVG, 'path');
|
||||
path.setAttribute('d', shape);
|
||||
path.setAttribute('class', className);
|
||||
path.style.strokeWidth = `${width}px`;
|
||||
svg.append(path);
|
||||
if (!onClick) return;
|
||||
const hit = document.createElementNS(SVG, 'path');
|
||||
hit.setAttribute('d', shape);
|
||||
hit.setAttribute('class', 'anomalous-ai-net-hit');
|
||||
hit.addEventListener('click', onClick);
|
||||
const tip = document.createElementNS(SVG, 'title');
|
||||
tip.textContent = title;
|
||||
hit.append(tip);
|
||||
svg.append(hit);
|
||||
};
|
||||
for (const [base, item] of open) line(base, item, 'anomalous-ai-net-line is-untried', 1.5);
|
||||
for (const edge of edges) {
|
||||
const own = touches(edge, chosen);
|
||||
const picked = chosen?.kind && chosen.base !== undefined && own;
|
||||
line(edge.base, edge.item, `anomalous-ai-net-line is-${edge.look.tone}${edge.look.ai ? ' is-ai' : ''}${own ? '' : ' is-dim'}${picked ? ' is-chosen' : ''}`,
|
||||
1.5 + Math.min(edge.count.runs, 10) * 0.35, edge.onClick, `${baseLabel(edge.base)} × ${edge.item.title}: ${short(edge.count)}`);
|
||||
function storedMode() {
|
||||
try {
|
||||
const kept = localStorage.getItem(MODE_KEY);
|
||||
return MODES.includes(kept) ? kept : 'graph';
|
||||
} catch (_) {
|
||||
return 'graph';
|
||||
}
|
||||
}
|
||||
|
||||
/** "4 runs · 3 good", or the AI's word when the user gave none. */
|
||||
function short(count) {
|
||||
const judged = look(count);
|
||||
return t(judged.ai ? 'aiNetShortAi' : 'aiNetShort', { runs: count.runs, good: judged.ai ? count.ai_good : count.good });
|
||||
function keepMode(mode) {
|
||||
state.mode = mode;
|
||||
try {
|
||||
localStorage.setItem(MODE_KEY, mode);
|
||||
} catch (_) { /* only not kept */ }
|
||||
}
|
||||
|
||||
function countWords(count, keys) {
|
||||
return keys.filter(key => count[key]).map(key => t(`aiNetCount_${key}`, { n: count[key] })).join(' · ');
|
||||
/** The workbench's ways in (`ctx`: {owner, openTask(id)}), kept for when the switch opens the network. */
|
||||
export function setNetworkContext(ctx) {
|
||||
state.ctx = ctx;
|
||||
}
|
||||
|
||||
/** What is shown below the graph for the chosen node or line. */
|
||||
function detailBox(data, bases, items, edges, ctx, redraw) {
|
||||
const box = el('div', 'anomalous-ai-net-detail');
|
||||
if (!chosen) {
|
||||
box.append(el('div', 'anomalous-ai-net-hint', t(edges.length ? 'aiNetHint' : 'aiNetEmpty')));
|
||||
return box;
|
||||
export const networkOpen = () => Boolean(state.root?.isConnected);
|
||||
|
||||
export function closeNetwork() {
|
||||
releaseGraph();
|
||||
state.root?.remove();
|
||||
state.root = null;
|
||||
state.setup = null;
|
||||
}
|
||||
|
||||
const openTask = (id) => {
|
||||
closeNetwork();
|
||||
state.ctx?.openTask?.(id);
|
||||
};
|
||||
|
||||
function read() {
|
||||
if (state.reading) return;
|
||||
forgetRuns();
|
||||
state.reading = fetch('/anomalous/ai_training').then(async (response) => {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || response.statusText);
|
||||
state.data = data;
|
||||
}).catch((error) => { state.data = { error: error.message }; }).finally(() => { state.reading = null; draw(); });
|
||||
}
|
||||
|
||||
/** Opens the network over the AI page (read again), or reads it again when it is open. */
|
||||
export function openNetwork() {
|
||||
const host = state.ctx?.owner?.aiPanel;
|
||||
if (!host) return;
|
||||
if (!networkOpen()) {
|
||||
state.root = el('section', 'anomalous-ai-netx');
|
||||
state.root.tabIndex = -1;
|
||||
state.root.setAttribute('aria-label', t('aiSideNetwork'));
|
||||
// First in the page, so the browser's Esc (ui_browser_navigation.js) finds its back button before the workbench's.
|
||||
host.prepend(state.root);
|
||||
}
|
||||
const train = (label, preset) => button('anomalous-ai-net-train is-small', label, () => { setup = preset; redraw(); });
|
||||
if (chosen.kind && chosen.base !== undefined) {
|
||||
const edge = edges.find(item => touches(item, chosen));
|
||||
if (!edge) return box;
|
||||
const count = edge.count;
|
||||
box.append(el('div', 'anomalous-ai-net-detail-title', `${baseLabel(edge.base)} × ${edge.item.title}`),
|
||||
el('div', 'anomalous-ai-net-detail-line', t('aiNetRuns', { runs: count.runs })),
|
||||
el('div', 'anomalous-ai-net-detail-line', countWords(count, ['good', 'bad', 'passed']) ? t('aiNetUser', { parts: countWords(count, ['good', 'bad', 'passed']) }) : t('aiNetUserNone')));
|
||||
if (count.ai_good || count.ai_passed) box.append(el('div', 'anomalous-ai-net-detail-line', t('aiNetAi', { parts: countWords(count, ['ai_good', 'ai_passed']) })));
|
||||
if (count.failed) box.append(el('div', 'anomalous-ai-net-detail-line', countWords(count, ['failed'])));
|
||||
if (count.clients.length) box.append(el('div', 'anomalous-ai-net-detail-line is-soft', t('aiRecordBy', { names: count.clients.join(', ') })));
|
||||
const pictures = [...count.pictures].reverse().map(([subfolder, filename]) => ({ type: 'image', folder: 'output', filename, subfolder }));
|
||||
if (pictures.length) box.append(imageResults(pictures, ctx.owner, { compact: true }));
|
||||
if (edge.item.usable) box.append(train(t('aiNetTrainThis'), { item: edge.item, base: edge.base }));
|
||||
return box;
|
||||
state.mode ||= storedMode();
|
||||
draw();
|
||||
read();
|
||||
state.root.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function choose(pick) {
|
||||
state.chosen = same(state.chosen, pick) ? null : pick;
|
||||
draw();
|
||||
}
|
||||
|
||||
function train(preset) {
|
||||
state.setup = preset;
|
||||
draw();
|
||||
}
|
||||
|
||||
function segmented(values, current, labelOf, onPick) {
|
||||
const box = el('div', 'anomalous-ai-gates-choice anomalous-ai-netx-seg');
|
||||
box.setAttribute('role', 'radiogroup');
|
||||
for (const value of values) {
|
||||
const option = button(`anomalous-ai-gates-option${value === current ? ' is-on' : ''}`, labelOf(value), () => onPick(value));
|
||||
option.setAttribute('role', 'radio');
|
||||
option.setAttribute('aria-checked', String(value === current));
|
||||
box.append(option);
|
||||
}
|
||||
const base = isBase(chosen) ? bases.find(item => item.base === chosen.base) : null;
|
||||
const item = base ? null : items.find(entry => entry.kind === chosen.kind && entry.name === chosen.name);
|
||||
if (!base && !item) return box;
|
||||
box.append(el('div', 'anomalous-ai-net-detail-title', base ? baseLabel(base.base) : item.title),
|
||||
el('div', 'anomalous-ai-net-detail-line is-soft', base ? (base.models ? t('aiNetModelsHere', { count: base.models }) : t('aiNetNotHere')) : item.sub));
|
||||
for (const edge of edges.filter(entry => touches(entry, chosen))) {
|
||||
const label = base ? edge.item.title : baseLabel(edge.base);
|
||||
const row = button(`anomalous-ai-net-row is-${edge.look.tone}${edge.look.ai ? ' is-ai' : ''}`, '', () => choose({ base: edge.base, kind: edge.item.kind, name: edge.item.name }, redraw));
|
||||
row.append(el('span', 'anomalous-ai-net-row-name', label), el('span', 'anomalous-ai-net-row-count', short(edge.count)));
|
||||
box.append(row);
|
||||
}
|
||||
const open = untried(bases, items, edges);
|
||||
if (open.length) box.append(el('div', 'anomalous-ai-net-detail-line is-soft', t('aiNetUntried', { names: open.map(([key, entry]) => (base ? entry.title : baseLabel(key))).join('、') })));
|
||||
if (base?.models) box.append(train(t('aiNetTrainBase'), { base: base.base }));
|
||||
if (item?.usable) box.append(train(t('aiNetTrainItem'), { item }));
|
||||
return box;
|
||||
}
|
||||
|
||||
function graphBox(data, ctx, redraw) {
|
||||
const items = itemsOf(data);
|
||||
const edges = edgesOf(data, items);
|
||||
const bases = basesOf(data, edges);
|
||||
for (const edge of edges) edge.onClick = () => choose({ base: edge.base, kind: edge.item.kind, name: edge.item.name }, redraw);
|
||||
const graph = el('div', 'anomalous-ai-net-graph');
|
||||
const svg = document.createElementNS(SVG, 'svg');
|
||||
svg.setAttribute('class', 'anomalous-ai-net-lines');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
const nodes = new Map();
|
||||
const column = (head, entries, keyOf, labelOf, subOf, pickOf, extra) => {
|
||||
const box = el('div', `anomalous-ai-net-col ${extra}`);
|
||||
box.append(el('div', 'anomalous-ai-net-head', head));
|
||||
for (const entry of entries) {
|
||||
const pick = pickOf(entry);
|
||||
const lit = JSON.stringify(chosen) === JSON.stringify(pick);
|
||||
const dim = chosen && !lit && !edges.some(edge => touches(edge, chosen) && touches(edge, pick)) && !untried(bases, items, edges).some(([base, item]) => keyOf(entry) === `base:${base}` || keyOf(entry) === item.key);
|
||||
const node = button(`anomalous-ai-net-node${lit ? ' is-chosen' : ''}${dim ? ' is-dim' : ''}`, '', () => choose(pick, redraw));
|
||||
node.setAttribute('aria-pressed', String(lit));
|
||||
node.append(el('span', 'anomalous-ai-net-node-name', labelOf(entry)), el('span', 'anomalous-ai-net-node-sub', subOf(entry)));
|
||||
nodes.set(keyOf(entry), node);
|
||||
box.append(node);
|
||||
}
|
||||
return box;
|
||||
function headBar(data) {
|
||||
const bar = el('div', 'anomalous-ai-netx-head');
|
||||
const title = el('h2', 'anomalous-ai-netx-title');
|
||||
title.append(icon('pulse', 18), el('span', '', t('aiSideNetwork')));
|
||||
bar.append(iconButton('anomalous-ai-netx-back', 'chevronLeft', t('aiNetBack'), closeNetwork), title);
|
||||
if (!data || data.error) return bar;
|
||||
const seen = data.shown_to_ai === true;
|
||||
const chip = el('span', `anomalous-ai-netx-seen${seen ? ' is-on' : ''}`, t(`aiNetSeenChip_${seen}`));
|
||||
chip.title = t(`aiNetSeen_${seen}`);
|
||||
bar.append(chip, el('span', 'anomalous-ai-netx-gap'));
|
||||
if (!state.setup) {
|
||||
bar.append(segmented(MODES, state.mode, mode => t(`aiNetMode_${mode}`), (mode) => { keepMode(mode); draw(); }),
|
||||
button('anomalous-ai-net-train', t('aiNetTrain'), () => train({})));
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
|
||||
function tiles(net) {
|
||||
const numbers = totals(net);
|
||||
const box = el('div', 'anomalous-ai-netx-tiles');
|
||||
for (const key of TILES) {
|
||||
const tile = el('div', `anomalous-ai-netx-tile is-${key}`);
|
||||
tile.append(el('span', 'anomalous-ai-netx-tile-value', numbers[key]), el('span', 'anomalous-ai-netx-tile-label', t(`aiNetTile_${key}`)));
|
||||
box.append(tile);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function filters() {
|
||||
const bar = el('div', 'anomalous-ai-netx-filters');
|
||||
const ran = el('label', 'anomalous-ai-netx-check');
|
||||
const check = el('input');
|
||||
check.type = 'checkbox';
|
||||
check.checked = state.filter.ran;
|
||||
check.onchange = () => {
|
||||
state.filter.ran = check.checked;
|
||||
draw();
|
||||
};
|
||||
graph.append(svg,
|
||||
column(t('aiNetModels'), bases, base => `base:${base.base}`, base => baseLabel(base.base),
|
||||
base => (base.models ? t('aiNetModelsHere', { count: base.models }) : t('aiNetNotHere')), base => ({ base: base.base }), 'is-bases'),
|
||||
column(t('aiNetItems'), items, item => item.key, item => item.title, item => item.sub, item => ({ kind: item.kind, name: item.name }), 'is-items'));
|
||||
const draw = () => drawLines(svg, graph, nodes, edges, untried(bases, items, edges));
|
||||
watcher?.disconnect();
|
||||
watcher = new ResizeObserver(draw);
|
||||
watcher.observe(graph);
|
||||
return [graph, detailBox(data, bases, items, edges, ctx, redraw)];
|
||||
ran.append(check, el('span', '', t('aiNetOnlyRan')));
|
||||
bar.append(segmented(['all', ...KINDS], state.filter.kind, kind => t(kind === 'all' ? 'aiNetKindAll' : `aiNetKind_${kind}`),
|
||||
(kind) => { state.filter.kind = kind; draw(); }), ran);
|
||||
return bar;
|
||||
}
|
||||
|
||||
function draw(pane, ctx, redraw) {
|
||||
const body = el('div', 'anomalous-ai-stage-body anomalous-ai-net');
|
||||
if (!shown || shown.error) {
|
||||
body.append(el('div', 'anomalous-ai-stage-empty', shown ? t('aiNetUnread', { error: shown.error }) : t('aiNetReading')));
|
||||
} else if (setup) {
|
||||
body.append(trainingSetup(shown, setup, { back: () => { setup = null; redraw(); }, openTask: ctx.openTask }));
|
||||
} else {
|
||||
body.append(el('div', 'anomalous-ai-blocks-note', t('aiNetIntro')),
|
||||
el('div', 'anomalous-ai-blocks-note', t(`aiNetSeen_${shown.shown_to_ai === true}`)),
|
||||
button('anomalous-ai-net-train', t('aiNetTrain'), () => { setup = {}; redraw(); }), ...graphBox(shown, ctx, redraw));
|
||||
function legend() {
|
||||
const box = el('div', 'anomalous-ai-netx-legend');
|
||||
for (const tone of TONES) {
|
||||
const key = el('span', 'anomalous-ai-netx-key');
|
||||
key.append(el('span', `anomalous-ai-netx-swatch is-${tone}`), el('span', '', t(`aiNetKey_${tone}`)));
|
||||
box.append(key);
|
||||
}
|
||||
pane.replaceChildren(sideHead(t('aiSideNetwork'), ctx.close), body);
|
||||
box.append(el('span', 'anomalous-ai-netx-key-note', t(`aiNetKeyNote_${state.mode}`)));
|
||||
return box;
|
||||
}
|
||||
|
||||
/** The view into `pane`; `ctx`: {close, owner, openTask(id)}. `fresh`: just opened, so the network is read again. */
|
||||
export function renderNetworkView(pane, ctx, fresh = false) {
|
||||
const redraw = () => draw(pane, ctx, redraw);
|
||||
if ((fresh || !shown) && !reading) {
|
||||
reading = fetch('/anomalous/ai_training').then(async (response) => {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || response.statusText);
|
||||
shown = data;
|
||||
}).catch((error) => { shown = { error: error.message }; }).finally(() => { reading = null; redraw(); });
|
||||
function draw() {
|
||||
const root = state.root;
|
||||
if (!root?.isConnected) return;
|
||||
const data = state.data;
|
||||
if (!data || data.error) {
|
||||
root.replaceChildren(headBar(data), el('div', 'anomalous-ai-stage-empty', data ? t('aiNetUnread', { error: data.error }) : t('aiNetReading')));
|
||||
return;
|
||||
}
|
||||
redraw();
|
||||
if (state.setup) {
|
||||
const page = el('div', 'anomalous-ai-netx-setup');
|
||||
page.append(trainingSetup(data, state.setup, {
|
||||
back: () => {
|
||||
state.setup = null;
|
||||
draw();
|
||||
read(); // a round may have been started
|
||||
},
|
||||
openTask,
|
||||
}));
|
||||
root.replaceChildren(headBar(data), page);
|
||||
return;
|
||||
}
|
||||
const before = root.querySelector('.anomalous-ai-netx-main');
|
||||
const beforeDetail = root.querySelector('.anomalous-ai-net-detail');
|
||||
const net = networkOf(data, state.filter);
|
||||
const main = el('div', `anomalous-ai-netx-main is-${state.mode}`);
|
||||
main.append(filters(), state.mode === 'table' ? matrixBox(net, state.chosen, choose) : graphBox(net, state.chosen, choose), legend());
|
||||
if (!net.edges.length) main.append(el('div', 'anomalous-ai-net-hint', t('aiNetEmpty')));
|
||||
const detail = detailBox(net, state.chosen, { choose, train, redraw: draw, ctx: { owner: state.ctx?.owner, openTask } });
|
||||
const body = el('div', 'anomalous-ai-netx-body');
|
||||
body.append(main, detail);
|
||||
root.replaceChildren(headBar(data), tiles(net), body);
|
||||
if (before) [main.scrollTop, main.scrollLeft] = [before.scrollTop, before.scrollLeft];
|
||||
if (beforeDetail && same(state.drawnPick, state.chosen)) detail.scrollTop = beforeDetail.scrollTop;
|
||||
state.drawnPick = state.chosen;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The training network's data (ui_ai_network.js), from GET /anomalous/ai_training: the items (the
|
||||
* ready-made ways and skills that are on or have a record, the node packs that ran), the base models
|
||||
* (those of the main models here, and any the record names), the pairs that ran (edges) and how each
|
||||
* turned out (the user's judgement first, else the AI's), the pairs that could still be tried, and the
|
||||
* numbers over the views. Shared by the drawings (ui_ai_network_graph.js) and the detail column
|
||||
* (ui_ai_network_detail.js).
|
||||
*/
|
||||
|
||||
import { resolveLocale, translate as t } from './locales.js';
|
||||
|
||||
export const KINDS = ['way', 'skill', 'pack'];
|
||||
export const words = value => (resolveLocale() === 'zh' ? value?.zh : value?.en) || value?.en || value?.zh || '';
|
||||
export const baseLabel = base => base || t('aiRecordOther');
|
||||
export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
export const isBase = pick => Boolean(pick) && pick.base !== undefined && !pick.kind;
|
||||
export const isItem = pick => Boolean(pick?.kind) && pick.base === undefined;
|
||||
export const isPair = pick => Boolean(pick?.kind) && pick.base !== undefined;
|
||||
export const pairKey = (base, item) => `${base}|${item.key}`;
|
||||
|
||||
/** Whether `edge` belongs to the pick: a base, an item, a pair, or nothing picked. */
|
||||
export const touches = (edge, pick) => !pick || (pick.base === undefined || pick.base === edge.base)
|
||||
&& (!pick.kind || pick.kind === edge.item.kind && pick.name === edge.item.name);
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = String(text);
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The items: the ways and skills on (and any with a record) and the node packs that ran, those that ran most first. */
|
||||
function itemsOf(data) {
|
||||
const record = data.record || {};
|
||||
const out = [];
|
||||
for (const block of data.blocks) {
|
||||
const usable = block.usable && block.on;
|
||||
if (usable || record.way?.[block.name]) out.push({ kind: 'way', name: block.name, title: words(block.title), sub: t(`aiBlocksJob_${block.job}`), usable });
|
||||
}
|
||||
for (const skill of data.skills) {
|
||||
if (skill.on || record.skill?.[skill.name]) out.push({ kind: 'skill', name: skill.name, title: words(skill.title), sub: 'Skill', usable: skill.on });
|
||||
}
|
||||
for (const name of Object.keys(record.skill || {})) {
|
||||
if (name.startsWith('external:') && !out.some(item => item.kind === 'skill' && item.name === name))
|
||||
out.push({ kind: 'skill', name, title: name.slice(9), sub: t('aiNetExternalSkill'), usable: false });
|
||||
}
|
||||
for (const pack of Object.keys(record.pack || {}).sort()) out.push({ kind: 'pack', name: pack, title: pack, sub: t('aiNetPack'), usable: false });
|
||||
const runs = item => Object.values(record[item.kind]?.[item.name] || {}).reduce((sum, count) => sum + count.runs, 0);
|
||||
return out.map(item => ({ ...item, key: `${item.kind}:${item.name}`, runs: runs(item) })).sort((a, b) => b.runs - a.runs);
|
||||
}
|
||||
|
||||
/** The base models with how many main models here have each and how often they ran, most run first. */
|
||||
function basesOf(data, edges) {
|
||||
const bases = new Map(data.models.map(model => [model.base, 0]));
|
||||
for (const model of data.models) bases.set(model.base, bases.get(model.base) + 1);
|
||||
const runs = new Map();
|
||||
for (const edge of edges) {
|
||||
runs.set(edge.base, (runs.get(edge.base) || 0) + edge.count.runs);
|
||||
if (!bases.has(edge.base)) bases.set(edge.base, 0);
|
||||
}
|
||||
return [...bases].map(([base, models]) => ({ base, models, runs: runs.get(base) || 0 }))
|
||||
.sort((a, b) => b.runs - a.runs || b.models - a.models || a.base.localeCompare(b.base));
|
||||
}
|
||||
|
||||
/** How a pair turned out: the user's judgement first, else the AI's (`ai`), else only that it ran. */
|
||||
export function look(count) {
|
||||
const share = (good, all) => (good / all >= 0.6 ? 'good' : good / all >= 0.3 ? 'fair' : 'bad');
|
||||
const user = count.good + count.bad + count.passed;
|
||||
const ai = count.ai_good + count.ai_passed;
|
||||
if (user) return { tone: share(count.good, user), ai: false };
|
||||
if (ai) return { tone: share(count.ai_good, ai), ai: true };
|
||||
return { tone: 'ran', ai: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* The network the views draw: {data, items, bases, edges, tried}. `filter`: {kind: 'all' | a kind,
|
||||
* ran: only what ran}.
|
||||
*/
|
||||
export function networkOf(data, filter) {
|
||||
let items = itemsOf(data);
|
||||
if (filter.kind !== 'all') items = items.filter(item => item.kind === filter.kind);
|
||||
if (filter.ran) items = items.filter(item => item.runs);
|
||||
const edges = items.flatMap(item => Object.entries(data.record?.[item.kind]?.[item.name] || {})
|
||||
.map(([base, count]) => ({ base, item, count, look: look(count) })));
|
||||
let bases = basesOf(data, edges);
|
||||
if (filter.ran) bases = bases.filter(base => base.runs);
|
||||
return { data, items, bases, edges, tried: new Set(edges.map(edge => pairKey(edge.base, edge.item))) };
|
||||
}
|
||||
|
||||
/** Whether a pair could be tried: the item runs here, the base model has models here, they never ran together. */
|
||||
export const canTry = (net, base, item) => item.usable && base.models > 0 && !net.tried.has(pairKey(base.base, item));
|
||||
|
||||
/** The pairs of the picked base model or item not tried yet: [base, item]. */
|
||||
export function untried(net, pick) {
|
||||
if (isBase(pick)) {
|
||||
const base = net.bases.find(entry => entry.base === pick.base);
|
||||
return base ? net.items.filter(item => canTry(net, base, item)).map(item => [base.base, item]) : [];
|
||||
}
|
||||
const item = isItem(pick) && net.items.find(entry => entry.kind === pick.kind && entry.name === pick.name);
|
||||
return item ? net.bases.filter(base => canTry(net, base, item)).map(base => [base.base, item]) : [];
|
||||
}
|
||||
|
||||
/** "4 runs · 3 good", or the AI's word when the user gave none. */
|
||||
export function short(count) {
|
||||
const judged = look(count);
|
||||
return t(judged.ai ? 'aiNetShortAi' : 'aiNetShort', { runs: count.runs, good: judged.ai ? count.ai_good : count.good });
|
||||
}
|
||||
|
||||
export function countWords(count, keys) {
|
||||
return keys.filter(key => count[key]).map(key => t(`aiNetCount_${key}`, { n: count[key] })).join(' · ');
|
||||
}
|
||||
|
||||
/** The numbers over the views: runs here, judged by the user, by the AI, pairs that could be tried, rounds. */
|
||||
export function totals(net) {
|
||||
const models = Object.values(net.data.by_model || {});
|
||||
const sum = key => models.reduce((all, count) => all + (count[key] || 0), 0);
|
||||
return {
|
||||
runs: sum('runs'), judged: sum('good') + sum('bad') + sum('passed'), ai: sum('ai'),
|
||||
untried: net.bases.reduce((all, base) => all + net.items.filter(item => canTry(net, base, item)).length, 0),
|
||||
rounds: (net.data.rounds || []).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** A thin bar of how `counts` (edges' counts summed) turned out: good, not good, not chosen, not judged. */
|
||||
export function meter(counts) {
|
||||
const sum = key => counts.reduce((all, count) => all + count[key], 0);
|
||||
const runs = sum('runs');
|
||||
const bar = el('span', 'anomalous-ai-net-meter');
|
||||
if (!runs) return bar;
|
||||
const parts = { good: sum('good'), bad: sum('bad'), passed: sum('passed') };
|
||||
parts.open = Math.max(0, runs - parts.good - parts.bad - parts.passed);
|
||||
for (const [part, value] of Object.entries(parts)) {
|
||||
if (!value) continue;
|
||||
const piece = el('span', `anomalous-ai-net-meter-part is-${part}`);
|
||||
piece.style.flexGrow = String(value);
|
||||
bar.append(piece);
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* The training network's detail column (ui_ai_network.js): what is picked in the graph or the table.
|
||||
* Nothing picked: the training rounds started here (each opens its task) and how to read the
|
||||
* network. A base model: its models here with how often each ran, the items it ran with, those it has
|
||||
* not tried. An item: the base models it ran on and those not tried. A pair: how its runs turned out,
|
||||
* by the user and by the AI, and each run (GET /anomalous/ai_training/runs, newest first): when, on
|
||||
* which model, by which AI app, how it was judged, its pictures and its task. Each can start a
|
||||
* training round on it (`train(preset)`, ui_ai_training_setup.js).
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
import { imageResults } from './ui_ai_images.js';
|
||||
import { baseLabel, countWords, isBase, isPair, meter, short, touches, untried } from './ui_ai_network_data.js';
|
||||
|
||||
const MODELS_SHOWN = 12;
|
||||
const runsRead = new Map(); // pair key → {runs} | {error} | 'reading'
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = String(text);
|
||||
return node;
|
||||
}
|
||||
|
||||
function button(className, label, onClick) {
|
||||
const node = el('button', className, label);
|
||||
node.type = 'button';
|
||||
node.onclick = onClick;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The runs read so far are forgotten (the network was read again). */
|
||||
export function forgetRuns() {
|
||||
runsRead.clear();
|
||||
}
|
||||
|
||||
const when = seconds => new Date(seconds * 1000).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
const section = label => el('div', 'anomalous-ai-net-section', label);
|
||||
|
||||
/** A model's name from its file, as the network lists it, else the file without its extension. */
|
||||
function modelName(data, file) {
|
||||
return data.models.find(model => model.file === file)?.name || String(file || '').replace(/\.[^.]+$/, '') || t('aiNetModelUnknown');
|
||||
}
|
||||
|
||||
function rowsOf(edges, labelOf, choose) {
|
||||
return edges.map((edge) => {
|
||||
const row = button(`anomalous-ai-net-row is-${edge.look.tone}${edge.look.ai ? ' is-ai' : ''}`, undefined,
|
||||
() => choose({ base: edge.base, kind: edge.item.kind, name: edge.item.name }));
|
||||
row.append(el('span', 'anomalous-ai-net-row-name', labelOf(edge)), el('span', 'anomalous-ai-net-row-count', short(edge.count)));
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function chips(pairs, labelOf, choose) {
|
||||
const box = el('div', 'anomalous-ai-net-chips');
|
||||
for (const [base, item] of pairs) box.append(button('anomalous-ai-net-chip', `+ ${labelOf(base, item)}`, () => choose({ base, kind: item.kind, name: item.name })));
|
||||
return box;
|
||||
}
|
||||
|
||||
/** One run of a pair: when, which model and AI, how it was judged, its pictures, its task. */
|
||||
function runRow(run, data, ctx) {
|
||||
const row = el('div', 'anomalous-ai-net-run');
|
||||
const top = el('div', 'anomalous-ai-net-run-top');
|
||||
top.append(el('span', 'anomalous-ai-net-run-when', when(run.at)), el('span', 'anomalous-ai-net-run-model', modelName(data, run.model)));
|
||||
if (run.client) top.append(el('span', 'anomalous-ai-net-run-client', run.client));
|
||||
const marks = el('div', 'anomalous-ai-net-run-marks');
|
||||
const user = run.state === 'error' ? 'error' : run.judged ? `${run.judged}_${run.by}` : 'none';
|
||||
marks.append(el('span', `anomalous-ai-net-mark is-${run.state === 'error' ? 'bad' : run.judged || 'none'}`, t(`aiNetJudged_${user}`)));
|
||||
if (run.ai) marks.append(el('span', `anomalous-ai-net-mark is-ai is-${run.ai === 'picked' ? 'good' : 'passed'}`, t(`aiNetAiMark_${run.ai}`)));
|
||||
row.append(top, marks);
|
||||
const pictures = run.pictures.map(([subfolder, filename]) => ({ type: 'image', folder: 'output', filename, subfolder }));
|
||||
if (pictures.length) row.append(imageResults(pictures, ctx.owner, { compact: true }));
|
||||
if (run.task) row.append(button('anomalous-ai-net-link', t('aiNetOpenTask'), () => ctx.openTask(run.task)));
|
||||
return row;
|
||||
}
|
||||
|
||||
/** The runs of a pair, read when first shown. */
|
||||
function runsBox(edge, data, ctx, redraw) {
|
||||
const key = JSON.stringify([edge.item.kind, edge.item.name, edge.base]);
|
||||
const known = runsRead.get(key);
|
||||
const box = el('div', 'anomalous-ai-net-runs');
|
||||
if (!known) {
|
||||
runsRead.set(key, 'reading');
|
||||
const query = new URLSearchParams({ kind: edge.item.kind, name: edge.item.name, base: edge.base });
|
||||
fetch(`/anomalous/ai_training/runs?${query}`).then(async (response) => {
|
||||
const answer = await response.json().catch(() => ({}));
|
||||
runsRead.set(key, response.ok ? { runs: answer.runs || [] } : { error: answer.error || response.statusText });
|
||||
}).catch(error => runsRead.set(key, { error: error.message })).finally(redraw);
|
||||
}
|
||||
if (!known || known === 'reading') box.append(el('div', 'anomalous-ai-net-hint', t('aiNetRunsReading')));
|
||||
else if (known.error) box.append(el('div', 'anomalous-ai-net-hint', t('aiNetUnread', { error: known.error })));
|
||||
else box.append(...known.runs.map(run => runRow(run, data, ctx)));
|
||||
return box;
|
||||
}
|
||||
|
||||
/** Nothing picked: the training rounds and how to read the network. */
|
||||
function roundsBox(net, ctx, train) {
|
||||
const box = el('div', 'anomalous-ai-net-detail-body');
|
||||
box.append(section(t('aiNetRounds')));
|
||||
const rounds = net.data.rounds || [];
|
||||
if (!rounds.length) box.append(el('div', 'anomalous-ai-net-hint', t('aiNetRoundsNone')));
|
||||
for (const round of rounds) {
|
||||
const row = el('div', 'anomalous-ai-net-round');
|
||||
const status = round.done >= round.total ? 'done' : !round.taken ? 'waiting' : round.ended ? 'ended' : 'going';
|
||||
const top = el('div', 'anomalous-ai-net-run-top');
|
||||
top.append(el('span', 'anomalous-ai-net-round-title', round.title || round.target), el('span', `anomalous-ai-net-mark is-${status === 'done' ? 'good' : 'none'}`, t(`aiNetRound_${status}`)));
|
||||
const progress = el('div', 'anomalous-ai-net-progress');
|
||||
const fill = el('span', 'anomalous-ai-net-progress-fill');
|
||||
fill.style.width = `${round.total ? Math.round(round.done / round.total * 100) : 0}%`;
|
||||
progress.append(fill);
|
||||
row.append(top, el('div', 'anomalous-ai-net-detail-line is-soft', t('aiNetRoundLine', { when: when(round.started), done: round.done, total: round.total, models: round.models.join('、') })),
|
||||
progress, button('anomalous-ai-net-link', t('aiNetOpenTask'), () => ctx.openTask(round.id)));
|
||||
box.append(row);
|
||||
}
|
||||
box.append(button('anomalous-ai-net-train', t('aiNetTrain'), () => train({})), section(t('aiNetHow')),
|
||||
el('div', 'anomalous-ai-net-hint', t(net.edges.length ? 'aiNetHint' : 'aiNetEmpty')));
|
||||
return box;
|
||||
}
|
||||
|
||||
/** A base model: its models here, the items it ran with, what it has not tried. */
|
||||
function baseBox(net, pick, choose, train) {
|
||||
const base = net.bases.find(entry => entry.base === pick.base);
|
||||
if (!base) return null;
|
||||
const box = el('div', 'anomalous-ai-net-detail-body');
|
||||
const edges = net.edges.filter(edge => edge.base === base.base);
|
||||
box.append(el('div', 'anomalous-ai-net-detail-title', baseLabel(base.base)),
|
||||
el('div', 'anomalous-ai-net-detail-line is-soft', base.models ? t('aiNetModelsHere', { count: base.models }) : t('aiNetNotHere')),
|
||||
meter(edges.map(edge => edge.count)));
|
||||
const runsOf = file => net.data.by_model?.[file]?.runs || 0;
|
||||
const models = net.data.models.filter(model => model.base === base.base).sort((a, b) => runsOf(b.file) - runsOf(a.file) || a.name.localeCompare(b.name));
|
||||
if (models.length) {
|
||||
box.append(section(t('aiNetTheseModels')));
|
||||
const list = el('div', 'anomalous-ai-net-models');
|
||||
for (const model of models.slice(0, MODELS_SHOWN)) {
|
||||
const row = el('div', 'anomalous-ai-net-model');
|
||||
row.append(el('span', 'anomalous-ai-net-row-name', model.name),
|
||||
el('span', 'anomalous-ai-net-row-count', runsOf(model.file) ? t('aiNetRunsShort', { runs: runsOf(model.file) }) : t('aiNetNeverRan')));
|
||||
list.append(row);
|
||||
}
|
||||
if (models.length > MODELS_SHOWN) list.append(el('div', 'anomalous-ai-net-hint', t('aiNetMoreModels', { count: models.length - MODELS_SHOWN })));
|
||||
box.append(list);
|
||||
}
|
||||
if (edges.length) box.append(section(t('aiNetRanWith')), ...rowsOf(edges, edge => edge.item.title, choose));
|
||||
const open = untried(net, pick);
|
||||
if (open.length) box.append(section(t('aiNetNotTried')), chips(open, (_base, item) => item.title, choose));
|
||||
if (base.models) box.append(button('anomalous-ai-net-train', t('aiNetTrainBase'), () => train({ base: base.base })));
|
||||
return box;
|
||||
}
|
||||
|
||||
/** An item: the base models it ran on, those not tried. */
|
||||
function itemBox(net, pick, choose, train) {
|
||||
const item = net.items.find(entry => entry.kind === pick.kind && entry.name === pick.name);
|
||||
if (!item) return null;
|
||||
const box = el('div', 'anomalous-ai-net-detail-body');
|
||||
const edges = net.edges.filter(edge => edge.item === item);
|
||||
box.append(el('div', 'anomalous-ai-net-detail-title', item.title), el('div', 'anomalous-ai-net-detail-line is-soft', item.sub), meter(edges.map(edge => edge.count)));
|
||||
if (edges.length) box.append(section(t('aiNetRanOn')), ...rowsOf(edges, edge => baseLabel(edge.base), choose));
|
||||
const open = untried(net, pick);
|
||||
if (open.length) box.append(section(t('aiNetNotTried')), chips(open, base => baseLabel(base), choose));
|
||||
if (item.usable) box.append(button('anomalous-ai-net-train', t('aiNetTrainItem'), () => train({ item })));
|
||||
return box;
|
||||
}
|
||||
|
||||
/** A pair: how it turned out, then each run. */
|
||||
function pairBox(net, pick, ctx, train, redraw) {
|
||||
const item = net.items.find(entry => entry.kind === pick.kind && entry.name === pick.name);
|
||||
if (!item) return null;
|
||||
const box = el('div', 'anomalous-ai-net-detail-body');
|
||||
box.append(el('div', 'anomalous-ai-net-detail-title', `${baseLabel(pick.base)} × ${item.title}`), el('div', 'anomalous-ai-net-detail-line is-soft', item.sub));
|
||||
const edge = net.edges.find(entry => touches(entry, pick));
|
||||
if (!edge) {
|
||||
box.append(el('div', 'anomalous-ai-net-detail-line', t('aiNetPairUntried')));
|
||||
if (item.usable) box.append(button('anomalous-ai-net-train', t('aiNetTrainThis'), () => train({ item, base: pick.base })));
|
||||
return box;
|
||||
}
|
||||
const count = edge.count;
|
||||
const user = countWords(count, ['good', 'bad', 'passed']);
|
||||
box.append(meter([count]), el('div', 'anomalous-ai-net-detail-line', t('aiNetRuns', { runs: count.runs })),
|
||||
el('div', 'anomalous-ai-net-detail-line', user ? t('aiNetUser', { parts: user }) : t('aiNetUserNone')));
|
||||
if (count.ai_good || count.ai_passed) box.append(el('div', 'anomalous-ai-net-detail-line', t('aiNetAi', { parts: countWords(count, ['ai_good', 'ai_passed']) })));
|
||||
if (count.failed) box.append(el('div', 'anomalous-ai-net-detail-line', countWords(count, ['failed'])));
|
||||
if (count.clients.length) box.append(el('div', 'anomalous-ai-net-detail-line is-soft', t('aiRecordBy', { names: count.clients.join(', ') })));
|
||||
if (item.usable) box.append(button('anomalous-ai-net-train', t('aiNetTrainThis'), () => train({ item, base: pick.base })));
|
||||
box.append(section(t('aiNetEachRun')), runsBox(edge, net.data, ctx, redraw));
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* The column for what is picked (`chosen`). `opts`: {choose(pick), train(preset), redraw(), ctx: {owner, openTask(id)}}.
|
||||
*/
|
||||
export function detailBox(net, chosen, { choose, train, redraw, ctx }) {
|
||||
const box = el('div', 'anomalous-ai-net-detail');
|
||||
const shown = !chosen ? roundsBox(net, ctx, train)
|
||||
: isPair(chosen) ? pairBox(net, chosen, ctx, train, redraw)
|
||||
: isBase(chosen) ? baseBox(net, chosen, choose, train) : itemBox(net, chosen, choose, train);
|
||||
if (chosen) box.append(button('anomalous-ai-net-link', t('aiNetUnpick'), () => choose(chosen)));
|
||||
if (chosen && !shown) box.append(el('div', 'anomalous-ai-net-hint', t('aiNetGone')));
|
||||
box.append(shown || roundsBox(net, ctx, train));
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* The training network's two drawings (ui_ai_network.js). The graph: the base models left, the ways,
|
||||
* skills and node packs right, a line for each pair that ran, coloured by how it turned out (dashed:
|
||||
* only the AI judged it) and as wide as it ran often, drawn over the two columns from where their
|
||||
* nodes are (a ResizeObserver redraws them); with a node picked, faint dashes lead to what it has not
|
||||
* tried. The table: a column per base model, a row per item grouped by kind (those that ran most
|
||||
* first), a cell per pair saying
|
||||
* how many of its runs turned out well, "+" where the pair could still be tried. A click on a node,
|
||||
* a line, a heading or a cell picks it (`choose`).
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
import { KINDS, baseLabel, canTry, isPair, meter, pairKey, same, short, touches, untried } from './ui_ai_network_data.js';
|
||||
|
||||
const SVG = 'http://www.w3.org/2000/svg';
|
||||
let watcher = null; // redraws the graph's lines when its size changes
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = String(text);
|
||||
return node;
|
||||
}
|
||||
|
||||
function button(className, label, onClick) {
|
||||
const node = el('button', className, label);
|
||||
node.type = 'button';
|
||||
node.onclick = onClick;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The graph's lines stop following its size (another drawing, or the network closed). */
|
||||
export function releaseGraph() {
|
||||
watcher?.disconnect();
|
||||
watcher = null;
|
||||
}
|
||||
|
||||
/** A node of the graph or a heading of the table: its name, a line under it, how its runs turned out. */
|
||||
function nodeFace(node, name, sub, counts, runs) {
|
||||
const top = el('span', 'anomalous-ai-net-node-top');
|
||||
top.append(el('span', 'anomalous-ai-net-node-name', name));
|
||||
if (runs) top.append(el('span', 'anomalous-ai-net-node-runs', t('aiNetRunsShort', { runs })));
|
||||
node.append(top, el('span', 'anomalous-ai-net-node-sub', sub), meter(counts));
|
||||
node.title = name;
|
||||
return node;
|
||||
}
|
||||
|
||||
const baseSub = base => (base.models ? t('aiNetModelsHere', { count: base.models }) : t('aiNetNotHere'));
|
||||
|
||||
/** The lines between the columns, drawn from where the nodes are now. */
|
||||
function drawLines(svg, graph, nodes, net, chosen, open) {
|
||||
const box = graph.getBoundingClientRect();
|
||||
svg.setAttribute('width', String(box.width));
|
||||
svg.setAttribute('height', String(box.height));
|
||||
svg.replaceChildren();
|
||||
const at = (node, right) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return [(right ? rect.right : rect.left) - box.left, rect.top + rect.height / 2 - box.top];
|
||||
};
|
||||
const line = (base, item, className, width, onClick, title) => {
|
||||
const from = nodes.get(`base:${base}`);
|
||||
const to = nodes.get(item.key);
|
||||
if (!from || !to) return;
|
||||
const [x1, y1] = at(from, true);
|
||||
const [x2, y2] = at(to, false);
|
||||
const middle = (x1 + x2) / 2;
|
||||
const shape = `M${x1} ${y1} C${middle} ${y1} ${middle} ${y2} ${x2} ${y2}`;
|
||||
const path = document.createElementNS(SVG, 'path');
|
||||
path.setAttribute('d', shape);
|
||||
path.setAttribute('class', className);
|
||||
path.style.strokeWidth = `${width}px`;
|
||||
svg.append(path);
|
||||
if (!onClick) return;
|
||||
const hit = document.createElementNS(SVG, 'path');
|
||||
hit.setAttribute('d', shape);
|
||||
hit.setAttribute('class', 'anomalous-ai-net-hit');
|
||||
hit.addEventListener('click', onClick);
|
||||
const tip = document.createElementNS(SVG, 'title');
|
||||
tip.textContent = title;
|
||||
hit.append(tip);
|
||||
svg.append(hit);
|
||||
};
|
||||
for (const [base, item] of open) line(base, item, 'anomalous-ai-net-line is-untried', 1.5);
|
||||
for (const edge of net.edges) {
|
||||
const own = touches(edge, chosen);
|
||||
const picked = isPair(chosen) && own;
|
||||
line(edge.base, edge.item, `anomalous-ai-net-line is-${edge.look.tone}${edge.look.ai ? ' is-ai' : ''}${own ? '' : ' is-dim'}${picked ? ' is-chosen' : ''}`,
|
||||
2 + Math.min(edge.count.runs, 12) * 0.5, edge.onClick, `${baseLabel(edge.base)} × ${edge.item.title}: ${short(edge.count)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The graph: base models left, items right, the lines between. */
|
||||
export function graphBox(net, chosen, choose) {
|
||||
const open = untried(net, chosen);
|
||||
for (const edge of net.edges) edge.onClick = () => choose({ base: edge.base, kind: edge.item.kind, name: edge.item.name });
|
||||
const graph = el('div', 'anomalous-ai-net-graph');
|
||||
const svg = document.createElementNS(SVG, 'svg');
|
||||
svg.setAttribute('class', 'anomalous-ai-net-lines');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
const nodes = new Map();
|
||||
const lit = new Set(open.flatMap(([base, item]) => [`base:${base}`, item.key]));
|
||||
const column = (head, entries, keyOf, nameOf, subOf, pickOf, countsOf, extra) => {
|
||||
const box = el('div', `anomalous-ai-net-col ${extra}`);
|
||||
box.append(el('div', 'anomalous-ai-net-head', head));
|
||||
for (const entry of entries) {
|
||||
const pick = pickOf(entry);
|
||||
const on = same(chosen, pick);
|
||||
const dim = chosen && !on && !net.edges.some(edge => touches(edge, chosen) && touches(edge, pick)) && !lit.has(keyOf(entry));
|
||||
const counts = countsOf(entry);
|
||||
const node = button(`anomalous-ai-net-node${on ? ' is-chosen' : ''}${dim ? ' is-dim' : ''}`, undefined, () => choose(pick));
|
||||
node.setAttribute('aria-pressed', String(on));
|
||||
nodes.set(keyOf(entry), nodeFace(node, nameOf(entry), subOf(entry), counts, counts.reduce((all, count) => all + count.runs, 0)));
|
||||
box.append(node);
|
||||
}
|
||||
return box;
|
||||
};
|
||||
graph.append(svg,
|
||||
column(t('aiNetModels'), net.bases, base => `base:${base.base}`, base => baseLabel(base.base), baseSub,
|
||||
base => ({ base: base.base }), base => net.edges.filter(edge => edge.base === base.base).map(edge => edge.count), 'is-bases'),
|
||||
column(t('aiNetItems'), net.items, item => item.key, item => item.title, item => item.sub,
|
||||
item => ({ kind: item.kind, name: item.name }), item => net.edges.filter(edge => edge.item === item).map(edge => edge.count), 'is-items'));
|
||||
const draw = () => drawLines(svg, graph, nodes, net, chosen, open);
|
||||
releaseGraph();
|
||||
watcher = new ResizeObserver(draw);
|
||||
watcher.observe(graph);
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** A cell's words: good runs of all, by the user (else the AI's picks, else only the runs). */
|
||||
function cellText(edge) {
|
||||
const count = edge.count;
|
||||
if (edge.look.tone === 'ran') return String(count.runs);
|
||||
return `${edge.look.ai ? count.ai_good : count.good}/${count.runs}`;
|
||||
}
|
||||
|
||||
/** The table: a column per base model, a row per item (by kind), a cell per pair. */
|
||||
export function matrixBox(net, chosen, choose) {
|
||||
releaseGraph();
|
||||
const wrap = el('div', 'anomalous-ai-net-matrix-wrap');
|
||||
const table = el('table', 'anomalous-ai-net-matrix');
|
||||
const head = el('tr');
|
||||
const corner = el('th', 'anomalous-ai-net-corner', t('aiNetItems'));
|
||||
corner.scope = 'col';
|
||||
head.append(corner);
|
||||
for (const base of net.bases) {
|
||||
const pick = { base: base.base };
|
||||
const cell = el('th', `anomalous-ai-net-colhead${chosen?.base === base.base ? ' is-lit' : ''}`);
|
||||
cell.scope = 'col';
|
||||
const counts = net.edges.filter(edge => edge.base === base.base).map(edge => edge.count);
|
||||
cell.append(nodeFace(button(`anomalous-ai-net-node is-head${same(chosen, pick) ? ' is-chosen' : ''}`, undefined, () => choose(pick)),
|
||||
baseLabel(base.base), baseSub(base), counts, counts.reduce((all, count) => all + count.runs, 0)));
|
||||
head.append(cell);
|
||||
}
|
||||
const body = el('tbody');
|
||||
const edges = new Map(net.edges.map(edge => [pairKey(edge.base, edge.item), edge]));
|
||||
const runsOf = kind => net.items.filter(item => item.kind === kind).reduce((all, item) => all + item.runs, 0);
|
||||
for (const kind of [...KINDS].sort((a, b) => runsOf(b) - runsOf(a))) { // the kinds that ran most first
|
||||
const rows = net.items.filter(item => item.kind === kind);
|
||||
if (!rows.length) continue;
|
||||
const group = el('tr', 'anomalous-ai-net-group');
|
||||
const label = el('th', '', t(`aiNetKind_${kind}`));
|
||||
label.colSpan = net.bases.length + 1;
|
||||
group.append(label);
|
||||
body.append(group);
|
||||
for (const item of rows) {
|
||||
const pick = { kind: item.kind, name: item.name };
|
||||
const row = el('tr', chosen?.kind === item.kind && chosen?.name === item.name ? 'is-lit' : '');
|
||||
const rowHead = el('th', 'anomalous-ai-net-rowhead');
|
||||
rowHead.scope = 'row';
|
||||
const counts = net.edges.filter(edge => edge.item === item).map(edge => edge.count);
|
||||
rowHead.append(nodeFace(button(`anomalous-ai-net-node is-head${same(chosen, pick) ? ' is-chosen' : ''}`, undefined, () => choose(pick)),
|
||||
item.title, item.sub, counts, item.runs));
|
||||
row.append(rowHead);
|
||||
for (const base of net.bases) {
|
||||
const edge = edges.get(pairKey(base.base, item));
|
||||
const pair = { base: base.base, kind: item.kind, name: item.name };
|
||||
const cell = el('td', chosen?.base === base.base ? 'is-lit' : '');
|
||||
const on = same(chosen, pair) ? ' is-chosen' : '';
|
||||
if (edge) {
|
||||
const node = button(`anomalous-ai-net-cell is-${edge.look.tone}${edge.look.ai ? ' is-ai' : ''}${on}`, cellText(edge), () => choose(pair));
|
||||
node.title = `${baseLabel(base.base)} × ${item.title}: ${short(edge.count)}`;
|
||||
cell.append(node);
|
||||
} else if (canTry(net, base, item)) {
|
||||
const node = button(`anomalous-ai-net-cell is-open${on}`, '+', () => choose(pair));
|
||||
node.title = t('aiNetCellOpen', { base: baseLabel(base.base), item: item.title });
|
||||
cell.append(node);
|
||||
} else {
|
||||
cell.append(el('span', 'anomalous-ai-net-cell is-none'));
|
||||
}
|
||||
row.append(cell);
|
||||
}
|
||||
body.append(row);
|
||||
}
|
||||
}
|
||||
const thead = el('thead');
|
||||
thead.append(head);
|
||||
table.append(thead, body);
|
||||
wrap.append(table);
|
||||
return wrap;
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
* shape of a run at full size (ui_ai_run_map.js; the newest run, or one picked from the list or
|
||||
* from its step in the timeline), the task's decisions (ui_ai_decisions.js), the materials
|
||||
* picker (ui_ai_toolbox.js) docked there, what the task made (ui_ai_outputs.js), the ready-made
|
||||
* ways the AI may use (ui_ai_blocks.js), or the training network (ui_ai_network.js).
|
||||
* Picking the view that is shown closes the column. Each view keeps its own pane, so going back
|
||||
* ways the AI may use (ui_ai_blocks.js). The switch's training network (ui_ai_network.js) opens over
|
||||
* the whole page instead, and picking another view closes it. Picking the view that is shown closes the column. Each view keeps its own pane, so going back
|
||||
* finds it as it was: a scrolled stage, materials picked but not handed over yet. The column is a
|
||||
* card of its own beside the timeline; its left edge drags it wider or narrower (kept on this
|
||||
* browser, the timeline keeps room; the whole gap beside the card catches the pointer, and the
|
||||
@@ -23,7 +23,7 @@ import { openQuestions, panelSteps, renderStage, sideHead, tabBar } from './ui_a
|
||||
import { renderDecisionsView } from './ui_ai_decisions.js';
|
||||
import { renderOutputsView } from './ui_ai_outputs.js';
|
||||
import { renderBlocksView } from './ui_ai_blocks.js';
|
||||
import { renderNetworkView } from './ui_ai_network.js';
|
||||
import { closeNetwork, openNetwork, setNetworkContext } from './ui_ai_network.js';
|
||||
|
||||
const WIDTH_KEY = 'anomalous.aiSideWidth';
|
||||
const WIDTH = { min: 340, start: 440 };
|
||||
@@ -90,6 +90,11 @@ export function sideSwitch(side, shown, badges, onPick) {
|
||||
* The materials view opens the picker first when it is not open (it comes in through its host).
|
||||
*/
|
||||
export function pickView(side, view, shown, task, redraw, changed) {
|
||||
if (view === 'network') {
|
||||
openNetwork();
|
||||
return;
|
||||
}
|
||||
closeNetwork();
|
||||
if (shown && side.view === view && side.tab === 'stage') {
|
||||
side.hidden = true;
|
||||
side.tab = 'timeline';
|
||||
@@ -105,7 +110,7 @@ export function pickView(side, view, shown, task, redraw, changed) {
|
||||
/** Whether the column is on screen: not closed, and its view has something (the stage: panels, or
|
||||
* the user asked for it, and sees it is empty; the picker: open). */
|
||||
export function sideShown(side, task) {
|
||||
if (side.view === 'toolbox' && !toolboxOpenFor(task.id)) side.view = 'stage';
|
||||
if ((side.view === 'toolbox' && !toolboxOpenFor(task.id)) || side.view === 'network') side.view = 'stage';
|
||||
return !side.hidden && (side.view !== 'stage' || panelSteps(task).length > 0 || Boolean(side.asked));
|
||||
}
|
||||
|
||||
@@ -163,7 +168,7 @@ function grip(box) {
|
||||
/** The side column's panes into `box` (once): the narrow window's tabs, the grip, then one pane per view. */
|
||||
function sidePanes(box) {
|
||||
if (!box.querySelector(':scope > .anomalous-ai-side-pane')) {
|
||||
box.replaceChildren(el('div', 'anomalous-ai-tabs'), grip(box), ...Object.keys(VIEWS).map(view => {
|
||||
box.replaceChildren(el('div', 'anomalous-ai-tabs'), grip(box), ...Object.keys(VIEWS).filter(view => view !== 'network').map(view => {
|
||||
const pane = el('div', `anomalous-ai-side-pane is-${view}`);
|
||||
pane.dataset.view = view;
|
||||
return pane;
|
||||
@@ -213,7 +218,7 @@ export function drawSide(box, task, side, ctx, pickTab, arrived = null) {
|
||||
const panes = sidePanes(box);
|
||||
if (panes.toolbox.dataset.task && panes.toolbox.dataset.task !== task.id) closeToolbox();
|
||||
const opened = panes.blocks.hidden && side.view === 'blocks'; // the ways are read again when the view comes up
|
||||
const netOpened = panes.network.hidden && side.view === 'network'; // so is the training network
|
||||
setNetworkContext(ctx);
|
||||
for (const [view, pane] of Object.entries(panes)) pane.hidden = view !== side.view;
|
||||
const asks = panelSteps(task).reduce((sum, step) => sum + openQuestions(step), 0);
|
||||
box.querySelector(':scope > .anomalous-ai-tabs').replaceWith(tabBar(side, asks, pickTab, viewLabel(side.view)));
|
||||
@@ -232,7 +237,6 @@ export function drawSide(box, task, side, ctx, pickTab, arrived = null) {
|
||||
panes.blocks.dataset.private = privacy;
|
||||
renderBlocksView(panes.blocks, ctx, opened, task.private === true);
|
||||
}
|
||||
if (side.view === 'network' && (netOpened || !panes.network.childElementCount)) renderNetworkView(panes.network, ctx, netOpened);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,8 +59,8 @@ export function closeWorkspace() {
|
||||
restoreWorkspaceReturnPanel(this);
|
||||
}
|
||||
|
||||
// The back buttons of the pages one level down: a model's details, one combo, one recipe.
|
||||
const BACK_BUTTONS = '.anomalous-detail-back, .anomalous-nb-back, .anomalous-wfd-back, .anomalous-sd-tune-back, .anomalous-ai-back';
|
||||
// The back buttons of the pages one level down: a model's details, one combo, one recipe, the AI's training network.
|
||||
const BACK_BUTTONS = '.anomalous-detail-back, .anomalous-nb-back, .anomalous-wfd-back, .anomalous-sd-tune-back, .anomalous-ai-netx-back, .anomalous-ai-back';
|
||||
|
||||
/**
|
||||
* Esc on the browser: a page one level down (a model's details, one combo or one recipe) goes
|
||||
|
||||
+1
-1
@@ -48,4 +48,4 @@
|
||||
@import url("./styles/47-ai-decisions.css?v=20261024-feel");
|
||||
@import url("./styles/48-ai-blocks.css?v=20261027-ways");
|
||||
@import url("./styles/49-model-versions.css?v=20261030-versions");
|
||||
@import url("./styles/50-ai-network.css?v=20261101-network");
|
||||
@import url("./styles/50-ai-network.css?v=20261101-netfull");
|
||||
|
||||
+209
-45
@@ -1,42 +1,116 @@
|
||||
/* The side column's training network (ui_ai_network.js): models left, ways and skills right, the
|
||||
lines between them coloured by how they turned out; the training round's form under it
|
||||
(ui_ai_training_setup.js). */
|
||||
/* The training network (ui_ai_network.js) over the AI page: its head, numbers and filters, the graph
|
||||
or the table (ui_ai_network_graph.js) beside the detail column (ui_ai_network_detail.js); the
|
||||
training round's form in their place (ui_ai_training_setup.js). */
|
||||
|
||||
.anomalous-ai-net { display: flex; flex-direction: column; gap: 12px; }
|
||||
.anomalous-ai-net-train {
|
||||
align-self: flex-start;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
#anomalous-ai-panel { position: relative; } /* the network covers the page */
|
||||
|
||||
.anomalous-ai-netx {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--amb-bg-panel);
|
||||
color: var(--amb-text-main);
|
||||
outline: none;
|
||||
}
|
||||
.anomalous-ai-netx-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--amb-border);
|
||||
}
|
||||
.anomalous-ai-netx-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px 5px 6px;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: var(--amb-radius-control, 7px);
|
||||
background: var(--amb-btn-primary-bg);
|
||||
color: var(--amb-btn-primary-text);
|
||||
background: none;
|
||||
color: var(--amb-text-soft);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anomalous-ai-net-train:hover { background: var(--amb-btn-primary-hover); }
|
||||
.anomalous-ai-net-train:disabled { cursor: progress; opacity: 0.7; }
|
||||
.anomalous-ai-net-train.is-small { padding: 4px 11px; font-size: 12.5px; }
|
||||
.anomalous-ai-netx-back:hover { border-color: var(--amb-border-hover); color: var(--amb-text-main); }
|
||||
.anomalous-ai-netx-title { display: flex; align-items: center; gap: 7px; margin: 0; color: var(--amb-text-main); font-size: 16px; font-weight: 600; }
|
||||
.anomalous-ai-netx-seen {
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: 999px;
|
||||
color: var(--amb-text-soft);
|
||||
font-size: 12px;
|
||||
cursor: help;
|
||||
}
|
||||
.anomalous-ai-netx-seen.is-on { border-color: color-mix(in srgb, var(--amb-warn) 55%, transparent); color: var(--amb-warn); }
|
||||
.anomalous-ai-netx-gap { flex: 1; }
|
||||
|
||||
.anomalous-ai-netx-tiles { display: flex; flex-wrap: wrap; gap: 10px; padding: 12px 16px 0; }
|
||||
.anomalous-ai-netx-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 108px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: 10px;
|
||||
background: var(--amb-bg-card);
|
||||
}
|
||||
.anomalous-ai-netx-tile-value { font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.anomalous-ai-netx-tile-label { color: var(--amb-text-soft); font-size: 12px; }
|
||||
.anomalous-ai-netx-tile.is-untried .anomalous-ai-netx-tile-value { color: var(--amb-link); }
|
||||
|
||||
.anomalous-ai-netx-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 400px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--amb-border);
|
||||
}
|
||||
.anomalous-ai-netx-main { display: flex; flex-direction: column; gap: 14px; min-width: 0; overflow: auto; padding: 14px 16px 20px; }
|
||||
.anomalous-ai-netx-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }
|
||||
.anomalous-ai-netx-seg .anomalous-ai-gates-option { padding: 5px 12px; font-size: 12.5px; }
|
||||
.anomalous-ai-netx-check { display: inline-flex; align-items: center; gap: 6px; color: var(--amb-text-soft); font-size: 12.5px; cursor: pointer; }
|
||||
.anomalous-ai-netx-check input { accent-color: var(--amb-link); }
|
||||
.anomalous-ai-netx-setup { flex: 1; min-height: 0; overflow: auto; padding: 16px; }
|
||||
.anomalous-ai-netx-setup .anomalous-ai-train { max-width: 640px; margin: 0 auto; }
|
||||
|
||||
.anomalous-ai-netx-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; color: var(--amb-text-soft); font-size: 12px; }
|
||||
.anomalous-ai-netx-key { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.anomalous-ai-netx-swatch { width: 22px; height: 0; border-top: 3px solid; }
|
||||
.anomalous-ai-netx-swatch.is-good { border-color: var(--amb-ok); }
|
||||
.anomalous-ai-netx-swatch.is-fair { border-color: var(--amb-warn); }
|
||||
.anomalous-ai-netx-swatch.is-bad { border-color: var(--amb-danger); }
|
||||
.anomalous-ai-netx-swatch.is-ran { border-color: rgba(var(--amb-ink-rgb), 0.35); }
|
||||
.anomalous-ai-netx-swatch.is-ai { border-top-style: dotted; border-color: var(--amb-ok); }
|
||||
.anomalous-ai-netx-swatch.is-untried { border-top: 2px dashed rgba(var(--amb-ink-rgb), 0.35); }
|
||||
.anomalous-ai-netx-key-note { flex-basis: 100%; color: var(--amb-text-dim); }
|
||||
|
||||
/* ---------- the graph ---------- */
|
||||
.anomalous-ai-net-graph {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 40%) minmax(40px, 1fr) minmax(0, 44%);
|
||||
grid-template-columns: minmax(180px, 30%) minmax(80px, 1fr) minmax(200px, 36%);
|
||||
}
|
||||
.anomalous-ai-net-lines { position: absolute; inset: 0; z-index: 0; overflow: visible; pointer-events: none; }
|
||||
.anomalous-ai-net-col { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.anomalous-ai-net-col { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.anomalous-ai-net-col.is-bases { grid-column: 1; }
|
||||
.anomalous-ai-net-col.is-items { grid-column: 3; }
|
||||
.anomalous-ai-net-head { margin-bottom: 2px; color: var(--amb-text-soft); font-size: 12px; font-weight: 600; }
|
||||
.anomalous-ai-net-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
align-items: stretch;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 6px 9px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: 8px;
|
||||
border-radius: 9px;
|
||||
background: var(--amb-bg-card);
|
||||
color: var(--amb-text-main);
|
||||
font: inherit;
|
||||
@@ -46,47 +120,86 @@
|
||||
}
|
||||
.anomalous-ai-net-node:hover { border-color: var(--amb-border-hover); }
|
||||
.anomalous-ai-net-node.is-chosen { border-color: var(--amb-link); box-shadow: 0 0 0 1px var(--amb-link); }
|
||||
.anomalous-ai-net-node.is-dim { opacity: 0.45; }
|
||||
.anomalous-ai-net-node-name { max-width: 100%; overflow: hidden; font-size: 12.5px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.anomalous-ai-net-node-sub { max-width: 100%; overflow: hidden; color: var(--amb-text-dim); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.anomalous-ai-net-node.is-dim { opacity: 0.4; }
|
||||
.anomalous-ai-net-node-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; }
|
||||
.anomalous-ai-net-node-name { min-width: 0; overflow: hidden; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.anomalous-ai-net-node-runs { flex: none; color: var(--amb-text-soft); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||
.anomalous-ai-net-node-sub { min-width: 0; overflow: hidden; color: var(--amb-text-dim); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.anomalous-ai-net-meter { display: flex; gap: 1px; height: 4px; overflow: hidden; border-radius: 2px; background: rgba(var(--amb-ink-rgb), 0.08); }
|
||||
.anomalous-ai-net-meter-part.is-good { background: var(--amb-ok); }
|
||||
.anomalous-ai-net-meter-part.is-bad { background: var(--amb-danger); }
|
||||
.anomalous-ai-net-meter-part.is-passed { background: var(--amb-warn); }
|
||||
.anomalous-ai-net-meter-part.is-open { background: rgba(var(--amb-ink-rgb), 0.3); }
|
||||
|
||||
.anomalous-ai-net-line { fill: none; stroke-linecap: round; opacity: 0.85; transition: opacity 0.15s; }
|
||||
.anomalous-ai-net-line.is-good { stroke: var(--amb-ok); }
|
||||
.anomalous-ai-net-line.is-fair { stroke: var(--amb-warn); }
|
||||
.anomalous-ai-net-line.is-bad { stroke: var(--amb-danger); }
|
||||
.anomalous-ai-net-line.is-ran { stroke: rgba(var(--amb-ink-rgb), 0.35); }
|
||||
.anomalous-ai-net-line.is-ai { stroke-dasharray: 2 4; }
|
||||
.anomalous-ai-net-line.is-ai { stroke-dasharray: 2 5; }
|
||||
.anomalous-ai-net-line.is-untried { stroke: rgba(var(--amb-ink-rgb), 0.3); stroke-dasharray: 5 5; opacity: 1; }
|
||||
.anomalous-ai-net-line.is-dim { opacity: 0.12; }
|
||||
.anomalous-ai-net-line.is-chosen { opacity: 1; filter: drop-shadow(0 0 2px currentColor); }
|
||||
.anomalous-ai-net-hit { fill: none; stroke: transparent; stroke-width: 12px; pointer-events: stroke; cursor: pointer; }
|
||||
.anomalous-ai-net-hit { fill: none; stroke: transparent; stroke-width: 14px; pointer-events: stroke; cursor: pointer; }
|
||||
|
||||
/* Kept in sight at the column's foot while the graph scrolls. */
|
||||
.anomalous-ai-net-detail {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
flex: none;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--amb-border-strong);
|
||||
border-radius: 9px;
|
||||
background: var(--amb-bg-panel);
|
||||
box-shadow: 0 -6px 16px rgba(0, 0, 0, 0.18);
|
||||
/* ---------- the table ---------- */
|
||||
.anomalous-ai-net-matrix-wrap { overflow: auto; border: 1px solid var(--amb-border); border-radius: 10px; }
|
||||
.anomalous-ai-netx-main.is-table { overflow: hidden; } /* the table scrolls under its own sticky headings */
|
||||
.anomalous-ai-netx-main.is-table .anomalous-ai-net-matrix-wrap { flex: 1 1 auto; min-height: 120px; }
|
||||
.anomalous-ai-net-matrix { border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
|
||||
.anomalous-ai-net-matrix th, .anomalous-ai-net-matrix td { padding: 5px; border-bottom: 1px solid var(--amb-border); background: var(--amb-bg-panel); }
|
||||
.anomalous-ai-net-matrix thead th { position: sticky; top: 0; z-index: 2; vertical-align: bottom; }
|
||||
.anomalous-ai-net-matrix .anomalous-ai-net-corner { left: 0; z-index: 3; color: var(--amb-text-soft); font-weight: 600; text-align: left; }
|
||||
.anomalous-ai-net-matrix .anomalous-ai-net-rowhead { position: sticky; left: 0; z-index: 1; width: 240px; min-width: 200px; max-width: 280px; }
|
||||
.anomalous-ai-net-colhead { min-width: 120px; max-width: 170px; }
|
||||
.anomalous-ai-net-matrix .anomalous-ai-net-node.is-head { width: 100%; padding: 6px 9px; }
|
||||
.anomalous-ai-net-group th { padding: 10px 8px 4px; color: var(--amb-text-soft); font-size: 12px; font-weight: 600; text-align: left; }
|
||||
.anomalous-ai-net-matrix td { text-align: center; }
|
||||
.anomalous-ai-net-matrix td.is-lit, .anomalous-ai-net-matrix tr.is-lit td { background: color-mix(in srgb, var(--amb-link) 7%, var(--amb-bg-panel)); }
|
||||
.anomalous-ai-net-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 64px;
|
||||
height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: none;
|
||||
color: var(--amb-text-main);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anomalous-ai-net-cell.is-good { background: color-mix(in srgb, var(--amb-ok) 26%, transparent); }
|
||||
.anomalous-ai-net-cell.is-fair { background: color-mix(in srgb, var(--amb-warn) 26%, transparent); }
|
||||
.anomalous-ai-net-cell.is-bad { background: color-mix(in srgb, var(--amb-danger) 26%, transparent); }
|
||||
.anomalous-ai-net-cell.is-ran { background: rgba(var(--amb-ink-rgb), 0.1); color: var(--amb-text-soft); }
|
||||
.anomalous-ai-net-cell.is-ai { border-style: dashed; border-color: currentColor; }
|
||||
.anomalous-ai-net-cell.is-open { border: 1px dashed rgba(var(--amb-ink-rgb), 0.25); color: var(--amb-text-dim); font-weight: 400; }
|
||||
.anomalous-ai-net-cell.is-open:hover { border-color: var(--amb-link); color: var(--amb-link); }
|
||||
.anomalous-ai-net-cell.is-none { cursor: default; }
|
||||
.anomalous-ai-net-cell.is-none::before { content: '·'; color: var(--amb-text-dim); }
|
||||
.anomalous-ai-net-cell:not(.is-none):hover { filter: brightness(1.15); }
|
||||
.anomalous-ai-net-cell.is-chosen { box-shadow: 0 0 0 2px var(--amb-link); }
|
||||
|
||||
/* ---------- the detail column ---------- */
|
||||
.anomalous-ai-net-detail { display: flex; flex-direction: column; gap: 8px; min-width: 0; overflow-y: auto; padding: 14px 16px 20px; border-left: 1px solid var(--amb-border); }
|
||||
.anomalous-ai-net-detail-body { display: flex; flex-direction: column; gap: 7px; }
|
||||
.anomalous-ai-net-section { margin-top: 8px; color: var(--amb-text-soft); font-size: 12px; font-weight: 600; }
|
||||
.anomalous-ai-net-hint { color: var(--amb-text-soft); font-size: 12.5px; line-height: 1.5; }
|
||||
.anomalous-ai-net-detail-title { color: var(--amb-text-main); font-size: 13.5px; font-weight: 600; }
|
||||
.anomalous-ai-net-detail-title { color: var(--amb-text-main); font-size: 15px; font-weight: 600; }
|
||||
.anomalous-ai-net-detail-line { color: var(--amb-text-main); font-size: 12.5px; line-height: 1.5; }
|
||||
.anomalous-ai-net-detail-line.is-soft { color: var(--amb-text-soft); }
|
||||
.anomalous-ai-net-link { align-self: flex-start; padding: 0; border: none; background: none; color: var(--amb-link); font: inherit; font-size: 12.5px; cursor: pointer; }
|
||||
.anomalous-ai-net-link:hover { text-decoration: underline; }
|
||||
.anomalous-ai-net-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 4px 8px;
|
||||
padding: 5px 8px;
|
||||
border: none;
|
||||
border-left: 3px solid rgba(var(--amb-ink-rgb), 0.3);
|
||||
border-radius: 0;
|
||||
@@ -104,15 +217,66 @@
|
||||
.anomalous-ai-net-row.is-ai { border-left-style: dashed; }
|
||||
.anomalous-ai-net-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.anomalous-ai-net-row-count { flex: none; color: var(--amb-text-soft); }
|
||||
.anomalous-ai-net-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.anomalous-ai-net-chip {
|
||||
padding: 3px 10px;
|
||||
border: 1px dashed rgba(var(--amb-ink-rgb), 0.3);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--amb-text-soft);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anomalous-ai-net-chip:hover { border-color: var(--amb-link); color: var(--amb-link); }
|
||||
.anomalous-ai-net-models { display: flex; flex-direction: column; gap: 2px; }
|
||||
.anomalous-ai-net-model { display: flex; justify-content: space-between; gap: 10px; padding: 2px 0; font-size: 12.5px; }
|
||||
.anomalous-ai-net-runs, .anomalous-ai-net-run, .anomalous-ai-net-round { display: flex; flex-direction: column; gap: 6px; }
|
||||
.anomalous-ai-net-runs { gap: 10px; }
|
||||
.anomalous-ai-net-run, .anomalous-ai-net-round { padding: 9px 11px; border: 1px solid var(--amb-border); border-radius: 9px; background: var(--amb-bg-card); }
|
||||
.anomalous-ai-net-run-top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 12.5px; }
|
||||
.anomalous-ai-net-run-when { color: var(--amb-text-soft); font-variant-numeric: tabular-nums; }
|
||||
.anomalous-ai-net-run-model { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.anomalous-ai-net-run-client { color: var(--amb-text-dim); }
|
||||
.anomalous-ai-net-run-marks { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.anomalous-ai-net-mark { padding: 1px 8px; border: 1px solid transparent; border-radius: 999px; background: rgba(var(--amb-ink-rgb), 0.08); color: var(--amb-text-soft); font-size: 11.5px; }
|
||||
.anomalous-ai-net-mark.is-good { background: color-mix(in srgb, var(--amb-ok) 22%, transparent); color: var(--amb-text-main); }
|
||||
.anomalous-ai-net-mark.is-bad { background: color-mix(in srgb, var(--amb-danger) 22%, transparent); color: var(--amb-text-main); }
|
||||
.anomalous-ai-net-mark.is-passed { background: color-mix(in srgb, var(--amb-warn) 20%, transparent); color: var(--amb-text-main); }
|
||||
.anomalous-ai-net-mark.is-ai { border-style: dashed; border-color: rgba(var(--amb-ink-rgb), 0.3); }
|
||||
.anomalous-ai-net-round-title { min-width: 0; font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.anomalous-ai-net-progress { height: 5px; overflow: hidden; border-radius: 3px; background: rgba(var(--amb-ink-rgb), 0.1); }
|
||||
.anomalous-ai-net-progress-fill { display: block; height: 100%; background: var(--amb-link); }
|
||||
|
||||
/* The training round's form. */
|
||||
.anomalous-ai-net-train {
|
||||
align-self: flex-start;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: var(--amb-radius-control, 7px);
|
||||
background: var(--amb-btn-primary-bg);
|
||||
color: var(--amb-btn-primary-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anomalous-ai-net-train:hover { background: var(--amb-btn-primary-hover); }
|
||||
.anomalous-ai-net-train:disabled { cursor: progress; opacity: 0.7; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.anomalous-ai-netx-body { grid-template-columns: minmax(0, 1fr); overflow-y: auto; }
|
||||
.anomalous-ai-netx-main, .anomalous-ai-netx-main.is-table { overflow: visible; }
|
||||
.anomalous-ai-net-detail { overflow: visible; border-top: 1px solid var(--amb-border); border-left: none; }
|
||||
.anomalous-ai-net-graph { grid-template-columns: minmax(140px, 1fr) 40px minmax(150px, 1.2fr); }
|
||||
}
|
||||
|
||||
/* ---------- the training round's form ---------- */
|
||||
.anomalous-ai-train { display: flex; flex-direction: column; gap: 10px; }
|
||||
.anomalous-ai-train-back { align-self: flex-start; padding: 0; border: none; background: none; color: var(--amb-link); font: inherit; font-size: 12.5px; cursor: pointer; }
|
||||
.anomalous-ai-train-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.anomalous-ai-train-field[hidden] { display: none; }
|
||||
.anomalous-ai-train-label { color: var(--amb-text-soft); font-size: 12px; font-weight: 600; }
|
||||
.anomalous-ai-train-select { padding: 5px 8px; border: 1px solid var(--amb-border-strong); border-radius: 7px; background: var(--amb-bg-input); color: var(--amb-text-main); font: inherit; font-size: 13px; }
|
||||
.anomalous-ai-train-models { display: flex; flex-direction: column; gap: 2px; max-height: 220px; overflow-y: auto; padding: 4px 8px; border: 1px solid var(--amb-border); border-radius: 8px; }
|
||||
.anomalous-ai-train-models { display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow-y: auto; padding: 4px 8px; border: 1px solid var(--amb-border); border-radius: 8px; }
|
||||
.anomalous-ai-train-base { margin-top: 4px; color: var(--amb-text-dim); font-size: 11.5px; font-weight: 600; }
|
||||
.anomalous-ai-train-model { display: flex; align-items: center; gap: 6px; color: var(--amb-text-main); font-size: 12.5px; cursor: pointer; }
|
||||
.anomalous-ai-train-model input { accent-color: var(--amb-link); }
|
||||
|
||||
Reference in New Issue
Block a user