Run control: stop a run, see its console, time left, VRAM and size warnings; materials as a side panel
- A stop button on a running run (timeline, and beside the run-status button), and stop_run for the AI: only this run's prompts leave the queue or are interrupted; the step ends "cancelled" and a user's stop reaches the AI as a "stop" message (ai_run_control.py). - The run's progress carries ComfyUI's console since it began (the bar's note, models loading), time left and per step, and VRAM; once queued, each sampler's working size is estimated from the prompt, with warnings for an upscaled picture sampled again, a size past the card and a huge decode, for the user and in the AI's run news (ai_run_watch.py, ui_ai_run_live.js). - The materials picker opens as a side panel that leaves the page usable (the full-screen browser makes room), expands to the large window and back, and remembers the choice. - Esc closes the run-status card only (data-anomalous-escape). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b60ab63833
commit
049b265126
+14
-3
@@ -71,6 +71,13 @@ covered by the `styles.css` manifest.
|
||||
|
||||
- `api/ai_run_tracking.py` attaches exact external prompt IDs to one task, prevents duplicate
|
||||
ownership, and returns bounded-wait execution receipts to the AI.
|
||||
- `api/ai_run_watch.py` reads what a run shows beyond its node and steps: ComfyUI's console
|
||||
since it began (`app.logger`'s buffer: the bar with its note, the models loading), the time
|
||||
per step and left, the card's memory, and once per run the size each sampler works at from
|
||||
the queued prompt, with warnings (an upscaled picture sampled again, a size past the card,
|
||||
a huge decode). `api/ai_run_control.py` stops a run (`stop_run`, the workbench's stop
|
||||
button): only its own prompts leave the queue or are interrupted; the step ends `cancelled`
|
||||
and a user's stop reaches the AI as a `stop` message. `tests/test_ai_run_watch.py`.
|
||||
|
||||
- `api/config.py` owns configured paths and active model-folder types.
|
||||
- `api/path_utils.py` owns containment, filename validation, atomic JSON writes, and resolving
|
||||
@@ -146,8 +153,10 @@ covered by the `styles.css` manifest.
|
||||
`web/modules/canvas_build.js` is its pure part (type fit, value checks, a free spot, the
|
||||
layered layout). `open_workflow` opens one of the user's saved workflows in a new tab.
|
||||
`api/ai_toolbox.py` keeps what the user hands the AI to work with in a task ("给 AI 的素材",
|
||||
`web/modules/ui_ai_toolbox.js`, a large window from the task header: search, kinds, a column
|
||||
by source / origin / type, a card grid drawn as it scrolls, a detail pane, the picks below),
|
||||
`web/modules/ui_ai_toolbox.js`, from the task header a side panel on the right that leaves
|
||||
the page usable (kinds as tabs, two columns, a card's detail over them) or, expanded, a large
|
||||
window: search, kinds, a column by source / origin / type, a card grid drawn as it scrolls,
|
||||
a detail pane, the picks below; the last choice is kept in the browser),
|
||||
by the names the AI's tools use, with a mode: prefer, or only (add_node / new_workflow then
|
||||
refuse other custom or paid node types). Node types come from the page's own definitions
|
||||
(`web/modules/ai_toolbox_data.js`: LiteGraph's registered types and their `nodeData`, so no
|
||||
@@ -188,7 +197,9 @@ covered by the `styles.css` manifest.
|
||||
done; kept on the step in memory, sent to the page as `progress` events that only move the
|
||||
step's bar, and given to the AI by `run_status`, which can wait for the run to end, and as
|
||||
`your_run` with one sentence in the AI's other answers while it goes and once after it ends,
|
||||
so the AI need not look for itself). A step a restart left
|
||||
so the AI need not look for itself; the console's lines, time left, memory and the size
|
||||
warnings come with it, `api/ai_run_watch.py`, and the page draws them with a stop button,
|
||||
`web/modules/ui_ai_run_live.js`). A step a restart left
|
||||
`"running"` is marked stopped when the record is read again. What each tool is, in one
|
||||
place, is `api/action_catalog.py`: its kind (colours, words, counts), the stage of the work
|
||||
it belongs to (model, combo, prompt, canvas, run, picture, voice, library: the flow over a
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
- AI 工作台任务工具栏新增小巧的“运行状态”按钮与浮窗(移除遮挡时间线的常驻大横条):点开即可查看真实运行状态、排队、节点进度、用时和结果,运行中直接在按钮上显示进度百分比;没有收到运行信息时明确提示,不再空转显示“AI 正在工作”或把沉默当成完成。页面可见时自动补查状态。运行工具默认等待 15 秒并返回实际结果;外部提交可用 `track_run` 接入准确的运行 ID,不会重新出图。
|
||||
- The AI workbench now places confirmed execution state, queue/node progress, elapsed time and results inside a compact toolbar button and popover (instead of a tall sticky banner), with visible-page refresh. Untracked work and silence are explicit. Run calls wait briefly for real results; `track_run` attaches external submission IDs without resubmitting.
|
||||
|
||||
### 运行时看得更清楚,也能一键停 / See more of a run, and stop it in one click
|
||||
- AI 运行工作流时,进度条旁边多了红色的 **■ 停止**(任务工具栏的运行状态按钮旁边也有):一点就停下这一次运行,排队中的移出队列、正在跑的立刻中断,只动这次运行。时间线上记着“你停止了运行”,AI 也会收到,不会自己再跑一遍;AI 自己也能用新工具 `stop_run` 停掉自己的运行。
|
||||
- 进度条下面多了当前节点**约剩多久**、每步几秒、**显存还剩多少**,以及 ComfyUI **控制台**最新的一行(比如放大模型加载、`Model Initializing ...`),点开看最近几行。AI 收到的进度里也有这些,不用再去翻控制台。
|
||||
- 出图一排上队,AMB 就读这次工作流,算出每个采样器要在多大的图上采样。放大模型放大后又整张重新采样、尺寸超出这张显卡的舒适范围、要解码特别大的图时,会出现黄色提醒,说清可能很慢或爆显存,以及怎么改(只想放大就单用放大模型,或先缩小、减步数、换分块解码)。AI 收到同样的提醒,并被要求先问你要不要停。
|
||||
- 运行状态浮窗开着时按 Esc 只关浮窗,不再把整个 AMB 窗口一起关掉。
|
||||
- A stop button on a running run (and beside the run-status button): only this run's prompts leave the queue or are interrupted; the step ends "cancelled", the AI hears that the user stopped it, and `stop_run` lets the AI stop its own. Runs show time left and per step, VRAM, and ComfyUI's console (the bar's note, models loading), for the user and the AI alike. Once queued, each sampler's working size is estimated from the prompt, with warnings for an upscaled picture sampled again, a size past the card or a huge decode. Esc closes the run-status card only.
|
||||
|
||||
### 素材面板的侧边栏小窗 / The materials picker as a side panel
|
||||
- “素材”默认在屏幕右边打开成侧边栏小窗,不挡工作台和画布:种类是一排标签,卡片两列,点卡片在小窗里看说明。右上角 ⤢ 展开成原来的大窗口,大窗口里也能收回侧栏;AMB 记得你上次用的哪种。
|
||||
- The materials picker opens as a side panel by default (the page stays usable), and expands to the large window and back; the last choice is remembered.
|
||||
|
||||
### AI 在画布上搭工作流 / The AI builds workflows on the canvas
|
||||
- AI 可以在你眼前的画布上搭工作流了:查这台 ComfyUI 有哪些节点(包括自定义节点)、每个节点有哪些输入输出和可选项;新建一个工作流标签页(空白的,或者一次搭好整套并自动排好位置,不动你正在开的那个);加节点、连线、改参数、删节点。每一步都在画布上当场发生,是一次 Ctrl+Z,记在操作记录里,并在 AI 工作台那一步下面显示改了什么,连线也会写成“某节点的输入 model:(空)→ 加载模型 #4 · MODEL”。连不上的类型、不存在的选项、写错的参数会先被拦下并告诉 AI 正确的写法,画布不会留下半截。新建的标签页名字带时间,不会打开或覆盖你存过的工作流。连线会绕成死循环时也会先拦下。运行被 ComfyUI 拒绝时,AI 收到的是 ComfyUI 给的具体原因(哪个节点、什么问题),不再只有一句“运行失败”。
|
||||
- The AI can build workflows on the canvas the user sees: look up this ComfyUI's node types (custom nodes too) with their inputs, outputs and choices; open a new tab, blank or built and laid out from a workflow in API form (the open one is left alone); add, wire, set and remove nodes. Each is one Ctrl+Z step, logged as the AI's and shown under its AI workbench step, rewiring included. Wrong types, choices and settings are refused with what fits, before anything changes. Wiring that would loop is refused too, and a run ComfyUI rejects reports ComfyUI's own reason.
|
||||
|
||||
@@ -63,6 +63,7 @@ TOOLS = {
|
||||
"update_panel": Entry(None, None, None),
|
||||
"get_answers": Entry(None, None, None),
|
||||
"run_status": Entry("look", "run", None),
|
||||
"stop_run": Entry(None, "run", None), # ends a run step of its own; the step says who stopped it
|
||||
"get_toolbox": Entry(None, None, None),
|
||||
# Anomalous's card in the conversation (mcp_apps.py): its own calls, not the AI's steps
|
||||
"app_task_view": Entry(None, None, None),
|
||||
|
||||
+4
-1
@@ -448,6 +448,8 @@ def _collect(client, panel_id, task_id=None):
|
||||
message = {"id": step["id"], "task": task["id"], "text": step["text"], "at": step["at"]}
|
||||
if step.get("tool") == "user_activity":
|
||||
message["kind"] = "change" # something the user changed in Anomalous, not words to the AI
|
||||
elif step.get("tool") == "user_stop":
|
||||
message["kind"] = "stop" # the user stopped the AI's run (ai_run_control.py)
|
||||
elif step.get("tool") == "user_toolbox":
|
||||
message["kind"] = "toolbox" # what the user picked for the AI to work with (ai_toolbox.py)
|
||||
elif step.get("tool") == "user_marks":
|
||||
@@ -547,7 +549,8 @@ def attach_pending(result, client, task_id=None):
|
||||
"kind=marks messages are places the user marked on a picture to change (the marked picture is attached): "
|
||||
"answer them in Anomalous, not in your chat: report(reply_to=<its id>) with what you see and how you would "
|
||||
"fix it (show_panel can show the picture with your own marks), and ask_user which fix to do; "
|
||||
"kind=toolbox messages are the nodes, combos, workflows, prompts and models the user picked for you to use."
|
||||
"kind=toolbox messages are the nodes, combos, workflows, prompts and models the user picked for you to use; "
|
||||
"kind=stop: the user stopped your run, so do not run it again until they say what to change."
|
||||
)
|
||||
pictures = ai_marks.image_items(messages)
|
||||
result["structuredContent"] = data
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Stopping a run an AI started (the workbench's stop button, the AI's stop_run): its prompts still
|
||||
waiting leave ComfyUI's queue and the one running is interrupted, only while it is still that one
|
||||
(ComfyUI's own cancel; never anyone else's prompt). The run's watcher (ai_task_runs.py) then ends
|
||||
the step as "cancelled" with who stopped it; when the user did, the AI is told with its next answer."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import ai_task_runs, ai_tasks
|
||||
|
||||
|
||||
class RunControlError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _interrupt(queue, prompt_id):
|
||||
"""Interrupts `prompt_id` if it is the prompt running now; True when it was."""
|
||||
if hasattr(queue, "interrupt_if_running"):
|
||||
return bool(queue.interrupt_if_running(prompt_id))
|
||||
running, _ = queue.get_current_queue() # an older ComfyUI: check, then the global interrupt
|
||||
if not any(item[1] == prompt_id for item in running):
|
||||
return False
|
||||
import nodes
|
||||
nodes.interrupt_processing()
|
||||
return True
|
||||
|
||||
|
||||
def stop(step_id, by="user", task_id=None, now=None):
|
||||
"""Stops run step `step_id` (in task `task_id` when given). Returns {"task", "removed", "interrupted"}."""
|
||||
now = time.time() if now is None else now
|
||||
with ai_tasks._lock:
|
||||
tasks = ai_tasks._load()
|
||||
task = next((item for item in tasks.values() if (not task_id or item["id"] == task_id)
|
||||
and any(step["id"] == step_id for step in item.get("steps") or [])), None)
|
||||
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
|
||||
if not step or step.get("tool") != "run_workflow":
|
||||
raise RunControlError("No such run in this task")
|
||||
if step.get("state") != "running" or not step.get("prompt_ids"):
|
||||
raise RunControlError("This run is not going any more")
|
||||
prompts = list(step["prompt_ids"])
|
||||
step["stop"] = by
|
||||
if by == "user":
|
||||
task["steps"].append({
|
||||
"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_stop", "kind": "user", "ok": True,
|
||||
"state": "done", "summary": "user_stop", "stopped": step_id, "delivered": False, "results": [],
|
||||
"text": "The user stopped your run in Anomalous. Do not start it again unless they ask; ask_user what to change.",
|
||||
})
|
||||
del task["steps"][:-ai_tasks.MAX_STEPS]
|
||||
task["updated"] = max(task.get("updated", 0), now)
|
||||
ai_tasks._save(task)
|
||||
queue = ai_task_runs._queue()
|
||||
removed = sum(1 for prompt_id in prompts if queue.delete_queue_item(lambda item, pid=prompt_id: item[1] == pid))
|
||||
interrupted = sum(1 for prompt_id in prompts if _interrupt(queue, prompt_id))
|
||||
ai_tasks._notify(task["id"])
|
||||
return {"task": task["id"], "removed": removed, "interrupted": interrupted}
|
||||
@@ -0,0 +1,221 @@
|
||||
"""What a run an AI started shows beyond the node and its steps (ai_task_runs.py adds it to the
|
||||
run's progress, for the AI and the workbench): ComfyUI's console since the run began (the
|
||||
progress bar of the node running with its own note, "Model Initializing", the models it loads:
|
||||
what the user sees in the console window), the time per step and the time left, the graphics
|
||||
card's memory, and once per run estimate(): the size each sampler works at, read from the queued
|
||||
prompt (never changed), with warnings for a run that will be very slow or will not fit, such as a
|
||||
picture upscaled by a model and then sampled again at full size."""
|
||||
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
||||
# tqdm's bar: " 12%|## | 6/50 [00:03<00:22, 1.95it/s, a note]"
|
||||
BAR = re.compile(r"(\d+)/(\d+)\s*\[([^<\]]*)<([^,\]]*),\s*([^,\]]*)(?:,\s*([^\]]*))?\]")
|
||||
CONSOLE_LINES = 5
|
||||
LINE_CHARS = 200
|
||||
# Megapixels a sampler can work at comfortably on a 12 GB card; scaled by the card's memory.
|
||||
COMFORT_MP_12GB = 4.2
|
||||
LATENT_INPUTS = ("latent_image", "samples", "latent", "pixels", "image", "images")
|
||||
NOT_PICTURES = {"model", "clip", "vae", "upscale_model", "positive", "negative", "conditioning", "control_net",
|
||||
"mask", "sampler", "sigmas", "guider", "noise", "clip_vision", "style_model"}
|
||||
|
||||
|
||||
def _logs():
|
||||
try:
|
||||
from app import logger
|
||||
except ImportError:
|
||||
return []
|
||||
return list(logger.get_logs() or [])
|
||||
|
||||
|
||||
def console_since(started, entries=None, limit=CONSOLE_LINES):
|
||||
"""The console's last `limit` lines written since `started` (a time.time()), as shown: colours
|
||||
gone, a progress bar redrawn in place kept at its latest."""
|
||||
written = []
|
||||
for entry in _logs() if entries is None else entries:
|
||||
try:
|
||||
at = datetime.fromisoformat(entry["t"]).timestamp()
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if at >= started - 1:
|
||||
written.append(str(entry.get("m") or ""))
|
||||
lines = []
|
||||
for raw in "".join(written).split("\n"):
|
||||
parts = [part for part in raw.split("\r") if part.strip()]
|
||||
line = ANSI.sub("", parts[-1]).strip() if parts else ""
|
||||
if line:
|
||||
lines.append(line[:LINE_CHARS])
|
||||
return lines[-limit:]
|
||||
|
||||
|
||||
def _seconds(text):
|
||||
"""tqdm's "01:23" / "1:02:03" in seconds, or None ("?")."""
|
||||
try:
|
||||
parts = [int(part) for part in text.strip().split(":")]
|
||||
except ValueError:
|
||||
return None
|
||||
return sum(part * 60 ** index for index, part in enumerate(reversed(parts))) if 1 < len(parts) <= 3 else None
|
||||
|
||||
|
||||
def bar_of(lines):
|
||||
"""The progress bar on the console's last line: steps, seconds left and per step, its note; or None."""
|
||||
match = BAR.search(lines[-1]) if lines else None
|
||||
if not match:
|
||||
return None
|
||||
out = {"value": int(match[1]), "max": int(match[2])}
|
||||
left = _seconds(match[4])
|
||||
if left is not None and out["value"]:
|
||||
out["left"] = left
|
||||
rate = match[5].strip()
|
||||
number = re.match(r"([\d.]+)\s*(s/it|it/s)", rate)
|
||||
if number and float(number[1]) > 0:
|
||||
out["rate"] = round(float(number[1]) if number[2] == "s/it" else 1 / float(number[1]), 2)
|
||||
if match[6] and match[6].strip():
|
||||
out["note"] = match[6].strip()[:80]
|
||||
return out
|
||||
|
||||
|
||||
class Pace:
|
||||
"""Seconds per step and seconds left of the node running, from the steps seen over time
|
||||
(for nodes whose bar is not on the console)."""
|
||||
|
||||
def __init__(self):
|
||||
self.key, self.first = None, None
|
||||
|
||||
def update(self, key, value, most, now=None):
|
||||
now = time.monotonic() if now is None else now
|
||||
if key != self.key or self.first is None or value < self.first[1]:
|
||||
self.key, self.first = key, (now, value)
|
||||
return None
|
||||
done = value - self.first[1]
|
||||
if done <= 0:
|
||||
return None
|
||||
rate = (now - self.first[0]) / done
|
||||
return {"rate": round(rate, 2), "left": max(0, round(rate * (most - value)))}
|
||||
|
||||
|
||||
def vram():
|
||||
"""The graphics card's free and total memory in MB, or None (no card, or no ComfyUI)."""
|
||||
try:
|
||||
import comfy.model_management as management
|
||||
device = management.get_torch_device()
|
||||
if getattr(device, "type", "") in ("cpu", "mps"):
|
||||
return None
|
||||
total, free = management.get_total_memory(device), management.get_free_memory(device)
|
||||
except Exception: # noqa: BLE001 - memory is a hint; the run goes on without it
|
||||
return None
|
||||
return {"free_mb": int(free // 2 ** 26) * 64, "total_mb": int(total // 2 ** 20)}
|
||||
|
||||
|
||||
def _image_size(name):
|
||||
"""(width, height) of picture `name` in ComfyUI's input folder, or None."""
|
||||
try:
|
||||
import folder_paths
|
||||
from PIL import Image
|
||||
with Image.open(folder_paths.get_annotated_filepath(str(name))) as picture:
|
||||
return picture.size
|
||||
except Exception: # noqa: BLE001 - an unreadable picture only leaves the size unknown
|
||||
return None
|
||||
|
||||
|
||||
def _number(value):
|
||||
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
|
||||
|
||||
|
||||
def _linked(value):
|
||||
return isinstance(value, list) and len(value) == 2 and isinstance(value[0], (str, int))
|
||||
|
||||
|
||||
def _model_factor(prompt, link):
|
||||
"""How many times an upscale model enlarges, from its file name ("4x-UltraSharp", "x2plus"); 4 if unsaid."""
|
||||
name = str(((prompt.get(str(link[0])) or {}).get("inputs") or {}).get("model_name") or "") if _linked(link) else ""
|
||||
match = re.search(r"(?<![0-9])([1-8])x|x([1-8])(?![0-9])", name, re.I)
|
||||
return int(match[1] or match[2]) if match else 4
|
||||
|
||||
|
||||
def estimate(prompt, vram_total_mb=None, image_size=_image_size):
|
||||
"""The size each sampler of API prompt `prompt` works at, and warnings: {"samplers", "warnings"}."""
|
||||
sizes = {}
|
||||
|
||||
def size(node_id, seen):
|
||||
node_id = str(node_id)
|
||||
if node_id in sizes:
|
||||
return sizes[node_id]
|
||||
node = prompt.get(node_id)
|
||||
if not isinstance(node, dict) or node_id in seen:
|
||||
return None
|
||||
seen.add(node_id)
|
||||
kind, inputs = str(node.get("class_type") or ""), node.get("inputs") or {}
|
||||
names = sorted((name for name, value in inputs.items() if _linked(value) and name not in NOT_PICTURES),
|
||||
key=lambda name: LATENT_INPUTS.index(name) if name in LATENT_INPUTS else len(LATENT_INPUTS))
|
||||
upstream = next((found for found in (size(inputs[name][0], seen) for name in names) if found), None)
|
||||
width, height = _number(inputs.get("width")), _number(inputs.get("height"))
|
||||
scale = _number(inputs.get("scale_by"))
|
||||
out = upstream
|
||||
if kind == "LoadImage":
|
||||
dims = image_size(inputs.get("image"))
|
||||
out = {"width": dims[0], "height": dims[1], "batch": 1, "upscaled": False} if dims else None
|
||||
elif width and height and width > 0 and height > 0:
|
||||
batch = _number(inputs.get("batch_size")) or (upstream or {}).get("batch", 1)
|
||||
out = {"width": width, "height": height, "batch": int(batch), "upscaled": (upstream or {}).get("upscaled", False)}
|
||||
elif upstream and (width or height): # one side given: the other keeps the picture's shape
|
||||
ratio = (width or 0) / upstream["width"] if width else height / upstream["height"]
|
||||
out = {**upstream, "width": upstream["width"] * ratio, "height": upstream["height"] * ratio}
|
||||
elif upstream and scale:
|
||||
out = {**upstream, "width": upstream["width"] * scale, "height": upstream["height"] * scale}
|
||||
elif upstream and kind == "ImageScaleToTotalPixels" and _number(inputs.get("megapixels")):
|
||||
ratio = math.sqrt(inputs["megapixels"] * 1024 * 1024 / (upstream["width"] * upstream["height"]))
|
||||
out = {**upstream, "width": upstream["width"] * ratio, "height": upstream["height"] * ratio}
|
||||
elif upstream and kind == "ImageUpscaleWithModel":
|
||||
factor = _model_factor(prompt, inputs.get("upscale_model"))
|
||||
out = {**upstream, "width": upstream["width"] * factor, "height": upstream["height"] * factor, "upscaled": True}
|
||||
sizes[node_id] = out
|
||||
return out
|
||||
|
||||
def steps_of(inputs):
|
||||
if _number(inputs.get("steps")):
|
||||
return int(inputs["steps"])
|
||||
source = prompt.get(str(inputs["sigmas"][0])) if _linked(inputs.get("sigmas")) else None
|
||||
return int(source["inputs"]["steps"]) if source and _number((source.get("inputs") or {}).get("steps")) else None
|
||||
|
||||
comfort = COMFORT_MP_12GB * max(0.5, min(3.0, (vram_total_mb or 12288) / 12288))
|
||||
samplers, warnings = [], []
|
||||
for node_id, node in sorted(prompt.items(), key=lambda item: str(item[0])):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
inputs = node.get("inputs") or {}
|
||||
title = str((node.get("_meta") or {}).get("title") or node.get("class_type") or node_id)[:80]
|
||||
if _linked(inputs.get("latent_image")):
|
||||
found = size(inputs["latent_image"][0], set())
|
||||
if not found:
|
||||
continue
|
||||
entry = {"node": str(node_id), "title": title, "width": round(found["width"]), "height": round(found["height"]),
|
||||
"batch": found["batch"], "steps": steps_of(inputs), "upscaled": found["upscaled"]}
|
||||
samplers.append(entry)
|
||||
megapixels = entry["width"] * entry["height"] * entry["batch"] / 1048576
|
||||
if megapixels > comfort:
|
||||
warnings.append({"kind": "upscale_resample" if found["upscaled"] else "big_sample",
|
||||
**entry, "megapixels": round(megapixels, 1)})
|
||||
elif node.get("class_type") == "VAEDecode" and _linked(inputs.get("samples")):
|
||||
found = size(inputs["samples"][0], set())
|
||||
if found and found["width"] * found["height"] * found["batch"] / 1048576 > comfort * 2:
|
||||
warnings.append({"kind": "big_decode", "node": str(node_id), "title": title,
|
||||
"width": round(found["width"]), "height": round(found["height"])})
|
||||
return {"samplers": samplers[:12], "warnings": warnings[:6], "comfort_mp": round(comfort, 1)}
|
||||
|
||||
|
||||
def warning_words(warning):
|
||||
"""One sentence for the AI on a warning of estimate()."""
|
||||
where = f"{warning['title']} (node {warning['node']}) works at {warning['width']}x{warning['height']}"
|
||||
if warning["kind"] == "upscale_resample":
|
||||
return (f"{where}{' for ' + str(warning['steps']) + ' steps' if warning.get('steps') else ''} after an upscale "
|
||||
"model enlarged the picture: very slow and may not fit in memory. If the user only wants it bigger, the "
|
||||
"upscale model alone (ImageUpscaleWithModel -> SaveImage, no sampler after it) is enough; else sample "
|
||||
"smaller (scale down before VAEEncode), use fewer steps or a lower denoise, or a tiled sampler.")
|
||||
if warning["kind"] == "big_sample":
|
||||
return (f"{where}: {warning['megapixels']} megapixels, more than this card is comfortable with; expect it to be "
|
||||
"very slow or run out of memory. Consider a smaller size and upscaling afterwards.")
|
||||
return f"{where} when decoded: VAEDecodeTiled saves memory there."
|
||||
+85
-7
@@ -6,13 +6,16 @@ anything. While it runs, where it stands (queued and how many are ahead, the nod
|
||||
its steps, nodes done; read from ComfyUI's queue and its progress registry, never changed) is
|
||||
the step's "progress", sent to the page with each change, and status() answers the AI's
|
||||
run_status with it, waiting for the end when asked. tell() puts the same news in the AI's
|
||||
other answers: where the run stands while it goes, and once that it ended."""
|
||||
other answers: where the run stands while it goes, and once that it ended. The console's lines,
|
||||
the time left and the card's memory join the progress, and the size each sampler works at with
|
||||
its warnings is the step's "estimate" (ai_run_watch.py); a run the user or the AI stopped
|
||||
(ai_run_control.py) ends "cancelled"."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import ai_tasks
|
||||
from . import ai_run_watch, ai_tasks
|
||||
|
||||
RUN_POLL_SECONDS = 2.0
|
||||
MAX_STATUS_WAIT = 55
|
||||
@@ -87,20 +90,30 @@ def _show_progress(task_id, step_id, progress):
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
|
||||
if step and step.get("stop"):
|
||||
progress = {**progress, "stopping": step["stop"]}
|
||||
if not step or step.get("progress") == progress or step.get("state") != "running":
|
||||
return
|
||||
step["progress"] = progress
|
||||
ai_tasks._notify(task_id, {"progress": {"step": step_id, **progress}})
|
||||
|
||||
|
||||
def _finish_run(task_id, step_id, prompts, history):
|
||||
def _finish_run(task_id, step_id, prompts, history, queued=None):
|
||||
if not prompts:
|
||||
_stop_run(task_id, step_id, "没有找到已排队的运行 / No queued run was found; completion cannot be confirmed")
|
||||
return True
|
||||
images, failed, done = [], False, 0
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
stopped = next((item.get("stop") for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
|
||||
for prompt_id in prompts:
|
||||
record_ = history.get(prompt_id)
|
||||
if not record_:
|
||||
if stopped and queued is not None and prompt_id not in queued:
|
||||
done += 1 # taken out of the queue before it started: it never gets a history
|
||||
continue
|
||||
if stopped and (record_.get("status") or {}).get("status_str") == "error":
|
||||
done += 1 # the interrupt ends a prompt "error"; it was stopped, not broken
|
||||
continue
|
||||
status = record_.get("status") or {}
|
||||
if status.get("status_str") == "error":
|
||||
@@ -117,12 +130,13 @@ def _finish_run(task_id, step_id, prompts, history):
|
||||
if not step:
|
||||
return True
|
||||
failed = failed or bool(step.get("submission_error"))
|
||||
stopped = step.get("stop")
|
||||
results = [item for item in step["results"] if item["type"] != "image"] + images[:ai_tasks.MAX_RESULTS]
|
||||
if (done < len(prompts) and results == step["results"] and step.get("prompts") == len(prompts)
|
||||
and (not failed or step.get("ok") is False)):
|
||||
return False # nothing new to keep: no write
|
||||
step["results"] = results
|
||||
step["state"] = "running" if done < len(prompts) else ("error" if failed else "done")
|
||||
step["state"] = "running" if done < len(prompts) else ("cancelled" if stopped else "error" if failed else "done")
|
||||
step["prompts"] = len(prompts)
|
||||
if failed:
|
||||
step["ok"] = False
|
||||
@@ -167,22 +181,34 @@ def watch(step_id, receipt):
|
||||
if not step:
|
||||
return # The user may remove the task while its submission is returning.
|
||||
step["prompt_ids"] = list(prompts)
|
||||
step_at = step["at"]
|
||||
if receipt.get("submission_error"):
|
||||
step.update(submission_error=receipt["submission_error"], error=receipt["submission_error"], ok=False)
|
||||
ai_tasks._save(task)
|
||||
|
||||
def follow():
|
||||
start = time.monotonic()
|
||||
pace, estimated = ai_run_watch.Pace(), False
|
||||
while time.monotonic() - start < ai_tasks.RUN_WATCH_SECONDS:
|
||||
try:
|
||||
history = {pid: _queue().get_history(prompt_id=pid).get(pid) for pid in prompts}
|
||||
except Exception: # noqa: BLE001 - stop the receipt, not the actual ComfyUI run
|
||||
_stop_run(task_id, step_id, "无法读取运行记录 / Run history could not be read; check ComfyUI's queue")
|
||||
return
|
||||
if _finish_run(task_id, step_id, prompts, {k: v for k, v in history.items() if v}):
|
||||
try:
|
||||
running, pending = _queue().get_current_queue()
|
||||
queued = {item[1]: item for item in [*running, *pending]}
|
||||
except Exception: # noqa: BLE001 - the queue only helps (estimate, a stopped run); history decides
|
||||
queued = None
|
||||
if not estimated and queued:
|
||||
estimated = _estimate(task_id, step_id, next((queued[pid] for pid in prompts if pid in queued), None))
|
||||
if _finish_run(task_id, step_id, prompts, {k: v for k, v in history.items() if v},
|
||||
None if queued is None else set(queued)):
|
||||
return
|
||||
try:
|
||||
progress = progress_of(prompts)
|
||||
if progress and progress["state"] == "running":
|
||||
_more_progress(progress, step_at, pace)
|
||||
except (AttributeError, KeyError, TypeError, ValueError):
|
||||
progress = None # progress is optional; keep watching the authoritative history
|
||||
if progress:
|
||||
@@ -193,11 +219,53 @@ def watch(step_id, receipt):
|
||||
threading.Thread(target=follow, name="anomalous-ai-run", daemon=True).start()
|
||||
|
||||
|
||||
def _estimate(task_id, step_id, item):
|
||||
"""The run's estimate (ai_run_watch.estimate) from its queued prompt `item`, kept on the step once."""
|
||||
if not item or len(item) < 3 or not isinstance(item[2], dict):
|
||||
return False
|
||||
memory = ai_run_watch.vram()
|
||||
try:
|
||||
found = ai_run_watch.estimate(item[2], (memory or {}).get("total_mb"))
|
||||
except Exception: # noqa: BLE001 - an odd prompt leaves the run without an estimate, nothing more
|
||||
return True
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = next((entry for entry in (task or {}).get("steps", []) if entry["id"] == step_id), None)
|
||||
if not step:
|
||||
return True
|
||||
step["estimate"] = found
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return True
|
||||
|
||||
|
||||
def _more_progress(progress, started, pace):
|
||||
"""The console's last lines, its bar's time left (else the pace of the steps), the card's memory."""
|
||||
lines = ai_run_watch.console_since(started)
|
||||
if lines:
|
||||
progress["console"] = lines
|
||||
bar = ai_run_watch.bar_of(lines)
|
||||
if bar and bar.get("note"):
|
||||
progress["note"] = bar["note"]
|
||||
timing = None
|
||||
if bar and bar["max"] == progress.get("max"):
|
||||
timing = {key: bar[key] for key in ("left", "rate") if key in bar}
|
||||
if not timing and progress.get("max", 0) > 1:
|
||||
timing = pace.update((progress.get("prompt"), progress.get("node")), progress["value"], progress["max"])
|
||||
if timing:
|
||||
progress.update(timing)
|
||||
memory = ai_run_watch.vram()
|
||||
if memory:
|
||||
progress["vram"] = memory
|
||||
|
||||
|
||||
def summary(run):
|
||||
"""One sentence for the AI on where run step `run` stands."""
|
||||
state, progress = run.get("state"), run.get("progress") or {}
|
||||
warned = " ".join(ai_run_watch.warning_words(item) for item in (run.get("estimate") or {}).get("warnings", []))
|
||||
warning = (f" Warning: {warned} Tell the user (report) and ask_user whether to stop it (stop_run)." if warned and state == "running" else "")
|
||||
if state == "running" and progress.get("state") == "queued":
|
||||
return f"Your run is queued, {progress.get('ahead', 0)} ahead of it; it has not started. Do not run it again."
|
||||
return f"Your run is queued, {progress.get('ahead', 0)} ahead of it; it has not started. Do not run it again.{warning}"
|
||||
if state == "running":
|
||||
parts = []
|
||||
if progress.get("prompts", 0) > 1:
|
||||
@@ -209,9 +277,16 @@ def summary(run):
|
||||
parts.append(f"{progress['percent']}%")
|
||||
if progress.get("nodes"):
|
||||
parts.append(f"{progress['nodes_done']} of {progress['nodes']} nodes done")
|
||||
if progress.get("left") is not None:
|
||||
parts.append(f"about {progress['left']} s left in this node")
|
||||
parts.append(f"{round(time.time() - run['at'])} s so far")
|
||||
if progress.get("note"):
|
||||
parts.append(f"console says: {progress['note']}")
|
||||
return (f"Your run is still going ({', '.join(parts)}). It has not finished: call run_status with "
|
||||
"wait_seconds to wait for it; do not look in the output folder or guess.")
|
||||
"wait_seconds to wait for it; do not look in the output folder or guess." + warning)
|
||||
if state == "cancelled":
|
||||
return ("The user stopped your run in Anomalous; do not start it again unless they ask." if run.get("stop") == "user"
|
||||
else "Your run was stopped (stop_run).")
|
||||
if state == "done":
|
||||
pictures = [item["filename"] for item in run.get("results") or [] if item.get("type") == "image"]
|
||||
return f"Your run finished in {round(run.get('ms', 0) / 1000)} s with {len(pictures)} picture(s): {', '.join(pictures[:8])}."
|
||||
@@ -280,6 +355,8 @@ def status(client, session="", task_id=None, wait_seconds=0, step_id=None):
|
||||
ai_tasks._save(task)
|
||||
if run.get("progress"):
|
||||
answer["progress"] = run["progress"]
|
||||
if (run.get("estimate") or {}).get("samplers"):
|
||||
answer["estimate"] = run["estimate"]
|
||||
if run.get("prompt_ids"):
|
||||
answer["prompt_ids"] = run["prompt_ids"]
|
||||
pictures = ["/".join(part for part in (item.get("subfolder"), item["filename"]) if part)
|
||||
@@ -292,5 +369,6 @@ def status(client, session="", task_id=None, wait_seconds=0, step_id=None):
|
||||
"running": "Still running: call run_status again (wait_seconds up to 55) instead of guessing it is done.",
|
||||
"done": "Finished: the pictures are in the output folder (search_images / get_image_info show them).",
|
||||
"asking": "Waiting for the user's OK in Anomalous before it runs.",
|
||||
"cancelled": "Stopped before it finished; nothing more will come of it.",
|
||||
}.get(run.get("state"), "The run did not finish; see error.")
|
||||
return answer
|
||||
|
||||
+16
-2
@@ -1,13 +1,14 @@
|
||||
"""The AI workbench's routes over ai_tasks.py (the record) and ai_panels.py (panels, answers,
|
||||
messages): the list and one task (reads, open like the other pages), and the writes the page
|
||||
makes - start a task, rename or remove one, answer a panel, write to the AI, answer a gate and
|
||||
set the gates' policies (ai_gates.py) - (local_guard.py keeps the writes to this computer)."""
|
||||
set the gates' policies (ai_gates.py), stop a run (ai_run_control.py) - (local_guard.py keeps the
|
||||
writes to this computer)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import activity_log, ai_gates, ai_marks, ai_task_changes, ai_tasks, ai_toolbox
|
||||
from . import activity_log, ai_gates, ai_marks, ai_run_control, ai_task_changes, ai_tasks, ai_toolbox
|
||||
|
||||
|
||||
async def api_list(request):
|
||||
@@ -157,6 +158,18 @@ async def api_toolbox_pick(request):
|
||||
return web.json_response({"step": step})
|
||||
|
||||
|
||||
async def api_stop(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/runs/{step}/stop - the user stops a run the AI started."""
|
||||
from . import ai_panels
|
||||
|
||||
try:
|
||||
answer = await asyncio.to_thread(ai_run_control.stop, request.match_info["step_id"], "user", request.match_info["task_id"])
|
||||
except ai_run_control.RunControlError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
ai_panels.wake()
|
||||
return web.json_response(answer)
|
||||
|
||||
|
||||
async def api_toolbox(request):
|
||||
"""GET /anomalous/ai_toolbox?kind=all: what the picker lists; GET/POST /anomalous/ai_toolbox/favorites."""
|
||||
if request.path.endswith("/favorites"):
|
||||
@@ -187,6 +200,7 @@ def register_routes(app):
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/marks", api_marks)
|
||||
app.router.add_get("/anomalous/ai_tasks/{task_id}/marks/{name}", api_marked)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/toolbox", api_toolbox_pick)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/stop", api_stop)
|
||||
app.router.add_get("/anomalous/ai_toolbox", api_toolbox)
|
||||
app.router.add_get("/anomalous/ai_toolbox/favorites", api_toolbox)
|
||||
app.router.add_post("/anomalous/ai_toolbox/favorites", api_toolbox)
|
||||
|
||||
+1
-1
@@ -190,7 +190,7 @@ async def _call(params, request):
|
||||
# The user's news and the run's go to the AI, never to the card's own calls (they do not reach the AI).
|
||||
if name not in mcp_apps.APP_TOOLS and isinstance(result, dict) and not result.get("isError"):
|
||||
resolved_task = active_task or await asyncio.to_thread(ai_tasks.active_task_id, client, session=session)
|
||||
if name not in ("run_workflow", "run_status") and resolved_task:
|
||||
if name not in ("run_workflow", "run_status", "stop_run") and resolved_task:
|
||||
await asyncio.to_thread(ai_task_runs.tell, result, resolved_task)
|
||||
if name != "get_answers" and name not in mcp_apps.APP_TOOLS and isinstance(result, dict) and not result.get("isError"):
|
||||
await asyncio.to_thread(ai_panels.attach_pending, result, client, task_id=resolved_task or ("" if session else None))
|
||||
|
||||
+20
-1
@@ -3,7 +3,7 @@ work, report leaves a note for the user (with pictures made outside Anomalous's
|
||||
show_panel / update_panel draw a panel from Anomalous's blocks (ai_panels.py), get_answers
|
||||
reads what the user picked or typed on it."""
|
||||
|
||||
from . import ai_marks, ai_panels, ai_run_tracking, ai_task_runs, ai_tasks, ai_toolbox
|
||||
from . import ai_marks, ai_panels, ai_run_control, ai_run_tracking, ai_task_runs, ai_tasks, ai_toolbox
|
||||
from .mcp_tools import MAX_LIMIT, ToolError, _schema
|
||||
|
||||
|
||||
@@ -111,6 +111,20 @@ def run_status(ctx, wait_seconds=0, task=None):
|
||||
raise ToolError(str(error))
|
||||
|
||||
|
||||
def stop_run(ctx, task=None):
|
||||
client, session = ctx.get("client") or "AI", ctx.get("session") or ""
|
||||
task_id = ai_tasks.active_task_id(client, session, task or ctx.get("task"))
|
||||
run = next((step for step in reversed((ai_tasks.get_task(task_id) or {}).get("steps", []))
|
||||
if step.get("tool") == "run_workflow"), None) if task_id else None
|
||||
if not run:
|
||||
raise ToolError("No run in this task yet.")
|
||||
try:
|
||||
done = ai_run_control.stop(run["id"], by="ai", task_id=task_id)
|
||||
except ai_run_control.RunControlError as error:
|
||||
raise ToolError(str(error))
|
||||
return {**done, "stopped": True, "note": "Stopping: run_status(wait_seconds=10) confirms it ended."}
|
||||
|
||||
|
||||
def get_toolbox(ctx, task=None):
|
||||
from . import mcp_tools
|
||||
from .mcp_canvas_edit import find_nodes
|
||||
@@ -256,6 +270,11 @@ TASK_TOOLS = dict([
|
||||
"carry the same news as your_run, and once more when it ends.",
|
||||
run_status, {"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_task_runs.MAX_STATUS_WAIT, "default": 0},
|
||||
"task": _TASK_PROP}),
|
||||
_task_tool("stop_run", "Stop the run",
|
||||
"Stops your task's run that is still going: its prompts still queued leave ComfyUI's queue and the one "
|
||||
"running is interrupted (never anyone else's). Use it when run_status warns that the run is far too big "
|
||||
"or slow and the user agrees (ask_user), or when the user asks you to stop.",
|
||||
stop_run, {"task": _TASK_PROP}),
|
||||
_task_tool("get_toolbox", "What the user picked for you",
|
||||
"The node types, combos, saved workflows, prompts and models the user picked in the AI workbench for "
|
||||
"this task, in full (inputs and outputs of the nodes, the combos' models and prompts…), and how strictly: "
|
||||
|
||||
@@ -281,6 +281,18 @@ never a global queue difference. A partial batch continues tracking accepted IDs
|
||||
as an error with its pictures retained. `run_status` includes these IDs. Missing receipts
|
||||
cannot trigger guessing or an automatic retry.
|
||||
|
||||
While a run goes, its watcher adds `api/ai_run_watch.py`'s readings to the progress: the
|
||||
console lines written since the step began (ComfyUI's `app.logger` buffer, `\r` redraws kept
|
||||
at their latest, colours stripped), the tqdm bar's note, seconds left and per step (else a pace
|
||||
from the steps seen), and the card's free / total memory. From the queued prompt it estimates,
|
||||
once, the size each sampler's `latent_image` works at (following LoadImage's file, empty
|
||||
latents, scale and upscale-model nodes) and keeps `estimate` on the step: warnings for an
|
||||
upscale model's output sampled again, a sampler past the card's comfort (4.2 MP on 12 GB,
|
||||
scaled by its memory) and a huge `VAEDecode`. `run_status` and `your_run` carry them in words.
|
||||
`api/ai_run_control.py` stops a run by its own prompt IDs only (dequeue, then
|
||||
`interrupt_if_running`); with `stop` set, an interrupted prompt's "error" history and a
|
||||
dequeued prompt's missing one both count as ended and the step becomes `cancelled`.
|
||||
|
||||
`POST /anomalous/mcp` (`api/mcp_server.py`) answers one JSON-RPC message per POST with
|
||||
one JSON object; GET and DELETE get 405, and nothing is sent unasked. A request whose
|
||||
`params._meta` carries `io.modelcontextprotocol/protocolVersion` is served as 2026-07-28:
|
||||
|
||||
@@ -6,7 +6,11 @@ localization, media, or explicit canvas mutations.
|
||||
## Bootstrap and module ownership
|
||||
|
||||
`web/modules/ai_task_status.js` derives confirmed task execution state;
|
||||
`web/modules/ui_ai_status.js` renders the task toolbar's compact execution-status button and popover from run steps.
|
||||
`web/modules/ui_ai_status.js` renders the task toolbar's compact execution-status button and popover from run steps,
|
||||
with a stop button beside it while a run goes. `web/modules/ui_ai_run_live.js` draws a running run for both the
|
||||
popover and the timeline: bar and percent, time left and per step, VRAM, the console's note and last lines in a
|
||||
`<details>`, the stop button (`POST /anomalous/ai_tasks/{id}/runs/{step}/stop`), and the step's size `estimate`
|
||||
with its warnings (also kept after the run).
|
||||
The header shares progress with the timeline. Websocket updates also update the in-memory
|
||||
step; a five-second visible-page refresh recovers missed events. The loop stops when the
|
||||
workbench/page is hidden or removed, and resumes on open or browser visibility return.
|
||||
|
||||
+2
-2
@@ -137,9 +137,9 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
|
||||
|
||||
**让 AI 在画布上搭工作流**:AI 可以先用 `find_nodes` 查这台 ComfyUI 有哪些节点、每个节点要接什么,然后用 `new_workflow` 新开一个标签页(空白,或者一次把整套工作流搭好并排好位置;你正开着的工作流不受影响),再用 `add_node`、`connect_nodes`、`set_values`、`remove_nodes` 加节点、连线、改参数、删节点。每一步你都能在画布上看到,按 Ctrl+Z 能撤回;AI 工作台那一步下面写着改了什么(包括哪根线从哪里连到哪里)。类型接不上、选项不存在时,画布不会动,AI 会收到能用的写法。
|
||||
|
||||
**给 AI 的素材**:任务标题旁边的 **✦ 素材** 打开一个大窗口:上面搜索所有素材,左边选种类,节点可以按来源筛(ComfyUI 自带、你装的每个第三方节点包、付费 API 节点),卡片上看得到节点的输入输出、搭配和工作流的封面、提示词原文;鼠标移到卡片上右边显示完整说明。提示词里也能挑搭配和工作流里的提示词。勾选后点 **交给 AI**,选“优先用这些”或“只能用这些节点”(自带节点照常可用,别的第三方和付费节点会被拦下)。☆ 设为常用:没挑素材的任务自动带上常用的。AI 用 `get_toolbox` 看到你挑的东西的详细说明,用 `open_workflow` 在新标签页打开你存的工作流。工作流详情页的 **✦ 交给 AI** 会新开一个带着这个工作流的任务。
|
||||
**给 AI 的素材**:任务标题旁边的 **✦ 素材** 默认在屏幕右边打开一个**侧边栏小窗**:不挡工作台和画布,上面搜索,种类是一排标签,卡片两列,点卡片在小窗里看详细说明(“返回列表”回去),下面挑好点交给 AI。右上角 **⤢** 展开成大窗口,大窗口标题栏的侧栏按钮再收回去;AMB 记得你上次用的是哪种。大窗口里:上面搜索所有素材,左边选种类,节点可以按来源筛(ComfyUI 自带、你装的每个第三方节点包、付费 API 节点),卡片上看得到节点的输入输出、搭配和工作流的封面、提示词原文;鼠标移到卡片上右边显示完整说明。提示词里也能挑搭配和工作流里的提示词。勾选后点 **交给 AI**,选“优先用这些”或“只能用这些节点”(自带节点照常可用,别的第三方和付费节点会被拦下)。☆ 设为常用:没挑素材的任务自动带上常用的。AI 用 `get_toolbox` 看到你挑的东西的详细说明,用 `open_workflow` 在新标签页打开你存的工作流。工作流详情页的 **✦ 交给 AI** 会新开一个带着这个工作流的任务。
|
||||
|
||||
**运行进度**:AI 运行工作流后,时间线上那一步下面有进度条(排队时前面还有几个;运行时哪个节点、第几步、节点做完几个)。进度条右边是百分比。AI 用 `run_status` 问同样的进度,或者等到跑完再继续,不会凭时间猜;运行期间 AI 调用别的工具时,回复里的 `your_run` 也带着一句当前进度,跑完后告诉它一次结果,所以它不用自己去翻输出文件夹或者看屏幕。
|
||||
**运行进度**:AI 运行工作流后,时间线上那一步下面有进度条(排队时前面还有几个;运行时哪个节点、第几步、节点做完几个)。进度条右边是百分比,旁边是红色的 **■ 停止**:一点就停这一次运行(还在排队的移出队列,正在跑的立刻中断,只动这次运行),AI 也会收到“你停止了运行”,不会自己再跑一遍。任务工具栏的运行状态按钮旁边也有一个小 ■。进度条下面写着当前节点 **约剩多久**、每步几秒、**显存剩多少**,再下面一行是 ComfyUI **控制台**最新的那一行(比如 `Model Initializing ...`、`Requested to load …`),点开能看最近几行;这些 AI 也同样看得到。出图刚排上队,AMB 会读这次的工作流算出每个采样器要在多大的图上采样:如果是放大模型放大后又整张重新采样、尺寸超过这张显卡的舒适范围、或者要解码特别大的图,会出现黄色提醒,告诉你可能很慢或爆显存,以及怎么改(只想放大就用纯模型超分,或者先缩小、减步数、分块解码);AI 收到同样的提醒,会被要求先问你要不要停(它也能用 `stop_run` 停自己的运行)。AI 用 `run_status` 问同样的进度,或者等到跑完再继续,不会凭时间猜;运行期间 AI 调用别的工具时,回复里的 `your_run` 也带着一句当前进度,跑完后告诉它一次结果,所以它不用自己去翻输出文件夹或者看屏幕。
|
||||
|
||||
**流程图和关卡**:任务标题下面的一排流程按 AI 做事的先后列出它经过的阶段(模型、搭配、提示词、运行、挑图……),点一下跳到那一步。任务列表上方的 **关卡** 里,改画布、用显卡出图配音、下载模型、联网扫描四类事各自可以选 **直接做 / 先问我 / 不许做**(默认只有下载先问)。选了“先问我”,AI 的那一步会停在时间线上列出它要做什么,你点 **允许** 才做;点 **不要** 时可以写原因或想怎么改,AI 会收到。AI 最多等 50 秒,等不及的话会先去问你的回答(`get_answers`),你允许后它再做一次,不会问第二遍。
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""A run's console, time left and size warnings (ai_run_watch.py), and stopping it (ai_run_control.py)."""
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
|
||||
from test_ai_tasks import isolate, ok
|
||||
from api import ai_panels, ai_run_control, ai_run_watch, ai_task_runs, ai_tasks
|
||||
|
||||
|
||||
def logs(started, *chunks):
|
||||
at = datetime.fromtimestamp(started + 1).isoformat()
|
||||
return [{"t": at, "m": chunk} for chunk in chunks]
|
||||
|
||||
|
||||
class ConsoleTests(unittest.TestCase):
|
||||
def test_the_bar_redrawn_in_place_is_its_latest_and_colours_go(self):
|
||||
started = time.time()
|
||||
entries = [{"t": datetime.fromtimestamp(started - 60).isoformat(), "m": "old line\n"}] + logs(
|
||||
started, "\x1b[32m[INFO]\x1b[0m Requested to load RRDBNet\n",
|
||||
"\r 0%| | 0/50 [00:00<?, ?it/s, Model Initializing ...]",
|
||||
"\r 12%|#2 | 6/50 [00:03<00:22, 1.95it/s, Model Initializing ...]")
|
||||
lines = ai_run_watch.console_since(started, entries)
|
||||
self.assertEqual(lines[0], "[INFO] Requested to load RRDBNet")
|
||||
self.assertTrue(lines[-1].endswith("Model Initializing ...]"))
|
||||
self.assertNotIn("old line", lines)
|
||||
bar = ai_run_watch.bar_of(lines)
|
||||
self.assertEqual((bar["value"], bar["max"], bar["left"], bar["note"]), (6, 50, 22, "Model Initializing ..."))
|
||||
self.assertAlmostEqual(bar["rate"], 0.51, places=2)
|
||||
|
||||
def test_no_bar_when_the_last_line_is_not_one(self):
|
||||
self.assertIsNone(ai_run_watch.bar_of(["[INFO] got prompt"]))
|
||||
self.assertIsNone(ai_run_watch.bar_of([]))
|
||||
self.assertNotIn("left", ai_run_watch.bar_of(["0/50 [00:00<?, ?it/s]"]))
|
||||
|
||||
def test_pace_from_steps_seen(self):
|
||||
pace = ai_run_watch.Pace()
|
||||
self.assertIsNone(pace.update("k", 2, 20, now=100))
|
||||
self.assertEqual(pace.update("k", 6, 20, now=108), {"rate": 2.0, "left": 28})
|
||||
self.assertIsNone(pace.update("other", 1, 20, now=110), "another node starts over")
|
||||
|
||||
|
||||
def prompt_upscaled_then_sampled():
|
||||
return {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": "in.png"}},
|
||||
"2": {"class_type": "UpscaleModelLoader", "inputs": {"model_name": "4x-UltraSharp.pth"}},
|
||||
"3": {"class_type": "ImageUpscaleWithModel", "inputs": {"upscale_model": ["2", 0], "image": ["1", 0]}},
|
||||
"4": {"class_type": "VAEEncode", "inputs": {"pixels": ["3", 0], "vae": ["9", 2]}},
|
||||
"5": {"class_type": "KSampler", "inputs": {"model": ["9", 0], "latent_image": ["4", 0], "steps": 50, "denoise": 0.4},
|
||||
"_meta": {"title": "Detail pass"}},
|
||||
"6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["9", 2]}},
|
||||
"9": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "a.safetensors"}},
|
||||
}
|
||||
|
||||
|
||||
class EstimateTests(unittest.TestCase):
|
||||
def test_an_upscaled_picture_sampled_again_is_warned_with_the_plain_upscale(self):
|
||||
found = ai_run_watch.estimate(prompt_upscaled_then_sampled(), 12288, image_size=lambda name: (1024, 1024))
|
||||
self.assertEqual(found["samplers"][0], {"node": "5", "title": "Detail pass", "width": 4096, "height": 4096,
|
||||
"batch": 1, "steps": 50, "upscaled": True})
|
||||
kinds = [warning["kind"] for warning in found["warnings"]]
|
||||
self.assertEqual(kinds, ["upscale_resample", "big_decode"])
|
||||
self.assertIn("upscale model alone", ai_run_watch.warning_words(found["warnings"][0]))
|
||||
|
||||
def test_a_usual_picture_has_no_warning_and_sizes_follow_scale_nodes(self):
|
||||
prompt = {
|
||||
"1": {"class_type": "EmptyLatentImage", "inputs": {"width": 832, "height": 1216, "batch_size": 2}},
|
||||
"2": {"class_type": "KSampler", "inputs": {"latent_image": ["1", 0], "steps": 28}},
|
||||
"3": {"class_type": "LatentUpscaleBy", "inputs": {"samples": ["2", 0], "scale_by": 1.5}},
|
||||
"4": {"class_type": "BasicScheduler", "inputs": {"steps": 12}},
|
||||
"5": {"class_type": "SamplerCustom", "inputs": {"latent_image": ["3", 0], "sigmas": ["4", 0]}},
|
||||
}
|
||||
found = ai_run_watch.estimate(prompt, 24576)
|
||||
self.assertEqual([(item["width"], item["height"], item["batch"], item["steps"]) for item in found["samplers"]],
|
||||
[(832, 1216, 2, 28), (1248, 1824, 2, 12)])
|
||||
self.assertEqual(found["warnings"], [])
|
||||
|
||||
def test_a_small_card_is_warned_sooner(self):
|
||||
prompt = {"1": {"class_type": "EmptyLatentImage", "inputs": {"width": 2048, "height": 1536, "batch_size": 1}},
|
||||
"2": {"class_type": "KSampler", "inputs": {"latent_image": ["1", 0], "steps": 20}}}
|
||||
self.assertEqual(ai_run_watch.estimate(prompt, 24576)["warnings"], [])
|
||||
self.assertEqual(ai_run_watch.estimate(prompt, 6144)["warnings"][0]["kind"], "big_sample")
|
||||
|
||||
def test_odd_prompts_do_not_break_it(self):
|
||||
loop = {"1": {"class_type": "X", "inputs": {"image": ["2", 0]}}, "2": {"class_type": "Y", "inputs": {"image": ["1", 0]}},
|
||||
"3": {"class_type": "KSampler", "inputs": {"latent_image": ["1", 0]}}, "4": "junk"}
|
||||
self.assertEqual(ai_run_watch.estimate(loop), {"samplers": [], "warnings": [], "comfort_mp": 4.2})
|
||||
|
||||
|
||||
class StopTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
isolate()
|
||||
self.task = ai_tasks.begin_task("A", "Test")["id"]
|
||||
self.step = ai_tasks.record("A", "run_workflow", {}, ok({}), time.time())
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load()[self.task]
|
||||
task["steps"][-1]["prompt_ids"] = ["running", "waiting"]
|
||||
ai_tasks._save(task)
|
||||
self.queue = mock.Mock(spec=["delete_queue_item", "interrupt_if_running", "get_current_queue"])
|
||||
self.queue.delete_queue_item.side_effect = lambda match: match((2, "waiting"))
|
||||
self.queue.interrupt_if_running.side_effect = lambda pid: pid == "running"
|
||||
|
||||
def test_the_user_stops_it_and_the_ai_is_told(self):
|
||||
with mock.patch.object(ai_task_runs, "_queue", return_value=self.queue):
|
||||
answer = ai_run_control.stop(self.step["id"], "user", self.task)
|
||||
self.assertEqual((answer["removed"], answer["interrupted"]), (1, 1))
|
||||
# The interrupted prompt ends "error" in its history; the removed one never gets one.
|
||||
ai_task_runs._finish_run(self.task, self.step["id"], ["running", "waiting"],
|
||||
{"running": {"status": {"status_str": "error"}}}, queued=set())
|
||||
step = ai_tasks.get_task(self.task)["steps"][0]
|
||||
self.assertEqual((step["state"], step["ok"], step["stop"]), ("cancelled", True, "user"))
|
||||
self.assertIn("user stopped", ai_task_runs.summary(step))
|
||||
result = ai_panels.attach_pending(ok({}), "A", task_id=self.task)
|
||||
self.assertEqual(result["structuredContent"]["user_messages"][0]["kind"], "stop")
|
||||
|
||||
def test_only_a_run_still_going_can_be_stopped(self):
|
||||
ai_task_runs._finish_run(self.task, self.step["id"], ["running", "waiting"],
|
||||
{"running": {"status": {"completed": True}}, "waiting": {"status": {"completed": True}}})
|
||||
with mock.patch.object(ai_task_runs, "_queue", return_value=self.queue), self.assertRaises(ai_run_control.RunControlError):
|
||||
ai_run_control.stop(self.step["id"], "user", self.task)
|
||||
with self.assertRaises(ai_run_control.RunControlError):
|
||||
ai_run_control.stop("nope", "user", self.task)
|
||||
self.queue.delete_queue_item.assert_not_called()
|
||||
|
||||
def test_the_ai_hears_the_warnings_while_its_run_waits_or_goes(self):
|
||||
warning = {"kind": "big_sample", "node": "2", "title": "KSampler", "width": 4096, "height": 4096, "megapixels": 16.0}
|
||||
run = {"state": "running", "at": time.time(), "progress": {"state": "queued", "ahead": 1}, "estimate": {"warnings": [warning]}}
|
||||
self.assertIn("16.0 megapixels", ai_task_runs.summary(run))
|
||||
self.assertIn("stop_run", ai_task_runs.summary({**run, "progress": {"state": "running"}}))
|
||||
self.assertNotIn("Warning", ai_task_runs.summary({**run, "state": "done", "ms": 1000}))
|
||||
|
||||
def test_a_stopped_prompt_still_queued_is_not_counted_done(self):
|
||||
with mock.patch.object(ai_task_runs, "_queue", return_value=self.queue):
|
||||
ai_run_control.stop(self.step["id"], "ai", self.task)
|
||||
self.assertFalse(ai_task_runs._finish_run(self.task, self.step["id"], ["running", "waiting"], {}, queued={"waiting"}))
|
||||
self.assertEqual(ai_tasks.get_task(self.task)["steps"][0]["state"], "running")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1247,6 +1247,32 @@ export const i18n = {
|
||||
aiExecution_done: "出图已完成",
|
||||
aiExecution_error: "运行失败",
|
||||
aiExecution_stopped: "跟踪已停止",
|
||||
aiExecution_cancelled: "已停止运行",
|
||||
aiRunCancelledByYou: "■ 你停止了这次运行",
|
||||
aiRunCancelledByAi: "■ AI 停止了这次运行",
|
||||
aiRunStop: "停止",
|
||||
aiRunStopHint: "停止这次运行:还在排队的移出队列,正在跑的立刻中断(只动这次运行,不碰别人的)",
|
||||
aiRunStopping: "正在停止…",
|
||||
aiRunStoppingHint: "已经叫停:ComfyUI 会在当前节点做完后停下",
|
||||
aiRunStoppedByYou: "你停止了这次运行。AI 会收到通知,不会自己再跑一遍。",
|
||||
aiTool_user_stop: "你停止了运行",
|
||||
aiTool_stop_run: "停止了运行",
|
||||
aiTool_track_run: "接入了外部提交的运行",
|
||||
aiRunSeconds: "{seconds} 秒",
|
||||
aiRunLeft: "约剩 {time}",
|
||||
aiRunLeftHint: "当前节点按目前速度估算的剩余时间",
|
||||
aiRunRate: "{seconds} 秒/步",
|
||||
aiRunVram: "显存剩 {free} / {total} GB",
|
||||
aiRunVramHint: "显卡现在可用的显存 / 总显存",
|
||||
aiRunConsoleHint: "ComfyUI 控制台里的最新几行(AI 也能看到)。点开看全部",
|
||||
aiRunWarn_upscale_resample: "「{title}」要在放大模型放大后的 {width}×{height} 上再采样 {steps} 步,可能非常慢,甚至爆显存。只想把图放大的话,单用放大模型(纯模型超分)就够了;想重绘细节,就先缩小再采样,或者减少步数、降低重绘幅度。",
|
||||
aiRunWarn_big_sample: "「{title}」要在 {width}×{height}({megapixels} 百万像素)上采样,超出这张显卡的舒适范围(约 {comfort} 百万像素),可能非常慢或爆显存。可以先出小图再放大。",
|
||||
aiRunWarn_big_decode: "「{title}」要解码 {width}×{height} 的大图,很吃显存;换成 VAEDecodeTiled(分块解码)更稳。",
|
||||
aiRunSizes: "采样尺寸:{sizes}",
|
||||
aiRunSteps: "{steps} 步",
|
||||
aiTbDock: "收进侧边栏",
|
||||
aiTbExpand: "展开成大窗口",
|
||||
aiTbBack: "返回列表",
|
||||
aiExecution_asking: "等待允许运行",
|
||||
aiExecution_allowed: "已允许,等待提交",
|
||||
aiExecution_tool: "正在处理工具调用",
|
||||
@@ -4314,6 +4340,32 @@ export const i18n = {
|
||||
aiExecution_done: "Generation finished",
|
||||
aiExecution_error: "Run failed",
|
||||
aiExecution_stopped: "Tracking stopped",
|
||||
aiExecution_cancelled: "Run stopped",
|
||||
aiRunCancelledByYou: "■ You stopped this run",
|
||||
aiRunCancelledByAi: "■ The AI stopped this run",
|
||||
aiRunStop: "Stop",
|
||||
aiRunStopHint: "Stop this run: what is still queued leaves the queue, what is running is interrupted (only this run, never anyone else's)",
|
||||
aiRunStopping: "Stopping…",
|
||||
aiRunStoppingHint: "Stopped: ComfyUI stops once the current node is done",
|
||||
aiRunStoppedByYou: "You stopped this run. The AI is told and will not run it again on its own.",
|
||||
aiTool_user_stop: "You stopped the run",
|
||||
aiTool_stop_run: "Stopped the run",
|
||||
aiTool_track_run: "Attached a run submitted elsewhere",
|
||||
aiRunSeconds: "{seconds} s",
|
||||
aiRunLeft: "about {time} left",
|
||||
aiRunLeftHint: "Time left in the current node at its current pace",
|
||||
aiRunRate: "{seconds} s/step",
|
||||
aiRunVram: "VRAM free {free} / {total} GB",
|
||||
aiRunVramHint: "The graphics card's free / total memory now",
|
||||
aiRunConsoleHint: "The latest lines of ComfyUI's console (the AI sees them too). Open for all",
|
||||
aiRunWarn_upscale_resample: "\"{title}\" samples {steps} steps again at {width}×{height}, after an upscale model enlarged the picture: very slow, and it may run out of memory. To only make the picture bigger, the upscale model alone is enough; to redraw details, scale down before sampling, or use fewer steps and a lower denoise.",
|
||||
aiRunWarn_big_sample: "\"{title}\" samples at {width}×{height} ({megapixels} megapixels), more than this card is comfortable with (about {comfort}): very slow, or out of memory. Make it smaller and upscale afterwards.",
|
||||
aiRunWarn_big_decode: "\"{title}\" decodes a {width}×{height} picture, which takes a lot of memory; VAEDecodeTiled is safer.",
|
||||
aiRunSizes: "Sampling at: {sizes}",
|
||||
aiRunSteps: "{steps} steps",
|
||||
aiTbDock: "Dock to the side",
|
||||
aiTbExpand: "Expand to a large window",
|
||||
aiTbBack: "Back to the list",
|
||||
aiExecution_asking: "Waiting for permission",
|
||||
aiExecution_allowed: "Allowed; awaiting submission",
|
||||
aiExecution_tool: "Processing tool call",
|
||||
|
||||
@@ -37,6 +37,11 @@ const PATHS = {
|
||||
chevronLeft: '<path d="m14.5 6-6 6 6 6"/>',
|
||||
chevronRight: '<path d="m9.5 6 6 6-6 6"/>',
|
||||
pulse: '<path d="M3 12h4l2.5-5.5 4 11L16 12h5"/>',
|
||||
stop: '<rect x="6" y="6" width="12" height="12" rx="2.4" fill="currentColor"/>',
|
||||
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
|
||||
chip: '<rect x="6.5" y="6.5" width="11" height="11" rx="1.8"/><path d="M9.5 3v3.5M14.5 3v3.5M9.5 17.5V21M14.5 17.5V21M3 9.5h3.5M3 14.5h3.5M17.5 9.5H21M17.5 14.5H21"/>',
|
||||
terminal: '<rect x="3" y="4.5" width="18" height="15" rx="2.2"/><path d="m7 9.5 3 2.5-3 2.5M12.5 15h4.5"/>',
|
||||
expand: '<path d="M14 4h6v6M10 20H4v-6M20 4l-7 7M4 20l7-7"/>',
|
||||
image: '<rect x="3.5" y="4.5" width="17" height="15" rx="2.2"/><circle cx="9" cy="10" r="1.8"/><path d="m4 17.5 5-4.5 3.5 3 3-2.5 4.5 4"/>',
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* What an AI task made, and where it leads (ui_ai_workbench.js draws the task): the pictures a
|
||||
* step made (the viewer), its models (their page), the combo, the spoken audio (▶), the canvas
|
||||
* change (found on the canvas, undone while it can be), the scan; and the strip of everything
|
||||
* a task made above its timeline, and a run's progress while it goes (runProgress, kept live by
|
||||
* updateRunProgress from the server's events). `shared` is set by the workbench: the browser (`owner`) and
|
||||
* the activity entries of the task's canvas steps.
|
||||
* a task made above its timeline, and a run's progress while it goes with its stop button and
|
||||
* size warnings (ui_ai_run_live.js, kept live by updateRunProgress from the server's events).
|
||||
* `shared` is set by the workbench: the browser (`owner`), the task shown (`taskId`) and the
|
||||
* activity entries of the task's canvas steps.
|
||||
*/
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
@@ -17,8 +18,12 @@ import { listCombos } from './image_keep.js';
|
||||
import { audioResult, outputUrl, taskResults } from './ai_task_view.js';
|
||||
import { imageResults } from './ui_ai_images.js';
|
||||
import { canvasChanges } from './ui_canvas_changes.js';
|
||||
import { estimateBox, runProgress } from './ui_ai_run_live.js';
|
||||
|
||||
export const shared = { owner: null, entries: new Map(), player: null };
|
||||
export const shared = { owner: null, taskId: null, entries: new Map(), player: null };
|
||||
|
||||
/** A run step's progress with its stop button (while the task shown is the run's). */
|
||||
const liveRun = progress => runProgress(progress, shared.taskId ? { task: shared.taskId, step: progress.step } : null);
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -83,43 +88,12 @@ export function locateNode(id, note) {
|
||||
app.canvas.setDirty?.(true, true);
|
||||
}
|
||||
|
||||
/** Where a run stands (api/ai_task_runs.py progress_of): queued, or the node running and its steps. */
|
||||
export function runProgress(progress) {
|
||||
const box = el('div', 'anomalous-ai-run-progress');
|
||||
let words;
|
||||
let share = null;
|
||||
let percent = '';
|
||||
if (progress.state === 'submitting') {
|
||||
words = t('aiExecution_submitting');
|
||||
} else if (progress.state === 'queued') {
|
||||
words = t('aiRunQueued', { ahead: progress.ahead });
|
||||
} else {
|
||||
const parts = [];
|
||||
if (progress.prompts > 1) parts.push(t('aiRunPrompt', { at: progress.prompt, count: progress.prompts }));
|
||||
if (progress.node) parts.push(progress.max > 1 ? `${progress.node} ${progress.value}/${progress.max}` : progress.node);
|
||||
if (progress.nodes) parts.push(t('aiRunNodes', { done: progress.nodes_done, count: progress.nodes }));
|
||||
words = parts.join(' · ') || t('aiRunRunning');
|
||||
share = progress.max > 1 ? progress.value / progress.max : progress.nodes ? progress.nodes_done / progress.nodes : null;
|
||||
if (Number.isFinite(progress.percent)) share = progress.percent / 100;
|
||||
if (share !== null) percent = `${Math.round(Math.min(1, Math.max(0, share)) * 100)}%`;
|
||||
}
|
||||
const bar = el('div', `anomalous-ai-run-bar${share === null ? ' is-waiting' : ''}`);
|
||||
const fill = el('span', 'anomalous-ai-run-fill');
|
||||
if (share !== null) fill.style.width = `${Math.round(Math.min(1, Math.max(0, share)) * 100)}%`;
|
||||
bar.append(fill);
|
||||
const line = el('div', 'anomalous-ai-run-line');
|
||||
line.append(el('span', 'anomalous-ai-run-words', words));
|
||||
if (percent) line.append(el('b', 'anomalous-ai-run-percent', percent));
|
||||
box.append(line, bar);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** A progress event for step `progress.step` drawn into `root` in place; false when it is not shown there. */
|
||||
export function updateRunProgress(root, progress) {
|
||||
const row = [...(root?.querySelectorAll('[data-step-ids]') || [])].find(item => item.dataset.stepIds.split(' ').includes(progress.step));
|
||||
const body = row?.querySelector('.anomalous-ai-step-body');
|
||||
if (!body) return false;
|
||||
const fresh = runProgress(progress);
|
||||
const fresh = liveRun(progress);
|
||||
const there = body.querySelector('.anomalous-ai-run-progress');
|
||||
if (there) there.replaceWith(fresh);
|
||||
else body.append(fresh);
|
||||
@@ -129,7 +103,9 @@ export function updateRunProgress(root, progress) {
|
||||
/** The links under a step: what it made and where that leads. */
|
||||
export function resultLinks(step, changed = () => {}) {
|
||||
const box = el('div', 'anomalous-ai-results');
|
||||
if (step.tool === 'run_workflow' && step.state === 'running' && step.progress) box.append(runProgress(step.progress));
|
||||
if (step.tool === 'run_workflow' && step.state === 'running') box.append(liveRun({ state: 'submitting', ...step.progress, step: step.id, stopping: step.stop || step.progress?.stopping }));
|
||||
const sizes = step.tool === 'run_workflow' ? estimateBox(step.estimate) : null;
|
||||
if (sizes) box.append(sizes);
|
||||
const pictures = (step.results || []).filter(result => result.type === 'image');
|
||||
if (pictures.length) box.append(imageResults(pictures, shared.owner));
|
||||
const links = el('div', 'anomalous-ai-links');
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* A run the AI started, while it goes (api/ai_task_runs.py, api/ai_run_watch.py): runProgress
|
||||
* draws where it stands (queued, the node and its steps, the percent), the time left and per
|
||||
* step, the graphics card's memory, the console's own note and last lines (what the console
|
||||
* window shows), and a stop button (api/ai_run_control.py); estimateBox the size each sampler
|
||||
* works at and the warnings for a run too big for the card. Used on the run's step in the
|
||||
* timeline (ui_ai_results.js) and in the run-status card (ui_ai_status.js).
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
import { icon } from './ui_ai_icons.js';
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** "1:05" / "12 s" for `seconds`. */
|
||||
export function clock(seconds) {
|
||||
const whole = Math.max(0, Math.round(seconds));
|
||||
if (whole < 60) return t('aiRunSeconds', { seconds: whole });
|
||||
const hours = Math.floor(whole / 3600);
|
||||
const minutes = Math.floor((whole % 3600) / 60);
|
||||
const rest = String(whole % 60).padStart(2, '0');
|
||||
return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${rest}` : `${minutes}:${rest}`;
|
||||
}
|
||||
|
||||
/** "3.9 / 12 GB" free of all. */
|
||||
export function memory(vram) {
|
||||
const gb = mb => (mb / 1024).toFixed(mb >= 10240 ? 0 : 1);
|
||||
return t('aiRunVram', { free: gb(vram.free_mb), total: gb(vram.total_mb) });
|
||||
}
|
||||
|
||||
/** The stop button of run step `stepId` in task `taskId`; `stopped()` after the server took it.
|
||||
* `stopping`: the run was stopped already and ends when its node does (ComfyUI stops between nodes). */
|
||||
export function stopButton(taskId, stepId, stopped = () => {}, compact = false, stopping = false) {
|
||||
const node = el('button', `anomalous-ai-run-stop${compact ? ' is-compact' : ''}${stopping ? ' is-busy' : ''}`);
|
||||
node.type = 'button';
|
||||
node.title = t(stopping ? 'aiRunStoppingHint' : 'aiRunStopHint');
|
||||
node.disabled = stopping;
|
||||
node.append(icon('stop', compact ? 13 : 14));
|
||||
if (!compact) node.append(el('span', '', t(stopping ? 'aiRunStopping' : 'aiRunStop')));
|
||||
node.onclick = async (event) => {
|
||||
event.stopPropagation();
|
||||
node.disabled = true;
|
||||
node.classList.add('is-busy');
|
||||
try {
|
||||
const response = await fetch(`/anomalous/ai_tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(stepId)}/stop`, { method: 'POST' });
|
||||
const answer = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(answer.error || response.statusText);
|
||||
node.replaceChildren(icon('stop', compact ? 13 : 14), ...(compact ? [] : [el('span', '', t('aiRunStopping'))]));
|
||||
stopped();
|
||||
} catch (error) {
|
||||
node.disabled = false;
|
||||
node.classList.remove('is-busy');
|
||||
await anomalousAlert(error.message);
|
||||
}
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Where a run stands; with `stop` ({task, step, stopped}) a stop button beside the bar. */
|
||||
export function runProgress(progress, stop = null) {
|
||||
const box = el('div', 'anomalous-ai-run-progress');
|
||||
let words;
|
||||
let share = null;
|
||||
if (progress.state === 'submitting') {
|
||||
words = t('aiExecution_submitting');
|
||||
} else if (progress.state === 'queued') {
|
||||
words = t('aiRunQueued', { ahead: progress.ahead });
|
||||
} else {
|
||||
const parts = [];
|
||||
if (progress.prompts > 1) parts.push(t('aiRunPrompt', { at: progress.prompt, count: progress.prompts }));
|
||||
if (progress.node) parts.push(progress.max > 1 ? `${progress.node} ${progress.value}/${progress.max}` : progress.node);
|
||||
if (progress.nodes) parts.push(t('aiRunNodes', { done: progress.nodes_done, count: progress.nodes }));
|
||||
words = parts.join(' · ') || t('aiRunRunning');
|
||||
if (Number.isFinite(progress.percent)) share = progress.percent / 100;
|
||||
else if (progress.max > 1) share = progress.value / progress.max;
|
||||
else if (progress.nodes) share = progress.nodes_done / progress.nodes;
|
||||
}
|
||||
const line = el('div', 'anomalous-ai-run-line');
|
||||
line.append(el('span', 'anomalous-ai-run-words', words));
|
||||
if (share !== null) line.append(el('b', 'anomalous-ai-run-percent', `${Math.round(Math.min(1, Math.max(0, share)) * 100)}%`));
|
||||
const bar = el('div', `anomalous-ai-run-bar${share === null ? ' is-waiting' : ''}`);
|
||||
const fill = el('span', 'anomalous-ai-run-fill');
|
||||
if (share !== null) fill.style.width = `${Math.round(Math.min(1, Math.max(0, share)) * 100)}%`;
|
||||
bar.append(fill);
|
||||
const row = el('div', 'anomalous-ai-run-row');
|
||||
row.append(bar);
|
||||
if (stop?.task && stop?.step) row.append(stopButton(stop.task, stop.step, stop.stopped, false, Boolean(progress.stopping)));
|
||||
box.append(line, row);
|
||||
|
||||
const facts = el('div', 'anomalous-ai-run-facts');
|
||||
const fact = (name, text, title) => {
|
||||
const item = el('span', 'anomalous-ai-run-fact');
|
||||
item.append(icon(name, 13), el('span', '', text));
|
||||
if (title) item.title = title;
|
||||
facts.append(item);
|
||||
};
|
||||
if (Number.isFinite(progress.left)) fact('clock', t('aiRunLeft', { time: clock(progress.left) }), t('aiRunLeftHint'));
|
||||
if (Number.isFinite(progress.rate)) fact('pulse', t('aiRunRate', { seconds: progress.rate }));
|
||||
if (progress.vram?.total_mb) fact('chip', memory(progress.vram), t('aiRunVramHint'));
|
||||
if (facts.childNodes.length) box.append(facts);
|
||||
|
||||
const lines = progress.console || [];
|
||||
if (progress.note || lines.length) {
|
||||
const say = el('details', 'anomalous-ai-run-console');
|
||||
const head = el('summary', 'anomalous-ai-run-console-head');
|
||||
head.append(icon('terminal', 13), el('span', 'anomalous-ai-run-console-last', progress.note || lines.at(-1)));
|
||||
head.title = t('aiRunConsoleHint');
|
||||
say.append(head);
|
||||
if (lines.length) say.append(el('pre', 'anomalous-ai-run-console-lines', lines.join('\n')));
|
||||
box.append(say);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** The size each sampler of a run works at, and what is too big for the card; null when nothing to say. */
|
||||
export function estimateBox(estimate) {
|
||||
const warnings = estimate?.warnings || [];
|
||||
const samplers = estimate?.samplers || [];
|
||||
if (!warnings.length && !samplers.length) return null;
|
||||
const box = el('div', `anomalous-ai-estimate${warnings.length ? ' is-warn' : ''}`);
|
||||
for (const warning of warnings) {
|
||||
const item = el('div', 'anomalous-ai-estimate-warning');
|
||||
item.append(icon('alert', 15), el('span', '', t(`aiRunWarn_${warning.kind}`, {
|
||||
title: warning.title, width: warning.width, height: warning.height, steps: warning.steps ?? '?',
|
||||
megapixels: warning.megapixels ?? '', comfort: estimate.comfort_mp ?? '',
|
||||
})));
|
||||
box.append(item);
|
||||
}
|
||||
if (samplers.length) {
|
||||
box.append(el('div', 'anomalous-ai-estimate-sizes', t('aiRunSizes', {
|
||||
sizes: samplers.map(item => `${item.title} ${item.width}×${item.height}${item.batch > 1 ? ` ×${item.batch}` : ''}${item.steps ? ` · ${t('aiRunSteps', { steps: item.steps })}` : ''}`).join('; '),
|
||||
})));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
/** Compact toolbar icon-button + fixed-position popover for confirmed task execution status. */
|
||||
/** Compact toolbar icon-button + fixed-position popover for confirmed task execution status; while
|
||||
* a run goes, a stop button beside it, and the card shows the run's time left, memory, console and
|
||||
* size warnings (ui_ai_run_live.js). */
|
||||
import { translate as t } from './locales.js';
|
||||
import { taskStatus } from './ai_task_status.js';
|
||||
import { icon } from './ui_ai_icons.js';
|
||||
import { runProgress } from './ui_ai_results.js';
|
||||
import { estimateBox, runProgress, stopButton } from './ui_ai_run_live.js';
|
||||
|
||||
let openTaskId = null;
|
||||
let boundGlobal = false;
|
||||
@@ -81,6 +83,7 @@ function activeLabel(status) {
|
||||
function buildPopover(task, status, refresh) {
|
||||
const card = el('section', `anomalous-ai-status-popover is-${status.state}`);
|
||||
card.setAttribute('aria-label', t('aiExecution'));
|
||||
card.dataset.anomalousEscape = ''; // Esc closes this card, not the browser (ui_browser_navigation.js)
|
||||
const top = el('div', 'anomalous-ai-execution-top');
|
||||
top.append(
|
||||
el('span', 'anomalous-ai-execution-label', t('aiExecution')),
|
||||
@@ -98,7 +101,9 @@ function buildPopover(task, status, refresh) {
|
||||
|
||||
if (status.run) {
|
||||
const run = status.run;
|
||||
if (run.state === 'running') card.append(runProgress(run.progress || { state: 'submitting' }));
|
||||
if (run.state === 'running') card.append(runProgress({ state: 'submitting', ...run.progress }, { task: task.id, step: run.id, stopped: refresh }));
|
||||
const sizes = estimateBox(run.estimate);
|
||||
if (sizes) card.append(sizes);
|
||||
const count = (run.prompt_ids || []).length;
|
||||
const pictures = (run.results || []).filter(item => item.type === 'image').length;
|
||||
const rows = el('div', 'anomalous-ai-execution-rows');
|
||||
@@ -157,6 +162,10 @@ export function executionStatus(task, refresh) {
|
||||
}
|
||||
};
|
||||
wrap.append(btn);
|
||||
if (status.run?.state === 'running' && status.run.prompt_ids?.length) {
|
||||
wrap.append(stopButton(task.id, status.run.id, refresh, true, Boolean(status.run.stop || status.run.progress?.stopping)));
|
||||
}
|
||||
if (status.run?.state === 'running' && status.run.estimate?.warnings?.length) btn.classList.add('has-warning');
|
||||
if (isOpen) openPopover();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* "给 AI 的素材": what the user hands the AI to work with in a task (api/ai_toolbox.py). A large
|
||||
* window: a search over everything, the kinds on the left (node types, combos, workflows,
|
||||
* "给 AI 的素材": what the user hands the AI to work with in a task (api/ai_toolbox.py). A side
|
||||
* panel on the right of the screen that leaves the workbench and the canvas usable (the kinds as
|
||||
* tabs, the cards in two columns, a card's detail over them), or, expanded, a large window: a
|
||||
* search over everything, the kinds on the left (node types, combos, workflows,
|
||||
* prompts, models) with how many are picked, a second column to narrow by source (each custom
|
||||
* node pack with its badge, ComfyUI's own, paid API nodes), by where a prompt comes from or by
|
||||
* model type, a grid of cards (ui_ai_toolbox_cards.js) drawn as it scrolls, the card in focus
|
||||
* in full on the right, and the picks below with how strictly the AI keeps to them. Node types
|
||||
* are the page's own (no waiting); the rest is read ahead and kept (ai_toolbox_data.js).
|
||||
* are the page's own (no waiting); the rest is read ahead and kept (ai_toolbox_data.js). The
|
||||
* user's last choice of the two is remembered on this browser.
|
||||
* toolboxButton() is the entry in a task's header, toolboxStep() the timeline row of a pick.
|
||||
*/
|
||||
|
||||
@@ -14,12 +17,14 @@ import { anomalousAlert } from './ui_dialog.js';
|
||||
import { aiWindow } from './ui_ai_connect.js';
|
||||
import { KINDS, keptCatalog, loadCatalog, nodeItems, nodeSources, prefetchToolbox, searchItems } from './ai_toolbox_data.js';
|
||||
import { card, coverUrl, detail, refreshCard } from './ui_ai_toolbox_cards.js';
|
||||
import { icon } from './ui_ai_icons.js';
|
||||
import { icon, iconButton } from './ui_ai_icons.js';
|
||||
import { emptyPicture, sourceBadge } from './ui_ai_toolbox_icons.js';
|
||||
|
||||
const MODES = ['prefer', 'only'];
|
||||
const CHUNK = 60;
|
||||
const SEARCH_DELAY_MS = 120;
|
||||
const LAYOUT_KEY = 'anomalous.aiToolboxLayout';
|
||||
let openNow = null; // {task, body, close} of the picker on screen
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -48,6 +53,33 @@ const empty = () => Object.fromEntries(KINDS.map(kind => [kind, []]));
|
||||
const total = box => KINDS.reduce((sum, kind) => sum + (box?.[kind]?.length || 0), 0);
|
||||
const keyOf = (kind, id) => `${kind}|${id}`;
|
||||
|
||||
function savedLayout() {
|
||||
try { return localStorage.getItem(LAYOUT_KEY) === 'full' ? 'full' : 'side'; } catch (_) { return 'side'; }
|
||||
}
|
||||
|
||||
function keepLayout(layout) {
|
||||
try { localStorage.setItem(LAYOUT_KEY, layout); } catch (_) { /* only not remembered */ }
|
||||
}
|
||||
|
||||
/** `body` in a panel on the right of the screen (nothing else blocked; the full-screen browser makes
|
||||
* room for it): its title, expand, close. Returns close. */
|
||||
function dockPanel(title, body, expand) {
|
||||
const panel = el('aside', 'anomalous-tb-dock');
|
||||
panel.setAttribute('aria-label', title);
|
||||
const head = el('div', 'anomalous-tb-dock-head');
|
||||
const close = () => {
|
||||
panel.remove();
|
||||
document.body.classList.remove('anomalous-tb-docked');
|
||||
};
|
||||
document.body.classList.add('anomalous-tb-docked');
|
||||
head.append(icon('sparkle', 16), el('div', 'anomalous-tb-dock-title', title),
|
||||
iconButton('anomalous-tb-dock-btn', 'expand', t('aiTbExpand'), expand, { iconOnly: true }),
|
||||
iconButton('anomalous-tb-dock-btn', 'close', t('close'), close, { iconOnly: true }));
|
||||
panel.append(head, body);
|
||||
document.body.append(panel);
|
||||
return close;
|
||||
}
|
||||
|
||||
let nodesKept = null; // the page's node types do not change while it is open
|
||||
const pageNodes = () => (nodesKept ||= nodeItems(globalThis.LiteGraph?.registered_node_types));
|
||||
|
||||
@@ -77,6 +109,12 @@ function facets(items, kind) {
|
||||
|
||||
/** The window for task `task`; `changed()` after the picks were handed over. */
|
||||
export async function openToolbox(task, changed = () => {}) {
|
||||
if (openNow?.body.isConnected) {
|
||||
const same = openNow.task === task.id;
|
||||
openNow.close();
|
||||
openNow = null;
|
||||
if (same) return; // its button again: put it away
|
||||
}
|
||||
const fromTask = Boolean(task.toolbox);
|
||||
const state = {
|
||||
kind: 'nodes', query: '', filter: 'all', facet: null, focus: null, mode: task.toolbox?.mode || 'prefer',
|
||||
@@ -117,8 +155,31 @@ export async function openToolbox(task, changed = () => {}) {
|
||||
main.append(rail, side, gridWrap, pane);
|
||||
const tray = el('div', 'anomalous-tb-tray');
|
||||
body.append(top, main, tray);
|
||||
const close = aiWindow(t('aiToolboxTitle'), body);
|
||||
body.closest('.anomalous-ai-connect-dialog')?.classList.add('is-toolbox');
|
||||
let closeFrame = () => {};
|
||||
/** Shows the picker as a side panel or a large window, moving the same `body` (nothing is redrawn but the pane). */
|
||||
function place(layout) {
|
||||
const scrolled = scroller.scrollTop;
|
||||
body.remove();
|
||||
closeFrame();
|
||||
keepLayout(layout);
|
||||
body.classList.toggle('is-side', layout === 'side');
|
||||
if (layout === 'side') {
|
||||
closeFrame = dockPanel(t('aiToolboxTitle'), body, () => place('full'));
|
||||
} else {
|
||||
closeFrame = aiWindow(t('aiToolboxTitle'), body);
|
||||
const dialog = body.closest('.anomalous-ai-connect-dialog');
|
||||
dialog?.classList.add('is-toolbox');
|
||||
const shut = dialog?.querySelector('.anomalous-ai-connect-close');
|
||||
shut?.before(iconButton('anomalous-tb-dock-btn', 'panel', t('aiTbDock'), () => place('side'), { iconOnly: true }));
|
||||
}
|
||||
scroller.scrollTop = scrolled;
|
||||
drawPane();
|
||||
}
|
||||
const close = () => {
|
||||
body.remove();
|
||||
closeFrame();
|
||||
if (openNow?.body === body) openNow = null;
|
||||
};
|
||||
|
||||
const picked = item => state.picks[item.kind].includes(item.id);
|
||||
const liked = item => state.favorites[item.kind].includes(item.id);
|
||||
@@ -256,8 +317,11 @@ export async function openToolbox(task, changed = () => {}) {
|
||||
}
|
||||
|
||||
function drawPane() {
|
||||
const side = body.classList.contains('is-side');
|
||||
pane.hidden = side && !state.focus; // beside the cards in the window; over them, while a card is open, in the side panel
|
||||
if (state.focus) {
|
||||
pane.replaceChildren(detail(state.focus, ctx));
|
||||
const back = button('anomalous-tb-pane-back', [icon('chevronLeft', 16), t('aiTbBack')], () => { state.focus = null; drawPane(); });
|
||||
pane.replaceChildren(...(side ? [back] : []), detail(state.focus, ctx));
|
||||
return;
|
||||
}
|
||||
const box = el('div', 'anomalous-tb-pane-empty');
|
||||
@@ -352,6 +416,8 @@ export async function openToolbox(task, changed = () => {}) {
|
||||
search.focus();
|
||||
}
|
||||
});
|
||||
place(savedLayout());
|
||||
openNow = { task: task.id, body, close };
|
||||
drawAll();
|
||||
drawTray();
|
||||
search.focus();
|
||||
|
||||
@@ -137,6 +137,9 @@ function stepRow(step, number) {
|
||||
body.append(toolboxStep(step));
|
||||
} else if (step.tool === 'user_activity') {
|
||||
body.append(userChange(step));
|
||||
} else if (step.tool === 'user_stop') {
|
||||
body.append(el('div', 'anomalous-ai-user-text', t('aiRunStoppedByYou')),
|
||||
el('div', 'anomalous-ai-user-state', t(step.delivered ? 'aiMessageSeen' : 'aiMessageWaiting')));
|
||||
} else if (step.kind === 'user') {
|
||||
body.append(el('div', 'anomalous-ai-user-text', step.text),
|
||||
el('div', 'anomalous-ai-user-state', t(step.delivered ? 'aiMessageSeen' : 'aiMessageWaiting')));
|
||||
@@ -154,7 +157,8 @@ function stepRow(step, number) {
|
||||
const gate = state.task && gateCard(step, state.task.id, () => void refresh());
|
||||
if (gate) body.append(gate);
|
||||
if (step.error && !gate) body.append(el('div', 'anomalous-ai-step-error', step.error));
|
||||
if (running) body.append(el('div', 'anomalous-ai-step-running', t(step.kind === 'run' ? 'aiRunRunning' : 'aiStepRunning')));
|
||||
if (step.state === 'cancelled') body.append(el('div', 'anomalous-ai-step-cancelled', t(step.stop === 'user' ? 'aiRunCancelledByYou' : 'aiRunCancelledByAi')));
|
||||
if (running && step.kind !== 'run') body.append(el('div', 'anomalous-ai-step-running', t('aiStepRunning')));
|
||||
const links = resultLinks(step, renderTask);
|
||||
if (links) body.append(links);
|
||||
const when = el('div', 'anomalous-ai-step-when');
|
||||
@@ -433,6 +437,7 @@ async function select(id) {
|
||||
if (state.selected !== id || request !== state.request) return;
|
||||
state.task = task;
|
||||
shared.entries = state.entries = entries;
|
||||
shared.taskId = task?.id || null;
|
||||
renderTask();
|
||||
}
|
||||
|
||||
@@ -497,6 +502,7 @@ async function refresh() {
|
||||
if (request !== state.request) return;
|
||||
state.task = task;
|
||||
shared.entries = state.entries = entries;
|
||||
shared.taskId = task?.id || null;
|
||||
renderTask();
|
||||
layout();
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ const BACK_BUTTONS = '.anomalous-detail-back, .anomalous-nb-back, .anomalous-wfd
|
||||
* Esc on the browser: a page one level down (a model's details, one combo or one recipe) goes
|
||||
* back as its own back button does; any other page closes the browser. Not while one of this
|
||||
* plugin's dialogs, overlays or menus, or a ComfyUI dialog, sits above it (they close
|
||||
* themselves), and not while a text field has the key (Esc there belongs to the field). It
|
||||
* themselves; a card inside the browser that does says so with `data-anomalous-escape`), and
|
||||
* not while a text field has the key (Esc there belongs to the field). It
|
||||
* listens first (capture), so a dialog that closes on this same Esc still counts as above.
|
||||
* ComfyUI's own keybindings mark Esc as handled, so `defaultPrevented` says nothing here.
|
||||
*/
|
||||
@@ -78,6 +79,7 @@ export function bindBrowserEscape(owner) {
|
||||
.some(el => el !== owner.modal && el.getClientRects().length
|
||||
&& (el.getAttribute('role') === 'menu' || [...el.classList].some(c => c.startsWith('anomalous-') && c.endsWith('overlay'))));
|
||||
if (layered || document.querySelector('dialog[open], .p-dialog-mask')) return;
|
||||
if ([...document.querySelectorAll('[data-anomalous-escape]')].some(el => el.getClientRects().length)) return;
|
||||
// Voice-over lets go of its picked lines with this Esc (ui_script_director.js).
|
||||
if (owner.modal.querySelector('.anomalous-sd-line-card.is-picked')) return;
|
||||
// A model's Outputs window lies over its details inside the browser: Esc closes it, as its ✖ does.
|
||||
|
||||
+4
-4
@@ -35,11 +35,11 @@
|
||||
@import url("./styles/34-voice-over-panel.css?v=20261021-sample");
|
||||
@import url("./styles/35-voice-over-drafts.css?v=20261020-drafts");
|
||||
@import url("./styles/36-reference-audio.css?v=20261020-remove");
|
||||
@import url("./styles/37-ai-workbench.css?v=20261023-popover");
|
||||
@import url("./styles/37-ai-workbench.css?v=20261024-cancel");
|
||||
@import url("./styles/38-ai-panels.css?v=20261023-ask");
|
||||
@import url("./styles/39-ai-stage.css?v=20261021-stage");
|
||||
@import url("./styles/40-ai-side.css?v=20261023-look");
|
||||
@import url("./styles/41-ai-results.css?v=20261023-tags");
|
||||
@import url("./styles/42-ai-flow.css?v=20261023-inpaint");
|
||||
@import url("./styles/43-ai-toolbox.css?v=20261023-art");
|
||||
@import url("./styles/44-ai-status.css?v=20261023-popover2");
|
||||
@import url("./styles/42-ai-flow.css?v=20261024-runlive");
|
||||
@import url("./styles/43-ai-toolbox.css?v=20261024-dock2");
|
||||
@import url("./styles/44-ai-status.css?v=20261024-stop");
|
||||
|
||||
@@ -569,6 +569,15 @@
|
||||
.anomalous-ai-reply-link:hover { border-color: var(--amb-link); color: var(--amb-text-main); }
|
||||
.anomalous-ai-reply-link.is-replied { border-color: color-mix(in srgb, var(--amb-ok) 55%, transparent); color: var(--amb-ok-text); }
|
||||
|
||||
.anomalous-ai-step-cancelled {
|
||||
align-self: flex-start;
|
||||
margin-top: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--amb-ink-rgb), 0.08);
|
||||
color: var(--amb-text-soft);
|
||||
font-size: 12px;
|
||||
}
|
||||
.anomalous-ai-step-error {
|
||||
color: var(--amb-danger-text);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -159,6 +159,63 @@
|
||||
.anomalous-ai-run-bar.is-waiting .anomalous-ai-run-fill { width: 30%; animation: anomalous-ai-run-wait 1.6s ease-in-out infinite; }
|
||||
@keyframes anomalous-ai-run-wait { 0% { left: -30%; } 100% { left: 100%; } }
|
||||
@media (prefers-reduced-motion: reduce) { .anomalous-ai-run-bar.is-waiting .anomalous-ai-run-fill { animation: none; } }
|
||||
/* Beside the bar, the stop button (ui_ai_run_live.js); under it the time left, memory and the console. */
|
||||
.anomalous-ai-run-row { display: flex; align-items: center; gap: 10px; }
|
||||
.anomalous-ai-run-row .anomalous-ai-run-bar { flex: 1; }
|
||||
.anomalous-ai-run-stop {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--amb-danger) 55%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--amb-danger) 12%, transparent);
|
||||
color: var(--amb-danger-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anomalous-ai-run-stop:hover { background: color-mix(in srgb, var(--amb-danger) 24%, transparent); }
|
||||
.anomalous-ai-run-stop:disabled { cursor: progress; opacity: 0.7; }
|
||||
.anomalous-ai-run-stop.is-compact { width: 28px; height: 28px; margin-left: 4px; padding: 0; justify-content: center; }
|
||||
.anomalous-ai-run-facts { display: flex; flex-wrap: wrap; gap: 4px 14px; color: var(--amb-text-dim); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||
.anomalous-ai-run-fact { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.anomalous-ai-run-console { min-width: 0; border-radius: 8px; background: rgba(var(--amb-ink-rgb), 0.05); }
|
||||
.anomalous-ai-run-console-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
color: var(--amb-text-soft);
|
||||
font: 11.5px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.anomalous-ai-run-console-head::-webkit-details-marker { display: none; }
|
||||
.anomalous-ai-run-console-last { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.anomalous-ai-run-console-lines {
|
||||
margin: 0;
|
||||
padding: 4px 8px 8px 27px;
|
||||
overflow-x: auto;
|
||||
color: var(--amb-text-dim);
|
||||
font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
/* How big the run's samplers work, and what is too big for the card. */
|
||||
.anomalous-ai-estimate { display: flex; flex-direction: column; gap: 6px; max-width: 620px; margin: 6px 0 4px; }
|
||||
.anomalous-ai-estimate.is-warn {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--amb-warn) 45%, transparent);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--amb-warn) 10%, transparent);
|
||||
}
|
||||
.anomalous-ai-estimate-warning { display: flex; gap: 8px; color: var(--amb-warn-text); font-size: 12.5px; line-height: 1.55; }
|
||||
.anomalous-ai-estimate-warning .anomalous-tb-icon { margin-top: 2px; }
|
||||
.anomalous-ai-estimate-sizes { color: var(--amb-text-dim); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Marking a picture for the AI (ui_ai_marker.js), and the marks sent, in the timeline. */
|
||||
.anomalous-ai-marker-overlay {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* "给 AI 的素材" (ui_ai_toolbox.js, ui_ai_toolbox_cards.js, ui_ai_toolbox_icons.js): the large
|
||||
picker window (search, kinds, sources, card grid, detail pane, tray), the entry in a task's
|
||||
header and the timeline row of a pick. */
|
||||
picker window (search, kinds, sources, card grid, detail pane, tray), the same picker as a side
|
||||
panel (.is-side, at the end), the entry in a task's header and the timeline row of a pick. */
|
||||
.anomalous-ai-connect-dialog.is-toolbox {
|
||||
width: min(1340px, 96vw);
|
||||
height: min(880px, 90vh);
|
||||
@@ -250,6 +250,21 @@
|
||||
|
||||
/* The detail pane. */
|
||||
.anomalous-tb-pane { min-width: 0; padding: 16px; overflow-y: auto; border-left: 1px solid var(--amb-border); background: rgba(var(--amb-ink-rgb), 0.025); }
|
||||
.anomalous-tb-pane-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: -4px 0 10px -6px;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--amb-text-soft);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anomalous-tb-pane-back:hover { background: rgba(var(--amb-ink-rgb), 0.07); color: var(--amb-text-main); }
|
||||
.anomalous-tb-pane-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 30px; text-align: center; }
|
||||
.anomalous-tb-pane-title { color: var(--amb-text-main); font-size: 15px; font-weight: 700; }
|
||||
.anomalous-tb-empty-art { width: 220px; height: 220px; }
|
||||
@@ -392,6 +407,82 @@
|
||||
.anomalous-tb-kind.is-on::before { display: none; }
|
||||
.anomalous-tb-tray { flex-wrap: wrap; }
|
||||
}
|
||||
/* The side panel: on the right of the screen, the workbench and canvas still usable beside it. */
|
||||
.anomalous-tb-dock {
|
||||
position: fixed;
|
||||
z-index: 1000000;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: min(400px, calc(100vw - 24px));
|
||||
box-sizing: border-box;
|
||||
padding: 12px 12px 14px;
|
||||
border: 1px solid var(--amb-border-strong, var(--amb-border));
|
||||
border-radius: 16px;
|
||||
background: var(--amb-bg-panel);
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
|
||||
color: var(--amb-text-main);
|
||||
}
|
||||
/* The full-screen browser's AI page moves over for it, so the task's own buttons stay in reach. */
|
||||
body.anomalous-tb-docked #anomalous-container.anomalous-maximized .anomalous-ai { padding-right: calc(min(400px, 100vw - 24px) + 12px); }
|
||||
.anomalous-tb-dock-head { display: flex; align-items: center; gap: 8px; padding: 0 2px; color: var(--amb-link); }
|
||||
.anomalous-tb-dock-title { flex: 1; min-width: 0; color: var(--amb-text-main); font-size: 14.5px; font-weight: 700; }
|
||||
.anomalous-tb-dock-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--amb-text-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
.anomalous-tb-dock-btn:hover { background: rgba(var(--amb-ink-rgb), 0.08); color: var(--amb-text-main); }
|
||||
.anomalous-ai-connect-head .anomalous-tb-dock-btn { margin-left: auto; }
|
||||
.anomalous-tb.is-side { gap: 10px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-top { gap: 8px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-for, .anomalous-tb.is-side .anomalous-tb-kbd, .anomalous-tb.is-side .anomalous-tb-facets-title { display: none; }
|
||||
.anomalous-tb.is-side .anomalous-tb-search { flex-basis: 100%; height: 36px; border-radius: 10px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-filters { display: flex; width: 100%; }
|
||||
.anomalous-tb.is-side .anomalous-tb-filter { flex: 1; justify-content: center; height: 28px; padding: 0 6px; font-size: 12px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-main,
|
||||
.anomalous-tb.is-side .anomalous-tb-main:has(> .anomalous-tb-facets[hidden]) {
|
||||
position: relative;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.anomalous-tb.is-side .anomalous-tb-rail, .anomalous-tb.is-side .anomalous-tb-facets {
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
overflow-x: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--amb-border);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.anomalous-tb.is-side .anomalous-tb-kind { flex: 1 0 auto; justify-content: center; gap: 4px; min-height: 32px; padding: 0 6px; font-size: 12px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-kind .anomalous-tb-icon svg { width: 16px; height: 16px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-kind-meta { margin-left: 0; }
|
||||
.anomalous-tb.is-side .anomalous-tb-kind-matches { display: none; }
|
||||
.anomalous-tb.is-side .anomalous-tb-kind.is-on::before { display: none; }
|
||||
.anomalous-tb.is-side .anomalous-tb-facet { flex: none; max-width: 170px; min-height: 28px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-grid-head { padding: 8px 10px 6px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-scroller { padding: 10px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-grid { grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); gap: 8px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-pane { position: absolute; z-index: 2; inset: 0; display: block; border-left: 0; background: var(--amb-bg-panel); }
|
||||
.anomalous-tb.is-side .anomalous-tb-pane[hidden] { display: none; }
|
||||
.anomalous-tb.is-side .anomalous-tb-tray { flex-wrap: wrap; gap: 8px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-chips { flex-basis: 100%; }
|
||||
.anomalous-tb.is-side .anomalous-tb-modes { flex: 1; }
|
||||
.anomalous-tb.is-side .anomalous-tb-mode { flex: 1; justify-content: center; height: 30px; padding: 0 8px; font-size: 12px; }
|
||||
.anomalous-tb.is-side .anomalous-tb-send { flex-basis: 100%; justify-content: center; height: 38px; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anomalous-tb-card, .anomalous-tb-card:hover .anomalous-tb-cover img, .anomalous-tb-send { transition: none; transform: none; }
|
||||
.anomalous-tb-card.is-skeleton { animation: none; }
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
.anomalous-ai-status-dot.is-stopped,
|
||||
.anomalous-ai-status-dot.is-asking { background: var(--amb-warn); }
|
||||
.anomalous-ai-status-dot.is-error { background: var(--amb-danger); }
|
||||
.anomalous-ai-status-dot.is-cancelled { background: var(--amb-text-dim); }
|
||||
.anomalous-ai-status-btn.has-warning { border-color: color-mix(in srgb, var(--amb-warn) 60%, transparent); color: var(--amb-warn-text); }
|
||||
.anomalous-ai-status-popover .anomalous-ai-estimate { margin-top: 10px; }
|
||||
.anomalous-ai-status-dot.is-time { background: var(--amb-link); }
|
||||
.anomalous-ai-status-dot.is-batch { background: #a78bfa; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user