Compare commits
466
Commits
v1.58.3-beta
...
v1.62.2
+4
-4
@@ -1,5 +1,6 @@
|
||||
# Local configuration and credentials
|
||||
config.json
|
||||
allow_remote_changes
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -77,10 +78,9 @@ AI_SYSTEM_TEMPLATE.md
|
||||
# AI Agents Directory
|
||||
.agents/
|
||||
|
||||
# Test files
|
||||
test_*.js
|
||||
test*.mjs
|
||||
tests/
|
||||
# Scratch test files at the top level (the plugin's tests live in tests/, see docs/guides/testing.md)
|
||||
/test_*.js
|
||||
/test*.mjs
|
||||
|
||||
# Local diagnostic/test artifacts
|
||||
grep_doctor.txt
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
以下几条每次提交都要做到,没有“这次改动小”的例外;其余各节是怎么做好的指引。
|
||||
|
||||
1. **结构和文档同步。** 按下表更新文档,与代码放在同一次提交里。新增、删除、改名、拆分源码文件一律算改了模块职责:职责清单同时是文件目录,每个源码文件都要有一行。
|
||||
2. **跑结构检查。** `node tools/check_structure.mjs`,失败不提交。它检查每个源码文件都登记在架构文档里、每个前端模块都能从入口引用到、每个前端文件都能按浏览器的方式解析(`node --check` 对 .js 文件会漏掉浏览器报错的写法),并执行规模阈值(第 2 节):新文件不能一开始就超线,已超线的文件比上次提交只能变短或持平。
|
||||
2. **跑结构检查。** `node tools/check_structure.mjs`,失败不提交。它检查每个源码文件都登记在架构文档里、每个前端模块都能从入口引用到、每个前端文件都能按浏览器的方式解析(`node --check` 对 .js 文件会漏掉浏览器报错的写法)、CSS 里没有按 `style` 属性匹配的 `:has()`(会让拖动画布掉帧,见 [lessons.md](docs/architecture/lessons.md)),并执行规模阈值(第 2 节):新文件不能一开始就超线,已超线的文件比上次提交只能变短或持平。
|
||||
3. **不留旧实现。** 被取代的模块、样式、文案 key、文档说明同一次提交删掉(第 6 节)。
|
||||
4. **按风险验证**(第 7 节),只提交本任务的文件,没有明确同意不推送。收尾时工作区干净,或在交付说明里列出每个有意留下的未提交文件。
|
||||
5. **测试产物不进仓库。** 截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
|
||||
5. **测试产物不进仓库。** 测试代码放 `tests/`;截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略,只留本机),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
|
||||
6. **交付说明**写清:改了什么和为什么、验证结果和没验证到的地方、遗留问题、提交号;用了规范例外就写原因和影响。
|
||||
|
||||
| 改了…… | 更新 |
|
||||
@@ -20,8 +20,8 @@
|
||||
| 数据流、接口、持久化格式、安全边界、跨系统规则 | 对应的 `docs/architecture/` 专题;系统图或阅读地图变了才改 `ARCHITECTURE.md` 入口 |
|
||||
| 用户能感觉到的行为 | `CHANGELOG.md`;使用方法变了同步 `README.md` 或 `docs/guides/` |
|
||||
| 长期有效的产品或架构取舍 | `docs/decisions/` |
|
||||
| 反复出现的坑、严重故障 | `.agents/logs/ai_lessons.md` |
|
||||
| 开发规范本身 | 只改本文件;`CLAUDE.md`、`GEMINI.md` 只做阅读入口 |
|
||||
| 反复出现的坑、严重故障 | `docs/architecture/lessons.md` |
|
||||
| 开发规范本身 | 只改本文件 |
|
||||
|
||||
普通修复、样式微调不写进架构文档;没有变化的边界不写“本次未改变”。
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
1. 查看 `git status` 和相关差异,保留已有工作;不覆盖、回退或提交他人的无关修改。
|
||||
2. 阅读 [ARCHITECTURE.md](ARCHITECTURE.md),再按阅读地图打开与任务有关的专题,避免每次加载全部文档。
|
||||
3. 搜索目标函数的调用方、相关状态、CSS 选择器及已有辅助函数,再确定修改位置。
|
||||
3. 搜索目标函数的调用方、相关状态、CSS 选择器及已有辅助函数,再确定修改位置。界面上的按钮或文字,先用 `node tools/ui_map.mjs find <屏幕上的字>` 查它在哪个文件、样式在哪、调哪个接口、后端哪个函数;每块界面的用途和统一叫法见 [ui-flows.md](docs/architecture/ui-flows.md)。改了界面文字或流程,顺手更新那里(结构检查会指出对不上的地方)。
|
||||
4. 简短说明本次行为变化和负责它的模块。一个修复优先落在能解释问题的最小责任范围内。
|
||||
|
||||
UX 请求默认允许调整布局、交互和文案,不代表允许改变文件存储位置、接口协议、工作流语义、模型识别依据或用户数据。
|
||||
@@ -107,8 +107,8 @@ ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalo
|
||||
|
||||
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
|
||||
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
|
||||
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。
|
||||
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。
|
||||
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。模型文件本身可以是链接(云服务器上模型文件夹里全是指向平台模型盘的链接):文件夹部分照样用 `resolve_within`,文件用 `model_file_within` / `model_reference_within` 按名字取,不跟着链接走出去,封面、信息和改动都在链接旁边。
|
||||
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。唯一的例外是云服务器用户在服务器上的插件文件夹里放了 `allow_remote_changes` 文件(启动时读一次,网页建不了也删不掉):这时 `local_guard.may_change` 也放行别的设备;接口里要自己判断时用 `may_change`,只有“浏览器就在这台电脑上”才成立的事(比如在本机的下载文件夹里找原文件)才用 `is_local`。MCP 始终只接受本机。
|
||||
5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。
|
||||
6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
|
||||
7. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
|
||||
@@ -122,6 +122,13 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
|
||||
3. **数据跟着界面走。** 界面打开时才读,关掉就释放;跨次使用的缓存要小、有上限,写清楚何时失效。长列表只渲染看得到的或前几十条。
|
||||
4. **图片用缩略图。** 网格和列表用缓存的缩略图,原图只在看大图或拖到画布时读取;浏览器里不常驻大量原图。
|
||||
|
||||
### 功能原则
|
||||
|
||||
几个功能对用户的承诺。改这些功能时不能破坏;确需例外,先问维护者:
|
||||
|
||||
1. **模型检查(模型医生)从不自己改工作流。** 检查只读;只有用户按了“换上”或“下载”才动节点,而且只换成同一个文件(SHA-256 指纹一致)。只是同名、没法核对的候选,标明“按文件名”由用户决定。下载的来源(C 站按指纹、工作流带的链接、ComfyUI-Manager 模型清单)在界面上写明。完整的判断规则(三档可信度、基础组件只认指纹、下载来源与核对)见 `docs/guides/model-check-rules.md`,改模型检查前先读、改了规则同时改它。
|
||||
2. **搭配只记住,从不修改参数数值。** 存的时候是什么,放回画布就是什么:强度、CLIP 层数、提示词等一律原样,不用节点默认值顶替,也不替用户“调”。想要别的数值,在画布上调好再存一个新搭配。放上画布只新放一组节点,从不改画布上已有的节点。(理由见 `docs/decisions/README.md` AD-021;模型检查的见 AD-005、AD-006、AD-020。)
|
||||
|
||||
## 6. 清理旧实现
|
||||
|
||||
- 替换一条路径时搜索它的导入、导出、调用、字段、事件和选择器;确认无用后一起移除。
|
||||
@@ -137,11 +144,10 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
|
||||
| 修改范围 | 至少验证 |
|
||||
| --- | --- |
|
||||
| 导入、导出、模块拆分 | JS 语法、模块链接,以及受影响按钮的真实执行路径 |
|
||||
| 工坊状态与生命周期 | `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` |
|
||||
| 词卡读取、分页 | `node tests/prompt_material_source.mjs` |
|
||||
| 翻译桥接 | `node tests/translation_service_contracts.mjs`,异步节点写入另跑工坊生命周期测试 |
|
||||
| 素材应用、笔记隔离 | `node --experimental-vm-modules tests/material_workspace_flow.mjs` |
|
||||
| 素材提示词协议、配方筛选、拖拽清理 | `node --experimental-vm-modules tests/studio_contracts.mjs` |
|
||||
| 提示词工坊、词块 | `node tests/prompt_tags.mjs`、`node tests/prompt_boxes.mjs` |
|
||||
| 素材提示词、节点参数 | `node tests/material_prompt_cross_node.mjs`、`node tests/node_material_actions.mjs`、`node tests/node_parameter_sets.mjs` |
|
||||
| 搭配、往画布插节点、撤销 | `node tests/combo_slots.mjs`、`node tests/graph_splice.mjs`、`node tests/canvas_undo.mjs` |
|
||||
| 提交前全部跑一遍 | `node tools/run_tests.mjs`(说明见 `docs/guides/testing.md`) |
|
||||
| CSS、布局、交互 | 实际浏览器验证受影响状态;涉及宿主行为时验证 ComfyUI |
|
||||
| 文件修改、导入、存储 | 对应 Python 测试,至少覆盖成功、冲突和中途失败保留旧数据 |
|
||||
| 仅文档 | 检查链接、命令、实际代码一致性和 Git 差异 |
|
||||
@@ -150,7 +156,7 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
|
||||
- 修复故障时,优先补充能复现原故障的行为测试。不能只通过源码字符串“存在某段代码”来证明修复。
|
||||
- UX 明确替换旧交互时,更新测试到新的用户流程,并保留原本要保护的行为;不要仅删除失败断言或重新塞回废弃功能。
|
||||
- 新增应随仓库交付的回归测试要检查 `.gitignore`,确认确实进入版本控制。
|
||||
- 历史本地 Python 测试可能不在 Git 中。使用合适环境运行相关测试,例如 `python -m unittest discover -s tests -p 'test_*.py'`,核对实际发现的数量;零项测试不算通过。
|
||||
- Python 测试要在 ComfyUI 的 Python 环境里跑(`node tools/run_tests.mjs` 会自己找),核对实际发现的数量;零项测试不算通过。
|
||||
- 无法运行真实宿主、网络服务或完整测试时,准确说明缺口;不要将模拟画布、语法检查或已有通过数量称为本轮宿主验证。
|
||||
|
||||
## 8. 文档与交付
|
||||
|
||||
+700
-86
@@ -9,7 +9,6 @@ by default.
|
||||
|
||||
Before implementation, follow [AGENTS.md](AGENTS.md), the canonical development
|
||||
and maintenance rules; its section 0 is required for every commit.
|
||||
[GEMINI.md](GEMINI.md) and [CLAUDE.md](CLAUDE.md) are reading entry points only.
|
||||
|
||||
Saved prompts and saved node values are material files (`anomalous_materials`); there is no
|
||||
Material Library page any more. Prompt Studio lists the saved prompts (reading each body
|
||||
@@ -18,6 +17,7 @@ and Current node lists the saved values. See the material contract below.
|
||||
|
||||
| When changing... | Read... |
|
||||
| --- | --- |
|
||||
| A button, a page or words on screen: where it is drawn, what it calls, which backend function answers | `node tools/ui_map.mjs find <the words on screen>` (read from the code), then [`docs/architecture/ui-flows.md`](docs/architecture/ui-flows.md) (what each part is for, why, one name per thing) |
|
||||
| Python routes, storage, paths, metadata, covers, or scan state | [`docs/architecture/backend.md`](docs/architecture/backend.md) |
|
||||
| Browser lifecycle, UI state, localization, media, or graph edits | [`docs/architecture/frontend.md`](docs/architecture/frontend.md) |
|
||||
| Update-guide content/versioning or sidebar hover labels | [`docs/architecture/update-guide.md`](docs/architecture/update-guide.md) |
|
||||
@@ -25,7 +25,8 @@ and Current node lists the saved values. See the material contract below.
|
||||
| Saved prompts and node values (material files), image parameter details, keeping an output image | [`docs/architecture/material-library.md`](docs/architecture/material-library.md) |
|
||||
| Model Check, provenance hashes, missing-model recovery, or model scanning | [`docs/architecture/model-resolution.md`](docs/architecture/model-resolution.md) |
|
||||
| Why a current product boundary exists | [`docs/decisions/README.md`](docs/decisions/README.md) |
|
||||
| Recurring implementation mistakes and post-mortems | [`.agents/logs/ai_lessons.md`](.agents/logs/ai_lessons.md) |
|
||||
| The MCP tools AI apps get: adding one, its description or arguments | [`docs/architecture/tool-exam.md`](docs/architecture/tool-exam.md) (the tool budget, `tests/test_tool_budget.py`; the exam tasks and their scorecard, `node tools/tool_exam.mjs`) |
|
||||
| Recurring implementation mistakes and post-mortems | [`docs/architecture/lessons.md`](docs/architecture/lessons.md) |
|
||||
|
||||
`README.md` is user-facing documentation and `CHANGELOG.md` is user-facing
|
||||
release history. Neither is the source of truth for internal architecture.
|
||||
@@ -70,6 +71,34 @@ covered by the `styles.css` manifest.
|
||||
|
||||
### Backend
|
||||
|
||||
- `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/ai_run_map.py` reads a run's shape from the prompt ComfyUI queued (plumbing wired
|
||||
through; boxes with a role and facts, links with ComfyUI's types) and what changed since the
|
||||
task's run before; `ai_task_runs.py` keeps both on the run step, `show_run_map` puts it on the
|
||||
stage (a `run_map` block), `web/modules/ui_ai_run_map.js` words and draws it (Simple: told by
|
||||
`web/modules/ui_ai_run_story.js`, one part per sampler with the model and LoRAs feeding it, its
|
||||
prompts, where it starts and where it ends up; Full: top down, shrunk
|
||||
to its column). `api/ai_task_canvas.py` keeps each task's own canvas tab (default "new", set in
|
||||
the Gates window or per task): `mcp_bridge.ask_page` gives canvas actions the task's tab,
|
||||
`web/modules/mcp_task_tab.js` brings it to the front by clicking ComfyUI's tab (the store
|
||||
alone would not load its graph) or copies the open workflow into a new one before the first
|
||||
change; `web/modules/ui_ai_task_canvas.js` is the header's chip saying which ("AI edits a
|
||||
copy ▾", opening the two to pick from with what each means) and the copy's "Show the copy".
|
||||
`ai_pictures.mark_gone` marks a task's pictures no longer in the output folder (`gone`) on its
|
||||
`GET`, which `ui_ai_images.gonePicture` shows instead of a broken picture.
|
||||
`api/ai_pictures.py` hands the AI output pictures (gallery card menu, the inspector's ✦): a
|
||||
`user_pictures` step, delivered as a `pictures` message with the pictures attached and
|
||||
what made each (read with ai_run_map); `search_images(with_pictures)` attaches the first
|
||||
six. `tests/test_ai_run_map.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
|
||||
a saved model value inside the model folders;
|
||||
@@ -83,24 +112,390 @@ covered by the `styles.css` manifest.
|
||||
and no scan replaces or deletes it.
|
||||
- `api/model_catalog.py`, `api/model_resolution.py`, `api/model_metadata.py`, and
|
||||
`api/model_media.py` own model listing, identity recovery, mutation, and covers;
|
||||
`api/model_folder.py` shows a model file in Explorer / Finder (by type, folder index and
|
||||
file name, never a path; a POST, so only this computer may ask);
|
||||
`api/model_folder.py` shows a model file in Explorer / Finder, or without a file name opens
|
||||
the models folder (by type, folder index, subfolder and file name, never a path; a POST, so
|
||||
only this computer may ask);
|
||||
`api/models.py` is a compatibility facade; `api/model_constants.py` holds the
|
||||
model, media and sidecar extensions they share. `api/model_type_listing.py` lists a
|
||||
whole models folder with its subfolders for the models page's type chips.
|
||||
- `api/scanner.py` and `scraper.py` own scan orchestration and enrichment; both scan
|
||||
routes map request fields to scraper switches in one place (`_scraper_flags`).
|
||||
`civitai_client.py` is the scraper's network side (version by hash, model page, media),
|
||||
telling "Civitai does not know the file" from "Civitai could not be asked".
|
||||
telling "Civitai does not know the file" from "Civitai could not be asked" (and why); it
|
||||
asks Civitai's other address (civitai.red) when civitai.com does not answer (`api_hosts`,
|
||||
also for import, Model Check and the network test).
|
||||
`net_proxy.py` is the proxy every request of the plugin goes through (Settings → Network,
|
||||
`NETWORK_PROXY` in api/config.json; empty = urllib's own: HTTPS_PROXY or the Windows proxy),
|
||||
handed to the scan process as HTTP(S)_PROXY, and the names of why a request failed;
|
||||
`api/network_check.py` (`GET /anomalous/civitai_check`, this computer only) tests Civitai through it.
|
||||
`api/scan_report.py` turns the scraper's per-model report lines into one result per
|
||||
scan: kept as the last scan (`/anomalous/last_scan`), recorded in the activity log, each
|
||||
model located so the browser can open it (`/anomalous/scan_model`).
|
||||
`api/kept_images.py` says what each output image was kept as, for the gallery's star: the
|
||||
Workflow Recipe, combo or saved prompt that names it as its source image.
|
||||
`api/quick_index.py` (`GET /anomalous/quick_index`) lists every model file in a few words
|
||||
(type, place, shown name, base model, a few trigger words, cover URL) for the canvas's
|
||||
quick search, read once when the bar opens.
|
||||
`api/scan_summary.py` counts models matched on Civitai, unmatched and not yet scanned
|
||||
for the scan page and lists the last two, by the same sidecar rules the scraper uses
|
||||
(`sidecar_info`, `is_unmatched`, `unmatched_reason` in `model_identity.py`); it also
|
||||
lists the model files in other formats, which scans never read, so the page can say so.
|
||||
lists the model files in other formats, which scans never read, so the page can say so,
|
||||
the models set to "don't scan", and the new ones whose header names no drawing model kind.
|
||||
It keeps the last summary (memory and user/anomalous/scan_summary.json) for the page to
|
||||
show at once, shares one count among the requests meanwhile, and lists the models found on
|
||||
Civitai on request (`GET /anomalous/scan_matched`).
|
||||
`api/model_heads.py` keeps what each model file's header says (model_kind.inspect_file)
|
||||
in user/anomalous/model_heads.json by real path, size and time, so a header is read once per
|
||||
file version, also across restarts; get_metadata reads headers through it.
|
||||
Models in component folders (VAE, text encoders, CLIP, CLIP Vision, upscalers) are counted
|
||||
and listed apart as well (`components`, `component_models`): many are not on Civitai.
|
||||
It also lists every model a scan reads for the scan settings' picker
|
||||
(`/anomalous/scan_pickable`), with size, cover and the card badge's metadata fields.
|
||||
`api/scan_choice.py` saves that choice (`POST /anomalous/scan_choice`, this computer only)
|
||||
into each model's `<model>.anomalous.json`.
|
||||
- `api/mcp_server.py` serves `/anomalous/mcp`, the Model Context Protocol endpoint AI apps
|
||||
connect to (Streamable HTTP; the 2026-07-28 stateless protocol and the earlier
|
||||
`initialize`-based ones; this computer only, Host and Origin checked against DNS
|
||||
rebinding). `api/mcp_tools.py` holds the reading tools (summarised views over the same
|
||||
stores the pages use: models, scans, output images, combos, recipes, saved prompts,
|
||||
generated audio, activity log) and `call_tool`; `api/mcp_actions.py` the acting ones:
|
||||
canvas actions done by the open page, scans and Anomalous TTS speech through this
|
||||
server's own routes. `api/mcp_bridge.py` hands a canvas action to the page over
|
||||
ComfyUI's websocket and waits for its answer; `web/modules/mcp_bridge.js` is the page
|
||||
side, doing it with Anomalous's canvas code (one Ctrl+Z step each, logged as the AI's; the
|
||||
outcome carries the log entry's id as `_activity`, for the AI workbench's undo).
|
||||
`api/mcp_look.py` is zoom_image, the AI's closer look: part of a picture in the output, input
|
||||
or temp folder shown larger, the same part of a second picture beside it, read only (so an AI
|
||||
need not crop or compare pictures with code of its own).
|
||||
`api/mcp_model_use.py` offers search_models and get_model (mcp_tools.py finds the files)
|
||||
with what loads each model: the node types whose drop-down lists it (from `/object_info`),
|
||||
or, for a file whose header says it belongs in another folder (model_placement.py), where,
|
||||
and `move_model` (behind the "files" gate) moves it there as Tidy does.
|
||||
`api/model_advice.py` is what Anomalous knows about using a model well: get_model's
|
||||
`author_examples` (sampler, steps, CFG, size and a LoRA's weight from the example pictures in
|
||||
the Civitai record a scan keeps), and add_lora / set_model's `base_model_note` when a LoRA and
|
||||
the main model on its line (`graph_splice.js` loraChain / modelLineStart, reported by the page)
|
||||
are for different base models. `tests/test_model_advice.py`.
|
||||
`api/mcp_canvas_edit.py` holds the tools that build a workflow where the user watches:
|
||||
`find_nodes` reads this ComfyUI's node types from its own `/object_info` (kept two
|
||||
minutes); `add_node`, `connect_nodes`, `set_values`, `remove_nodes` and `new_workflow` (a
|
||||
new tab, blank or built from a workflow in ComfyUI's API form, never the open one) are
|
||||
checked for shape there and done by `web/modules/mcp_canvas_edit.js` through the bridge's
|
||||
`recorded`, after checking types, settings and choices against the nodes themselves;
|
||||
`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.
|
||||
`web/modules/canvas_wiring.js` plans unique concrete-type connections, selected-branch insertion,
|
||||
splices, the wires of a group added in API form (add_nodes; `canvas_build.placedBlock` lays it
|
||||
out as one block), conflict/cycle checks and rollback on application failure (`tests/canvas_wiring.mjs`).
|
||||
`web/modules/mcp_canvas_brief.js` is the canvas in brief for an AI (pure): bounded path chains and node lines,
|
||||
the inputs a run needs that nothing feeds yet with the outputs that fit them, ComfyUI's
|
||||
errors; every canvas change answers with what is still unwired. `api/mcp_situation.py` is
|
||||
get_situation: the task, that brief (describe_canvas with `brief`), the latest run and the
|
||||
next step in one answer kept under Antigravity's size for answers it files away, for an AI
|
||||
starting a new chat or lost (`tests/test_mcp_situation.py`, `tests/mcp_canvas_brief.mjs`).
|
||||
`api/mcp_directory.py` is the directory of the tools by what an AI wants to do (as this file is
|
||||
for the code): each tool under its stage (`action_catalog.TOOLS`) with its title, built from the
|
||||
registry, in begin_task's answer and in get_situation's while there is no task
|
||||
(`tests/test_mcp_directory.py`). AI apps are offered every tool (they load what they need
|
||||
themselves: Antigravity reads a tool's file before its first call); the module also keeps which
|
||||
list each app fetched last (`anomalous/ai_tool_lists.json`), so a call from an app still holding
|
||||
the list of an older Anomalous is told so and the task header says how to reconnect it.
|
||||
`api/mcp_fill.py` gives run_workflow its blanks, a daily run in one call: a saved workflow
|
||||
opened in the task's tab once and reused (the page marks the tab with the workflow's file),
|
||||
then the prompts, seed, size and the picture a LoadImage reads filled in by the page's
|
||||
`fill_slots`, planned by `web/modules/mcp_fill.js` (pure). Any other setting goes by its name
|
||||
(`values`, also set_values without a node): the one node that has it, else the AI names the
|
||||
node; the fixed blanks are roles (which prompt, which latent), not a list that grows. With them a run asks as the stricter
|
||||
of its gate and the canvas's (`action_catalog.ALSO_CANVAS`). `tests/test_mcp_fill.py`,
|
||||
`tests/mcp_fill.mjs`.
|
||||
`api/run_refusal.py` says ComfyUI's refusal of a run as what to do next: the page passes it on
|
||||
as data (`web/modules/mcp_run.js` refusal), each problem becomes a line naming the node, what
|
||||
is wrong and the tool that fixes it. `tests/test_run_refusal.py`, `tests/mcp_run.mjs`.
|
||||
`api/ai_toolbox.py` keeps what the user hands the AI to work with in a task ("给 AI 的素材",
|
||||
`web/modules/ui_ai_toolbox.js`, from the task header a side panel (on the AI page a view of
|
||||
its side column, `setToolboxHost`; elsewhere docked on the right) that leaves
|
||||
the page usable (kinds as tabs, one line per item (`rowCard`), 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 (and whole custom node packs by folder, `packs`: get_toolbox
|
||||
lists their node types, `chosen` counts them picked), 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
|
||||
request; page-only helpers go with their pack when their name says which); the rest in one
|
||||
answer from `api/ai_toolbox_catalog.py` (combos, workflows with covers and prompts, prompts
|
||||
saved or inside combos and workflows as `combo:<name>` / `workflow:<name>`, models with
|
||||
covers), read ahead when a task header is drawn and kept a minute.
|
||||
`web/modules/ui_ai_facet_menu.js` is the side panel's list of sources (or types) under its
|
||||
"Source: All ▾" button: a search over the names, ↑ ↓ and Enter, the row under the pointer lit
|
||||
and the one shown ticked.
|
||||
`web/modules/ui_ai_toolbox_cards.js` draws the cards (a node in miniature with its sockets in
|
||||
ComfyUI's colours, covers, prompt text) and the detail pane; `web/modules/ui_ai_toolbox_icons.js`
|
||||
each pack's badge, and the illustrations (`web/assets/toolbox_*.webp`, made
|
||||
with ChatGPT): the empty selection and the stand-in cover of a combo, workflow or model. Favourites (`anomalous/ai_toolbox.json`) stand in until the user picks for
|
||||
a task. A pick is a `user_toolbox` step the AI gets as a message (kind `toolbox`); begin_task
|
||||
names the picks, `get_toolbox` gives them in full, find_nodes lists them first.
|
||||
A rewired input shows in the receipt as a `wired` change
|
||||
(`activity_diff.js` keeps what feeds each input).
|
||||
`web/modules/mcp_run.js` captures exact prompt IDs from one native queue request,
|
||||
preserving native seed/widget callbacks and excluding later manual batches. Busy submissions
|
||||
and unsupported host request IDs are refused before queueing; wrappers/listeners are released.
|
||||
`api/mcp_prompts.py` serves `prompts/list` / `prompts/get`: ready-made requests (the AI app's
|
||||
slash commands) that tell the AI how to do a common job so the user can follow it on the AI
|
||||
workbench. `api/mcp_apps.py` serves the MCP Apps extension (`io.modelcontextprotocol/ui`):
|
||||
`api/mcp_app/task_card.html`, Anomalous's own card that apps supporting it show in the
|
||||
conversation under `begin_task`, `show_panel`, `report` and `run_workflow` results
|
||||
(`resources/list` / `resources/read`, `_meta.ui.resourceUri` on those tools). The card reads
|
||||
the task through `app_task_view` (steps, inline gallery thumbnails, open questions) and
|
||||
answers the AI's questions through `app_answer`, tools marked for the card only
|
||||
(`visibility: ["app"]`, never steps, never given the user's news). It never answers a gate:
|
||||
an OK is only given in Anomalous, so no tool an AI can reach grants one.
|
||||
User guide: `docs/guides/mcp.md`.
|
||||
- The AI workbench (1.62). `api/ai_tasks.py` keeps every MCP call as a step of a task (one
|
||||
file per task in the ComfyUI user folder, `anomalous/ai_tasks/`, the newest 300): a task is
|
||||
named by the AI (`begin_task`) or is one app's calls without a long pause (20 minutes); a
|
||||
step keeps the tool, a short summary, whether it worked and what it made (models, the combo,
|
||||
the canvas change by its activity entry, the spoken audio, the scan; a run's pictures are
|
||||
watched in ComfyUI's history until it ends, `api/ai_task_runs.py`, which also reads where it
|
||||
stands from ComfyUI's queue and progress registry, never changing them: queued and how many
|
||||
are ahead, or the node running, its steps, a percent like ComfyUI's tab title and the nodes
|
||||
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
|
||||
(that first answer also carries up to four thumbnails of its pictures: `show_pictures`),
|
||||
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
|
||||
task) and its gate (canvas, gpu, download, network: what the user may want to approve); a
|
||||
test keeps it and the MCP tools in step. `api/ai_task_changes.py` puts the user's own changes
|
||||
(activity entries, `activity_log.add_listener`) made while an AI works into that AI's task
|
||||
as `user_activity` steps, which the AI receives once with its next call (a message of kind
|
||||
`change`); `activity_log.tag` names the task on the entry, and the activity page links to
|
||||
it. `api/ai_gates.py` holds the user's gates: each gate is `auto`, `ask` or `never`
|
||||
(`anomalous/ai_gates.json`, `/anomalous/ai_gates`); `mcp_server.py` asks it before running a
|
||||
tool. It also keeps (`anomalous/ai_tools.json`) how long in all an AI waits for the user's answer (`answer_wait`: 1, 3 or 10 minutes): `api/ai_answer_wait.py`
|
||||
tells an AI whose question is still open to wait on with get_answers until then, and while the
|
||||
user types (the page posts `POST /anomalous/ai_tasks/{id}/typing`) until a minute after their last
|
||||
key; report and show_panel remind it to end each round with ask_user. With `ask` the step waits in state `asking` (what it would do listed) for up to 50 s;
|
||||
the user's Allow or No comes through `POST /anomalous/ai_tasks/{id}/gate`. Unanswered by
|
||||
then, the AI is told to wait and call again; the later answer reaches it as a message of kind
|
||||
`gate`, and the same call made again after an Allow goes ahead without asking twice.
|
||||
`api/ai_marks.py` keeps the marks the user draws on an output picture for the AI
|
||||
(`POST /anomalous/ai_tasks/{id}/marks`: the marked PNG, checked, kept in
|
||||
`anomalous/ai_marks/<task>/`, never the output folder, gone with the task; the marks as
|
||||
fractions with notes): a `user_marks` step the AI receives like a message (kind `marks`) with
|
||||
the marked picture attached as an image to the same answer. `web/modules/ui_ai_marker.js` is
|
||||
the marker window (box, circle, arrow, pen; three colours; a note per mark; a brush for the
|
||||
area to repaint) opened from a picture's ✎ on the AI page, and the timeline row of marks sent.
|
||||
The user also picks how to change it (`ai_marks.MODES`: leave it to the AI, only the marked
|
||||
areas, the whole picture anew), the first line the AI reads, as the user's choice, and the
|
||||
timeline row says it. For only the marked areas the page also sends a mask (white where boxes,
|
||||
circles, lines and brush strokes are); the server writes the picture with that area as its alpha to ComfyUI's
|
||||
input folder as `anomalous_inpaint_<step>.png` (a new file, never replacing one; kept when the
|
||||
task goes, as workflows may load it), and the AI's marks message carries its name with how to
|
||||
inpaint with it (LoadImage gives the MASK). The canvas tools read ComfyUI's lists anew once
|
||||
when a value is not among a drop-down's choices, so they take such a new file.
|
||||
`api/ai_repaint.py` lets the AI agree on that area with the user first (propose_repaint_area:
|
||||
boxes or ellipses, grown and softened by pixels): both areas tinted over the picture into the
|
||||
task's marks folder, side by side on a panel (block `area`, `ui_ai_panel.js`) with the AI's,
|
||||
the user's own or marking again; the picked area is written like the user's mask
|
||||
(`ai_marks.write_inpaint`, soft edges kept) before the answer is recorded, so the answer the AI
|
||||
reads carries `inpaint_image` and the inpaint note. Building the repaint stays the AI's.
|
||||
Options Anomalous makes name a page word (`label_key`). `tests/test_ai_repaint.py`.
|
||||
`web/modules/ui_ai_gate.js` draws the card under a step that asks and the Gates window;
|
||||
`ui_ai_flow.js` the flow over a task (its stages in order, each done, running, waiting or
|
||||
failed; a stage leads to its first step). `ui_ai_handoff.js` is "Hand to AI" on a model's
|
||||
page, an output image's inspector and a combo card: a task the user starts with `about`
|
||||
(the model's `model_id`, the image, the combo's name; checked in `ai_tasks.start_task`),
|
||||
which `begin_task` hands to the AI, and the sentence to paste into the AI app. It tells the page through the
|
||||
`anomalous.ai_task` event; `api/ai_tasks_routes.py` serves `/anomalous/ai_tasks` (list, one
|
||||
task, start, rename or remove, a panel's answer, a message). The user can start a task first
|
||||
(New task): it waits, and the next app that calls takes it over, so its work starts there
|
||||
instead of joining its last task (one waiting at a time, found again after a restart).
|
||||
`begin_task` resumes a waiting or active task, including preliminary reads, and keeps a user's title;
|
||||
`new_task=true` explicitly separates a new request. Ended or idle tasks are not resumed.
|
||||
`mcp_server.py` starts an in-flight `"running"` step before each call and updates the same step in place when it finishes or fails (the app's name from its client
|
||||
info or User-Agent); `api/mcp_task_tools.py` holds the AI's own tools for it, `begin_task`
|
||||
and `report` (a note, with pictures from the output folder it made another way).
|
||||
`web/modules/ui_ai_workbench.js` is the page (rail entry `ai`): the tasks, one task's
|
||||
results strip and timeline with links (picture viewer, model page, combo, canvas locate and
|
||||
undo, scan page, ▶ for audio), updated live; also the home page's "What the AI did lately"
|
||||
and the rail's dot (`owner.railNews`). A task's parts stay in the page between redraws: a
|
||||
step is drawn again only when it changed (`syncRows`), so a scrolled diagram or strip and
|
||||
half-typed text survive the AI's steps coming in. `ui_ai_message.js` is the message box, made
|
||||
once per task and docked at the foot of its column (in view while the timeline scrolls; a task
|
||||
opens at its newest step, and `ui_ai_newest.js` is the button back to it over the box): a growing input, the gallery and materials buttons beside it, and
|
||||
over it what the message carries (`ui_ai_tray.js`: pictures to mark up with
|
||||
`ui_ai_marker.js` or leave out, chips of what "@" names), and over that what the task needs or
|
||||
does now (`ui_ai_now.js`: a gate to allow, a run's progress with stop, the AI's open question
|
||||
to answer there; with none of that, `ui_ai_call.js`'s lines to paste into the AI's chat once it
|
||||
has stopped, which `ai_call_lines.js` picks by what waits for the AI: a run it was not told has
|
||||
ended, a reply it has not got, else go on, and a line for a new chat; `tests/ai_call_lines.mjs`). `ui_ai_drop.js` takes pictures dropped on the box or pasted, kept in the
|
||||
output folder's ai_inbox (`POST /anomalous/ai_pictures/upload`, `ai_pictures.save_upload`). `ui_ai_mention.js` holds those refs
|
||||
and gives it "@" (the task's pictures, the gallery's newest and those its search finds,
|
||||
combos, models, workflows, prompts, node packs and node types from the materials picker's
|
||||
data) and "/" (`ui_ai_commands.js`: stop, undo, side views, new task, canvas mode, words to
|
||||
fill in); `ui_ai_gallery_pick.js` is the gallery grid pictures are attached from.
|
||||
`api/ai_message_refs.py` checks what the message names and gives each to the AI
|
||||
with the message (in full, pictures attached; get_toolbox shares its `lookup`),
|
||||
`tests/test_ai_message_refs.py`. `ui_ai_step_body.js` is what a timeline step says under its
|
||||
title (the user's own steps with whether the AI has them, a decision's card, the AI's note, what
|
||||
happened outside Anomalous). `api/ai_outside.py` is that last: when the AI calls (never in the
|
||||
background) it looks at the top of the output and input folders, by the files' own times, for
|
||||
pictures no ComfyUI run saved and pictures rewritten in place since the AI's previous call
|
||||
(an `outside_files` step; the AI is told with its answer), and keeps `log_outside`, the AI's
|
||||
own word on a command or script it ran; both are steps of kind `outside`.
|
||||
`tests/test_ai_outside.py`.
|
||||
`api/ai_decisions.py` keeps a task's key decisions: `log_decision` (the AI records what was
|
||||
settled, why, what was left out, who decided, the steps it is about; a `decision` step), and
|
||||
the user's own choices read from the task's steps (panel answers, gates, materials, the canvas
|
||||
mode — `ai_task_canvas.set_mode` adds a `user_canvas` step — and stops); `GET` of a task adds
|
||||
`decisions` and `decisions_gap` (runs and canvas changes since the AI's last one), begin_task
|
||||
repeats them to an AI picking the task up. `ui_ai_decisions.js` draws the card and the side
|
||||
column's Decisions view. `tests/test_ai_decisions.py`. `api/ai_callers.py` names who calls (the
|
||||
app from its client info or User-Agent, a session, a task it names); an app whose task ended or
|
||||
paused goes on in the task the user wrote to and it has not read (`ai_tasks._take_replied`,
|
||||
which opens it again). `api/ai_task_links.py` keeps which task
|
||||
a task goes on from (`from`: set when an AI begins a new task while on another, or the user
|
||||
starts one while looking at a task; changed or taken off by the user, never a loop); `GET` of a
|
||||
task adds `links` {from, next}, begin_task gives an AI the task it continues and what was
|
||||
decided there (`continues`). `ui_ai_task_links.js` shows them under the task's title and picks
|
||||
one to link. `tests/test_ai_task_links.py`. `ui_ai_recent.js` the home page's list, `ui_ai_results.js` what a
|
||||
step made and where it leads, `ui_ai_shared.js` what the page's parts share. `ui_ai_outputs.js` is what the whole task made: a line over the
|
||||
timeline counting it, and the side column's Results view (`ai_task_view.taskOutputs`: the
|
||||
pictures by the step that brought them, the workflow, the combos, the newest picture's prompts,
|
||||
the models used and download requests — `ai_tasks.results_of` names the downloads — and the sounds),
|
||||
which keeps what made the newest picture as a recipe, a combo or a saved prompt through the
|
||||
keep menu's own `keepPictureAs`; its button counts what came since it was last seen
|
||||
(`stage.outputsSeen`). For a run the AI did not wait
|
||||
for (`ai_gates.run_wait` "call") the server marks the ended run `call_ai` until the AI is told,
|
||||
and the lines over the message box are about it meanwhile. `ui_ai_side.js` is the
|
||||
stage, the column on the right where the AI's panels are shown (the timeline keeps a card per
|
||||
panel, with what it holds from `ai_task_view.panelGist` and "Show on the right", which says
|
||||
"on the right now" while the panel is in view there, an IntersectionObserver of the stage): like the timeline it reads from the top down, the task's panels in
|
||||
the order they came with their step numbers, the newest at the bottom. Opening a task,
|
||||
new/updated latest panels and late image loads follow the bottom while the reader is there;
|
||||
reading earlier content pauses follow, with a "See latest" button to resume. Timeline links
|
||||
instead locate the requested panel's start. The stage's close button hides it for the selected
|
||||
task (`stage.hidden`); updates of a panel preserve that choice and its DOM/drafts, a new panel
|
||||
reopens it (`bringStage`) unless the user turned "Open on new panels" off (localStorage
|
||||
`anomalous_ai_stage_auto`; in a narrow window it lights the stage's tab instead). A timeline panel
|
||||
link reopens it; selecting a different task resets it. The stage is one view of the side
|
||||
column: `ui_ai_side_views.js` is the switch in the window title bar, AI page only (stage, a run's
|
||||
node map at full size with a list of the runs, the decisions, the materials picker, the
|
||||
results; the shown one again closes the column), one pane per view kept between switches
|
||||
(each under `ui_ai_side.sideHead`), and the picker's dock there.
|
||||
The task's header stays on top while the timeline scrolls. In a window too narrow
|
||||
for three columns the timeline and the side column are two tabs (a panel that asks brings the stage
|
||||
forward). The side column is a card whose left-edge grip sets its width (`--ai-side-w`, kept;
|
||||
the timeline keeps `TIMELINE_MIN`). The task list folds from the title bar's list button
|
||||
(`headerLeadTools`, `listToggle`); folded, the list stays laid out out of sight, and pointing at
|
||||
the button (or moving left onto the page's left edge) slides it over the page at once.
|
||||
`ui_ai_task_list.js` draws it (New task, Connect an AI
|
||||
and the gates, which AI came last, quick filters by state, the tasks by day with a state
|
||||
icon each), making anew only the rows that changed (`web/modules/sync_rows.js`, the timeline's
|
||||
way too). `web/modules/ai_task_cache.js` keeps the tasks as last read: a task picked shows at
|
||||
once from there (read ahead while the pointer rests on its row) and again, what changed, when the
|
||||
server answers; the activity entries a task lacks follow. The stage's pictures, like the
|
||||
timeline's, load the 512 px thumbnail first and the file itself only when shown larger. `ui_ai_icons.js` is the AI pages' one set of line icons and the icon button.
|
||||
`ai_text_format.js` lays out what the AI writes (its notes and replies: headings, lists, bold,
|
||||
code) with textContent only. A note may answer the user's message or marks (`report`'s
|
||||
`reply_to`): the two steps point at each other, and the timeline links them both ways.
|
||||
`ui_ai_connect.js` says how to connect an AI app (the address, a sentence to send the AI so
|
||||
it sets itself up and starts on a waiting task, each common app's way), in a window from the
|
||||
list's "Connect an AI", and as a card (`connectHint`) that opens the guide in place on the
|
||||
empty page and under a task waiting for an AI. `ui_ai_ready.js` is that waiting task's one
|
||||
page: the sentence to send the AI first, kept in view at the top while the page scrolls, then
|
||||
folds to open for what may be put ready (what to do, a line naming the task; the gallery grid
|
||||
of `ui_ai_gallery_pick.js` feeding the message box) and the user's own steps so far. The pictures (`web/assets/ai_*.webp`)
|
||||
were made with ChatGPT. `ai_task_view.js` holds
|
||||
its rules without DOM (folding reads and chains of the AI's work, tool words, counts, result addresses);
|
||||
`ui_ai_chain.js` draws the folded rows: reads in a row, and a chain of reads, canvas changes and
|
||||
decisions as one row of chips with undo for its canvas changes, opening to its steps.
|
||||
`ask_user` (`api/mcp_task_tools.py`) is the AI's question in the workbench: a one-block panel
|
||||
(choices with an optional `recommended` option and an `other` line, or an input) whose answer
|
||||
it waits for in the same call; the server instructions and `begin_task` tell the AI to ask
|
||||
this way rather than in its own chat.
|
||||
Panels the AI draws (`show_panel`, `update_panel`): `api/ai_panels.py` checks every block
|
||||
against a fixed set (heading, text, images from the output folder, models by id with their
|
||||
covers, table, list, params, choices, input, progress, divider; plain strings of bounded
|
||||
length, nothing run or read as markup) and keeps the panel as a step. `update_panel` preserves
|
||||
the order already seen: new blocks append; omitted blocks remain; unchanged resubmitted
|
||||
blocks match without their ids. Every new block gets a stable id, returned by the tools;
|
||||
an explicit existing id edits that block in place without changing its type or position.
|
||||
A uniquely labelled progress block also updates in place for clients that omit ids.
|
||||
The cumulative limit is 30 blocks; overflow or invalid input rejects the entire update
|
||||
before changing the panel, title or answers. Legacy records are not reordered on load;
|
||||
blocks without ids receive handles when updated. New phases should use `show_panel`.
|
||||
The user's answers come
|
||||
in through `POST /anomalous/ai_tasks/{id}/answer` and wait there until the AI reads them
|
||||
(`get_answers`, which can wait up to 55 s for one; Anomalous cannot call the AI). The user
|
||||
can also write to the AI on a task (`POST /anomalous/ai_tasks/{id}/message`, a `user` step);
|
||||
`get_answers` brings those messages too, and with no panel named waits for one; unread answers and user messages are also piggybacked once onto the next successful MCP tool response (`ai_panels.attach_pending`) so AI apps that do not poll `get_answers` still receive them immediately.
|
||||
`web/modules/ui_ai_panel.js` draws a panel in the timeline (textContent only) and the toast
|
||||
when the AI shows one while the user is elsewhere; a question can be skipped (`{skip: true}`,
|
||||
the AI reads `skipped`). `web/modules/ui_ai_delivery.js` is the pill under each answer and
|
||||
each thing the user gave the AI: sent and waiting, or the AI has it at `delivered_at` (set
|
||||
when `ai_panels._collect` hands it over). The stage blocks are data the page lays
|
||||
out, never drawings of the AI's own (no SVG is taken): marks on pictures (circles or boxes at
|
||||
fractions of the picture, a label that zooms there), the slider (before and after over each
|
||||
other), picks (pictures with a reason, "Take this one", "Put on the canvas" through ComfyUI's
|
||||
own file loading, drag onto the canvas) and the diagram (boxes and arrows; a box naming a
|
||||
canvas node finds it). `ui_ai_stage.js` draws them, `ai_stage_layout.js` holds where they go
|
||||
without DOM (a diagram's layers by its longest chain of arrows, boxes, arrow ends, marks).
|
||||
- `ui_ai_images.js` connects task outputs and panel pictures to the existing image inspector,
|
||||
keep menu and `ui_param_compare.js`: tools over each output picture (shown on hover), and a
|
||||
tick; two ticked pictures, from any step or task, are compared from the bar at the foot of
|
||||
the AI page (a third tick replaces the oldest). Videos and temporary files get neither.
|
||||
Thumbnails load initially, metadata only after an explicit action. `ui_canvas_changes.js`
|
||||
renders the activity record's bounded before/after receipt for both the AI timeline and
|
||||
`ui_activity.js`, with a caller-owned locate action. Missing records show unavailable;
|
||||
receipts never reconstruct history from the current graph. The workbench retains only
|
||||
its selected task's activity entries and ignores superseded refresh/navigation responses.
|
||||
- `api/backup.py` is Settings → Backup (`/anomalous/backup/*`): one .zip of the user's stores
|
||||
(recipes, combos, materials, parameters, optionally ComfyUI's saved workflows), the plugin
|
||||
settings without the Civitai key, and each model's user layer and own cover; putting it
|
||||
back adds what is missing, replaces differing files only on request (Recycle Bin first)
|
||||
and finds models again by SHA-256, else by folder, path and size. Its model list (every
|
||||
model's folder, path, size, SHA-256, link) names the models a new computer lacks.
|
||||
`ui_backup.js` is its export and import dialogs; `ui_backup_downloads.js` downloads the
|
||||
lacking models again through Model Check's downloads, into the path they had.
|
||||
- `api/lora_info.py` reads a LoRA's safetensors header to tell whether it trains the text
|
||||
encoder (`/anomalous/lora_info`), so inserting it wires the CLIP line only then.
|
||||
- `api/model_download.py` downloads the models a workflow misses (Model Check's
|
||||
"Download", `/anomalous/download/*`): one at a time into `<file>.part`, continued after
|
||||
an interruption, checked against the SHA-256 before it gets its name, never over an
|
||||
existing file; also where downloads go by default (`download_settings.json`).
|
||||
`api/download_sources.py` finds the one exact file: Civitai by hash, a Civitai version
|
||||
link, or a Hugging Face / GitHub file link the workflow carries, else a file of the same
|
||||
name in ComfyUI-Manager's model list (`api/manager_catalog.py`), checked by the hash.
|
||||
`api/hf_card.py` gives a model downloaded from Hugging Face its model card's example image
|
||||
as cover and its trigger words and example prompts as notes (never over the user's).
|
||||
- `api/model_import.py` puts model files the user drops on the models page into the right
|
||||
models folder (`/anomalous/import/*`): finds the dropped file in this computer's Downloads
|
||||
or Desktop and moves it (or uploads a copy), tells what it is, checks it against Civitai
|
||||
and the scanned models, never replaces a file (" (2)").
|
||||
- `api/model_placement.py` is the models page's Tidy check (`/anomalous/placement/*`): models
|
||||
whose header names another type than their folder, identical files (by SHA-256), and moving
|
||||
a model with its sidecars to where it belongs; `belongs()` checks one file for the AI, `look()`
|
||||
says what one file's header suggests even when unsure. `web/modules/ui_model_retype.js` is
|
||||
"Change type" for one model (its page's actions, the toast after an import): the types with
|
||||
their loaders, the header's guess marked, where it goes, moved as Tidy moves.
|
||||
- `api/model_usage.py` is Tidy's "not used lately" (`GET /anomalous/usage/check`, this computer
|
||||
only, on a press): when each model and node pack was last used, from the run record, the
|
||||
PNG outputs' prompts (image_search's cached records, which keep the node types too), FLAC /
|
||||
Ogg sounds' prompts and the saved workflows, recipes, combos and parameter sets; nothing that
|
||||
came in the last 30 days is judged. Node packs come from ComfyUI's loaded packs, with whether
|
||||
they add to the page and which other packs take what only they make. Read with limits,
|
||||
deletes nothing. `POST /anomalous/usage/keep` (this computer only) keeps the packs the user
|
||||
says they still use, which no record can show, in `user/anomalous/usage_kept.json`.
|
||||
- `api/usage_ledger.py` is the run record behind it: ComfyUI's on_prompt hook (registered in
|
||||
the plugin's `__init__.py`) notes each queued run's node types and model files with the time,
|
||||
and `POST /anomalous/usage/opened` (this computer only, capped lists) what an opened workflow
|
||||
holds, apart from the runs, in `user/anomalous/usage_ledger.json`, saved a few seconds later;
|
||||
it never stops a run.
|
||||
- `api/workflow_schema.py`, `api/recipe_schema.py`, `api/recipe_images.py`, and
|
||||
`api/recipe_store.py` own recipe validation/shaping, images, CRUD, history, and
|
||||
integrity receipts; `api/recipes.py` is the HTTP facade. `api/recipe_constants.py`
|
||||
@@ -114,13 +509,25 @@ covered by the `styles.css` manifest.
|
||||
`api/node_material.py` saves one canvas node's current values as a
|
||||
`node_parameter_selection` material (the current-node panel's "save").
|
||||
- `api/local_guard.py` decides which requests only this computer may make (every change under
|
||||
`/anomalous/`; other devices may look), as one middleware; see backend.md.
|
||||
`/anomalous/`; other devices may look), as one middleware, and lets other devices change
|
||||
things too when the server has an `allow_remote_changes` file (cloud servers); see backend.md.
|
||||
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
|
||||
`api/folder_types.py` own the formerly mixed utility route families.
|
||||
- `model_policies.py` owns shared backend rename and protected-category policy.
|
||||
- `model_sidecars.py` says where a model's side files are, shared with the scanner: beside it,
|
||||
or the plugin's own ones (`.info`, `.anomalous.json`, `.civitai_bak.*`) in the folder's hidden
|
||||
`.amb` (`SIDECAR_LAYOUT` in api/config.json); reading looks in both places. Covers stay beside
|
||||
the model. It also tells Civitai's cover from the user's (the SHA-256 a scan notes in the
|
||||
`.info`, or a copy kept aside, kept only while the user's own cover shows).
|
||||
`api/sidecar_layout.py` is Settings → Models → "Files that come with models"
|
||||
(`/anomalous/sidecars/plan|apply`, apply this computer only): saves the place, moves the files
|
||||
there and drops Civitai pictures kept twice to the Recycle Bin.
|
||||
- `model_identity.py` owns file SHA-256 evidence and the base model a safetensors header
|
||||
tells, shared with the standalone scanner (which runs as its own process, so the API
|
||||
never imports `scraper.py`).
|
||||
never imports `scraper.py`). `model_kind.py` reads a .safetensors / .gguf header (never
|
||||
formats that can carry code) and says which models folder the file belongs in and its base
|
||||
model (model patches such as Anima LLLite go to `model_patches`, not `controlnet`); model
|
||||
import and the scanner's offline base model use it.
|
||||
- `recycle_bin.py` is the only way the plugin deletes the user's files (models, covers,
|
||||
outputs, recipes, materials, notes, audio): to the system Recycle Bin, together, or not
|
||||
at all (`TrashUnavailable` on drives without one). `api/trash.py` is the API's import of it
|
||||
@@ -150,6 +557,34 @@ covered by the `styles.css` manifest.
|
||||
switch refuses to run over modified tracked files. `ui_version_manager.js` is
|
||||
the version line (shown in the update guide opened by the header "!" button) and panel; nothing goes online until "check for updates"
|
||||
is clicked, and restarts go through ComfyUI Manager's reboot route when present.
|
||||
Every function takes an optional `Checkout` (another folder and its own state
|
||||
file): `api/tts_version.py` uses it for the companion node pack Anomalous_TTS,
|
||||
found among the custom_nodes folders by its node class and accepted only as a
|
||||
git clone whose `origin` is the official repository (`/anomalous/tts_version`,
|
||||
`/releases` local-only, `/update`, `/undo`; last switch in the ignored
|
||||
`.anomalous_tts_version.local.json`). `ui_tts_version.js` is its group in the
|
||||
GPT-SoVITS settings dialog: installed version, "Check for updates", update to
|
||||
the newest stable release or back, then the restart question.
|
||||
- Generation presets (Anomalous_TTS interface 16): `tts_presets_api.js` holds the
|
||||
calls (`/anomalous_tts/presets`) and pure helpers (the language pairs the
|
||||
characters can meet, the presets file written back with only what differs from
|
||||
the built-in values plus the presets made here (`custom_<id>` with a label),
|
||||
where a preset is used and taking it out, a dialog row's numbers in effect
|
||||
next to the general ones, a character's `defaults` / `defaults.by_language` as
|
||||
rows and back, keeping every other field, where a mode sits between steady and
|
||||
lively, a line to listen with in each language). `ui_tts_preset_switch.js`
|
||||
draws the tones (the presets) as choices with the caller's listen row, and when
|
||||
asked for (Settings' Numbers switch, Voice-over's Numbers tab), the picked one
|
||||
against one fixed tone (the default one, or a character's usual): a sentence, a
|
||||
steady-to-lively scale and the four numbers as the Current node card's bars, each
|
||||
with its plain name and the node's own name (`top_k` …), editable in place. `ui_tts_mode_preview.js` makes
|
||||
and keeps the takes for listening (one line, one seed, only the mode changing,
|
||||
through Preview Audio). `ui_tts_presets.js` is the group in the GPT-SoVITS
|
||||
settings dialog (that switch with a character and a line to listen with, make
|
||||
default / reset / new / rename / delete; the rules per pair as a second part), saving each change at once; `ui_tts_tuning.js` is the
|
||||
per-character dialog behind the card's Own settings button, shown only
|
||||
with interface 16. `ui_tab_strip.js` is the row of tabs over one part at a time, shared by
|
||||
that dialog and Voice-over's side panel (the tab left open is remembered).
|
||||
|
||||
- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared
|
||||
browser class and extracted-method wiring; `browser_entry.js` owns the single
|
||||
@@ -185,9 +620,20 @@ covered by the `styles.css` manifest.
|
||||
requests; `invalidateEngineCache()` drops them after a save or an added voice,
|
||||
and `invalidateEngineCache({ rescan: true })` (Refresh button) also makes the node
|
||||
re-read its folders (`?refresh=1`).
|
||||
`ui_audio_tts_editor.js` edits a character's emotion references through that API:
|
||||
it opens at once, loads the character's audio list in the background (save waits
|
||||
for it), and keeps settings fields it does not know.
|
||||
`ui_audio_tts_editor.js` is the reference audio editor (a character card's "Reference audio
|
||||
and emotions"): a large window with the voices on top (main voice, each emotion, new
|
||||
emotion) and every clip of the character below, one row each (listen, file and length, its
|
||||
line editable in place, where else it is used, Main / Aux for the voice picked), with filters,
|
||||
a search and the first 60 rows then more. It opens at once and loads the character's clips,
|
||||
their lines and lengths (`/anomalous_tts/reference_lines`) in the background (save waits for
|
||||
them), and keeps settings fields it does not know; 🗑 on a row moves that clip to the
|
||||
Recycle Bin through the node (`removeGptSovitsClips`, which also takes it out of the saved
|
||||
settings) and out of the window's voices. `tts_voice_refs.js` holds its rules without
|
||||
DOM: the voices read from the settings and the node's view (an emotion found from a file
|
||||
name is written only once changed), one main reference and any number of auxiliary ones per
|
||||
voice, the lines fixed here (`lines`; a voice's own `text` moves there), what keeps it from
|
||||
saving, the settings written back, and the order of the list. Auxiliary references and
|
||||
fixed lines need Anomalous_TTS interface 18 (auxiliary ones a v1 / v2 / v2Pro model).
|
||||
`tts_setup_api.js` holds the other calls (pretrained downloads, chunked
|
||||
upload, inspect, commit, discard) and the pure import-form rules (`importKind`,
|
||||
`pickWeights`, `nameConflict`, `textFromFile`, `buildImportBody`, `importProblem`,
|
||||
@@ -196,9 +642,12 @@ covered by the `styles.css` manifest.
|
||||
`ui_tts_setup.js` is the GPT-SoVITS settings dialog, opened from the sidebar
|
||||
footer (`setupAttention` gives that entry its dot). Missing pretrained files only get a dismissable dot
|
||||
when the studio's characters need them (dismissed ids in `localStorage`; a newly
|
||||
needed file brings it back); missing packages get a red one. Inside: the storage
|
||||
place and other places with characters (shown, not changed), pretrained files
|
||||
and package sources, packages, and the settings file where folders are changed.
|
||||
needed file brings it back); missing packages get a red one. Inside: a status chip
|
||||
by the title (ready, files to download, packages missing), then tabs: the tones, the
|
||||
tones for speaking another language, the base files (pretrained files and package
|
||||
sources, packages; a dot when they need the user, and the dialog opens there), where
|
||||
things are (the storage place and other places with characters, shown, not changed,
|
||||
and the settings file where folders are changed) and the version.
|
||||
It polls the status only while a download runs and it is open.
|
||||
`ui_tts_import.js` is the import window. It first asks
|
||||
what the user has (`mode`: `single` = one card led by its checklist, `batch` = a
|
||||
@@ -235,24 +684,27 @@ covered by the `styles.css` manifest.
|
||||
"show all", left-out files folded, line files, language) and the unassigned card
|
||||
(one folded line per folder when large), and builds each row's elements once, so
|
||||
they survive redraws and moves; `ui_tts_import_screens.js` holds the fixed
|
||||
screens (the first question, the batch drop area, the "add more" menu) and the
|
||||
tour steps. `ui_tts_file_drop.js` reads OS drops (walking dropped folders, only
|
||||
files an import can use, up to 5000) for the studio, the sidebar and the
|
||||
workbench.
|
||||
screens (the window's frame, the toolbar over the cards, the first question, the
|
||||
batch drop area, the "add more" menu) and the tour steps. `ui_tts_file_drop.js`
|
||||
reads OS drops (walking dropped folders, only files an import can use, up to 5000)
|
||||
for the studio, the sidebar and the workbench, and binds the import window's own drop.
|
||||
- `audio_node_targets.js` is the single table of canvas nodes the audio studio
|
||||
writes into (the character widget and the script widget of
|
||||
`AnomalousTTS_CharacterSpeech`).
|
||||
`planVoiceDrop` decides every character drop and names the reason for each refusal;
|
||||
unlisted nodes are refused, never matched by widget name. Supporting a node
|
||||
means adding one entry plus a test.
|
||||
- The rail's Voices entry has two views, switched by `ui_voice_tabs.js` at the top of
|
||||
each and sharing the character list: Characters (shell page `voices`) and Voice-over
|
||||
(shell page `script`; `ui_shell_nav.js` keeps the rail on Voices and reopens the view
|
||||
used last).
|
||||
- The rail's Voices entry has three views, switched by `ui_voice_tabs.js` at the top of
|
||||
each and sharing the list column: Characters (shell page `voices`), Voice-over (shell
|
||||
page `script`) and Audio (shell page `audio-gallery`, `ui_audio_gallery.js`).
|
||||
`ui_shell_nav.js` keeps the rail on Voices and reopens the view used last, from the
|
||||
rail and from the 9 key; the rail stays within the nine number keys.
|
||||
`ui_audio_studio.js` owns the Characters view: the character cards, preview playback,
|
||||
tag copying, dragging a card header onto a node through the shared `bindMaterialDrag`
|
||||
with `targetHint`/`rejectHint` telling the user what releasing does, and each card's
|
||||
"Voice-over" button (`owner.openScript(group)`).
|
||||
with `targetHint`/`rejectHint` telling the user what releasing does, each card's
|
||||
"Voice-over" button (`owner.openScript(group)`), and its Delete character button
|
||||
(node interface 19, local only): after a confirm, `removeGptSovitsCharacter` has the node
|
||||
move the character's folder to the Recycle Bin.
|
||||
`ui_script_page.js` owns the Voice-over view: it loads the characters and mounts the
|
||||
script director, or shows the install steps or the way to import one when there is
|
||||
nothing to voice yet. A character picked in the list speaks there; the audio
|
||||
@@ -262,20 +714,49 @@ covered by the `styles.css` manifest.
|
||||
(`buildScriptPackage`, with `[take:N]` for retakes), combo value
|
||||
matching, and `buildTtsPrompt` (a GPT-SoVITS script as a ComfyUI API prompt
|
||||
saving to `output/audio/<character>/`).
|
||||
`ui_script_director.js` owns the script director, the Voice-over view's body: one
|
||||
character, an emotion chip row per line card (with the chosen emotion's reference clip;
|
||||
none when the character has only its main voice), editing buttons shown on hover, and a
|
||||
bundle that is generated in the view, or, under "Into your own workflow", pushed to the selected/only target node,
|
||||
dragged onto one (writing the character and script widgets together, one Ctrl+Z
|
||||
step) or copied. The page feeds it the voice groups after each fetch.
|
||||
`ui_script_director.js` owns the script director, the Voice-over view's body: the lines,
|
||||
each read by its character (the one picked in the list on the left at first; the node's
|
||||
own character is the first line's), and a bundle (`[name]` where the speaker changes, a
|
||||
line's own `[lang:…][speed:…][preset:…]`) that is generated in the view, or, under "Into your own workflow"
|
||||
(`ui_script_workflow.js`), pushed to the selected/only target node, dragged onto one
|
||||
(writing the character and script widgets together, one Ctrl+Z step) or copied. The page
|
||||
feeds it the voice groups after each fetch. `ui_script_cards.js` draws the line cards: a
|
||||
click picks (`ui_script_cast.js`: Ctrl / Shift, the characters' colours), a double-click or
|
||||
✎ edits the text in place, the name opens a menu of characters, emotion chips per the
|
||||
line's character, a tag when the line is set for itself, buttons on hover.
|
||||
The left side only picks; the side panel follows it (`ui_script_tune.js`) in three
|
||||
layers for this script, under a path (Whole script › character › line): nothing picked,
|
||||
the whole script (and each character in it); a character clicked in the list
|
||||
(`castScriptCharacter`), that character in this script; picked lines, who reads them and
|
||||
how. Each layer has the same rows (speed, tone, language) with where each value comes
|
||||
from; lasting settings are links at the foot. `script_tune.js` holds the rules without
|
||||
DOM: the layers (line over character over whole script over the character's usual
|
||||
values), each holding only the fields set on it, and the tags Anomalous_TTS interface 17
|
||||
reads (only where a line differs from what the node gives by itself; the whole script's
|
||||
values go into the node's inputs). Every script is a draft in the drafts box
|
||||
(`ui_script_drafts.js`): one file in ComfyUI's user folder
|
||||
(`user/<user>/anomalous_model_browser/voice_over_drafts.json`, through ComfyUI's
|
||||
`/userdata` API), saved half a second after a change, read again and joined before each
|
||||
write so two tabs do not undo each other; a bar over the script names the open draft
|
||||
and leads to New, the box (cards: open, rename, copy, delete) and Close; deleted drafts
|
||||
stay in Recently deleted for 30 days. `script_drafts.js` holds its rules without DOM or
|
||||
storage (the shapes, the merge, titles, when a draft changed). A reload opens the draft
|
||||
that was open; the one draft the earlier version kept in `localStorage` moves in once.
|
||||
It puts the cards on one side and generating on the other: beside
|
||||
them once the page is wide, below them when it is narrow (docked browser).
|
||||
them once the page is wide, below them when it is narrow (docked browser); on a
|
||||
wide page `ui_split_bar.js` is the divider between them (dragged, remembered).
|
||||
A card is one row (number, text, buttons); "Into your own workflow" is folded.
|
||||
`ui_script_run.js` is the director's "Generate" section (the bar with the result and
|
||||
a way to the audio gallery; language and speed in view, the sampling numbers folded
|
||||
under "fine-tuning" with plain names): it runs
|
||||
a way to the audio gallery; each character's values in this script, starting from its
|
||||
saved defaults and Auto, which it draws through `ui_script_tune.js`; the modes in the
|
||||
same switch measured against Auto (compact: scale and numbers folded) and heard on a
|
||||
line from the panel or the ▶ on its card (the takes heard stay on the side to compare),
|
||||
a preset's numbers changed there saved into the preset; links to the presets and to the
|
||||
character's generation settings; a node older than interface 17 reads the whole script
|
||||
as the first line's character reads, through its inputs): it runs
|
||||
the script without the canvas, plays the result, retakes the whole script
|
||||
(new seed) or one line (`[take:N]`, the node caches the rest), and saves a
|
||||
character's language, speed and folded sampling parameters to its `defaults`
|
||||
character's language and speed to its `defaults` (keeping the rest of them)
|
||||
(only values that differ from the node's own defaults, which it reads from
|
||||
`/object_info`; needs Anomalous_TTS interface 11).
|
||||
`ui_tts_pronunciation.js` edits a GPT-SoVITS character's pronunciation table
|
||||
@@ -287,7 +768,9 @@ covered by the `styles.css` manifest.
|
||||
targeted `/interrupt`); it never touches the canvas.
|
||||
- In the audio domain the header **!** opens `AUDIO_USAGE_GUIDE` (a how-to, see
|
||||
`docs/guides/audio-studio.md`) instead of the visual update guide.
|
||||
- `ui_audio_sidebar.js` owns the audio navigation and the active audio filter:
|
||||
- `ui_audio_sidebar.js` owns the audio navigation and the active audio filter
|
||||
(on Voice-over it is the cast: narrower, characters with their colours only, a click
|
||||
gives the picked lines to one and never leaves the page):
|
||||
characters grouped by language, groups folded
|
||||
in `localStorage`, a character unfolds into its clips (a click plays the clip
|
||||
through its studio row, switching the studio to that character when needed), a
|
||||
@@ -299,14 +782,16 @@ covered by the `styles.css` manifest.
|
||||
recorded in each file, plus a section for unsaved temp previews that can be
|
||||
copied into `output/audio/`.
|
||||
- `ui_sidebar.js` assembles the browser window (rail, list column, header, page panels;
|
||||
`remote_view.js` adds the header's "view only" chip when opened from another device)
|
||||
and renders the model folder list. `ui_shell_rail.js` owns the left icon rail: one
|
||||
`remote_view.js` adds the header's "view only" chip when opened from another device;
|
||||
`headerTools` is a page's own slot in the header, shown on that page: the AI page's side switch)
|
||||
and puts the model folder column in the list column (`ui_folder_list.js`: the folders of the
|
||||
type the chips show, counts with subfolders, 📂 to open one in Explorer). `ui_shell_rail.js` owns the left icon rail: one
|
||||
entry per page, the tool slots and the settings slot. `ui_shell_nav.js` owns page
|
||||
navigation (`goTo`): the domain each page needs, which pages have a list and whether
|
||||
it is open (remembered per page; closed and overlaying below 760 px), the header's
|
||||
page title, and the page reopened next time. `ui_home.js` owns the home page (task
|
||||
cards, first steps). `ui_shell_frame.js` owns dragging, resizing and keeping the
|
||||
floating window on screen; `ui_scan_watch.js` polls scan status for the rail's scan
|
||||
floating window on screen, and the full-window button (double-click on the header too); `ui_scan_watch.js` polls scan status for the rail's scan
|
||||
button, the progress panel and the model reload afterwards.
|
||||
The activity log (docs/decisions AD-018): `api/activity_log.py` keeps the entries
|
||||
(newest first, bounded, in the ComfyUI user folder), records this plugin's and
|
||||
@@ -320,28 +805,58 @@ covered by the `styles.css` manifest.
|
||||
`canvas_undo.js` keeps what each canvas entry did, in full, while the page stays open,
|
||||
and undoes an entry from the log while nothing has changed it since (entries that
|
||||
removed nodes or rewired existing ones are never offered).
|
||||
- `canvas_menu.js` is AMB's part of the canvas's right-click menus (the extension's `getNodeMenuItems`
|
||||
and `getCanvasMenuItems`, browser_entry.js): for a node, Open in AMB (`showForNode`: the page
|
||||
opening with it selected shows; its saved values on Current node), change its model by picture
|
||||
(`_openGalleryReplacer`), insert a LoRA before / after, the model's pictures and details
|
||||
(`/anomalous/find_model`, `openModelDetail`), save its values or the picked nodes as a combo, each
|
||||
only where it applies; for the empty canvas, AMB's pages. `canvas_menu_settings.js` keeps which
|
||||
entries are on, their order and whether they sit in one "AMB" submenu or right in the menu
|
||||
(localStorage, no DOM); `ui_canvas_menu_settings.js` is its Settings -> Canvas group (two placement cards drawing a small menu each, a preview line per list).
|
||||
`ui_quick_search.js` is the canvas's quick search (Alt+B, the command
|
||||
`Anomalous.ModelBrowser.QuickSearch`, or the empty canvas's menu): a bar that finds combos,
|
||||
models and saved prompts as you type and puts the chosen one where the pointer was (a combo
|
||||
through `sendNotebookToCanvas`, a model through `applyModelToCanvas`, both following the
|
||||
pointer; a prompt as a new prompt node, `prompt_card_drag.js` `addPromptNode`); it takes every
|
||||
key while open. `quick_search_data.js` reads its lists (kept a minute) and matches without
|
||||
DOM; `MODEL_LOADERS` there is also the loader node `applyModelToCanvas` uses for each type.
|
||||
`ui_settings_hub.js` owns the display preferences (view mode, scale, atmosphere, window
|
||||
layout) and their application, the gear and language redraws; `ui_settings_page.js`
|
||||
is the settings page the gear opens (a tool page with Back): look and language, model
|
||||
cards and memory with the card image cache, folders, workflow fingerprints, opening
|
||||
mode and window, help; `ui_feedback_dialog.js` is the feedback window (Home and the
|
||||
is the settings page the gear opens (a tool page with Back), in tabs: Look (look and
|
||||
language, model cards and memory with the card image cache, opening mode and window),
|
||||
Models (folders, where downloads and imports go, where the side files go through
|
||||
`sidecar_layout.js`), Workflows (fingerprints), Node packs
|
||||
(those not used lately, `ui_pack_usage.js`), Canvas (the right-click menus, `ui_canvas_menu_settings.js`), Prompts
|
||||
(translation), Backup (`ui_backup.js`), Help; `ui_feedback_dialog.js` is the feedback window (Home and the
|
||||
settings page open it): one text box, then `feedback.js` opens a GitHub issue with it in
|
||||
the browser's language, the environment folded at the end when attached (versions and
|
||||
hardware only, never a path or ComfyUI's command line), or copies the environment;
|
||||
hardware only, never a path or ComfyUI's command line), or copies the environment, and
|
||||
`openRepoPage()` opens the project page for a Star (on a press only; nothing is fetched);
|
||||
`ui_rail_tools.js` owns the rail's tool buttons (scan, doctor, current node);
|
||||
`ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return,
|
||||
including Esc on the workspace panel (`nbPanel`, below the header and right of the
|
||||
rail; the list column steps aside while it is open).
|
||||
`ui_scan_page.js` is the scan page (a tool page like the doctor): counts, the scan
|
||||
button, a card for how the next scan goes (opening the scan settings page), the last
|
||||
scan's summary; each count and the summary open a
|
||||
`ui_scan_page.js` is the scan page (a tool page like the doctor), quieter the further
|
||||
down: the scan card (a band of covers with an empty frame per model not scanned yet, what is left, the scan button with "pick
|
||||
models to scan" beside it, online or offline, what the scan will do in one sentence), the
|
||||
models' state as a bar and chips (the components on a chip of their own), what needs a look,
|
||||
the settings (`ui_scan_settings.js`: the scan options as tiles, the two that change files
|
||||
folded under "more"), the last scan in one line; icons from `scan_icons.js`. Each state chip,
|
||||
the components and the last scan open a
|
||||
list page from `ui_scan_lists.js` (not scanned, unmatched, other formats, the last scan's
|
||||
models) with Back, a button for the whole list (scan these / look them up again) and rows
|
||||
that open a model, whose Back returns to the list, or scan it;
|
||||
`scan_results.js` holds the words for scan results, shared with the activity page.
|
||||
`scan_results.js` holds the words for scan results, shared with the activity page;
|
||||
`net_failure.js` the words for why Civitai could not be reached (net_proxy.py's codes), for
|
||||
the scan results and Settings → Network's connection test.
|
||||
`ui_scan_picker.js` is the scan page's model picker: every model a scan reads, by folder,
|
||||
state (not scanned, not found, damaged, found, don't scan) and search, as a list or covers;
|
||||
the page keeps its picks as items (none: the button scans everything).
|
||||
`scan_runner.js` starts and follows scans (every folder, picked or listed models, or one
|
||||
model from its card), shows the result and refreshes node drop-downs, hashes and the
|
||||
grid afterwards; folder visibility/order lives in
|
||||
grid afterwards; before an online scan `scan_connection.js` checks whether Civitai can be
|
||||
reached and, when not, asks: scan from the files, set a proxy, or not now;
|
||||
folder visibility/order lives in
|
||||
`ui_folder_manager.js`.
|
||||
`scan_progress.js` owns the scan progress panel
|
||||
(`updateScanProgress` / `finishScanProgress` / `failScanProgress`): inside the scan
|
||||
@@ -350,15 +865,47 @@ covered by the `styles.css` manifest.
|
||||
shortcuts (the second keeps its old "materials" ids), their fallback when ComfyUI's keybinding does not fire, and the
|
||||
settings control that opens ComfyUI's keybinding editor.
|
||||
- `ui_model_sources.js` renders the Models page's Sources view (the last type chip): where each model is downloaded, for the open workflow or every model, with each link editable and saved in place; `model_source_links.js` owns its data and actions (collecting the workflow's models, resolving them here, saving a link to the model's information and the workflow, the canvas note and the clipboard list).
|
||||
- `ui_gallery_card.js` builds one output image's gallery card (viewer, workbench, cover pick, drag, star, delete);
|
||||
`gallery_card_menu.js` is its right-click menu (insert as a Load Image node reading the output file, parameters);
|
||||
- `ui_gallery_card.js` builds one output's gallery card (viewer, workbench, cover pick, drag, star, compare, delete; a video's card
|
||||
plays in the viewer and has no workbench, star or menu); `gallery_filter.js` holds what the gallery lists (All / Pictures /
|
||||
Videos and the formats hidden in Settings, per browser) and `ui_gallery_filter.js` draws both controls;
|
||||
`gallery_card_menu.js` is its right-click menu (insert as a Load Image node reading the output file, parameters),
|
||||
drawn by `action_menu.js` (a small menu at the pointer or under a button);
|
||||
`ui_param_compare.js` compares parameters: a card's ⇄ picks an image, a second image's ⇄ or
|
||||
the canvas completes the pair (the image workbench's ⇄ takes the canvas; the Gallery's and
|
||||
Activity's ⇄ Canvas vs. newest picture pair the newest output PNG with it), and its window says
|
||||
whether both are the same node structure and lists each value that differs (prompts by the tags
|
||||
only one side has, seeds apart), writing ticked image values onto the canvas as one Ctrl+Z
|
||||
step with Undo; `param_compare.js` reads and compares the two sides without DOM.
|
||||
`key_params.js` keeps the starred key parameters (node type + widget name, a hidden ComfyUI
|
||||
setting); the window shows them first. `ui_param_set_keys.js` puts parameter sets in view: a
|
||||
recipe card's ▤ N chip (first in its specs line) (`GET /anomalous/parameters/signatures`; sets equal to the recipe not counted) opens its Parameters tab,
|
||||
and each set there shows its key values, how many values differ from the recipe, and ⇄.
|
||||
`ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts,
|
||||
each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe,
|
||||
notebook or material delete route); `image_keep.js` does the keeping (an image as a combo,
|
||||
its prompts as a saved prompt, reading what was kept) without DOM.
|
||||
named by `comboTitle` — first LoRA or main model · two telling tags — and renamed by `renameCombo`;
|
||||
its prompts as a saved prompt, reading what was kept) without DOM. A new combo's toast is
|
||||
`ui_kept_toast.js` (the image's thumbnail, the name in a box to rename it on the spot, Open).
|
||||
An image becomes a node-structure combo: `sampler_inputs.js` (no DOM) picks the nodes upstream
|
||||
of its samplers' model / positive / negative / conditioning inputs (not through images,
|
||||
latents or masks), and `image_keep_nodes.js` saves them with `captureStructure`, every value
|
||||
as the image had it, models renamed to where they are on this computer; a workflow with no
|
||||
sampler gives the older main model + LoRAs + prompt combo.
|
||||
- `ui_combos.js` renders the Combos page's list (搭配: a main model, LoRAs and a prompt, formerly Prompt Notes): search,
|
||||
New, and a card per combo with its model's cover and Put on canvas; a card opens the combo's editor, and dragged onto the
|
||||
? (what a combo is), New, and a card per combo with its model's cover (one kept from an output image shows that image, the model's cover as a badge) and Put on canvas; a card opens the combo's editor, and dragged onto the
|
||||
canvas (`material_drag.js`) becomes a new group of nodes where it is dropped. Combos keep the notes' files.
|
||||
A combo can instead hold a node structure (`data.kind === 'nodes'`): `combo_structure.js`
|
||||
captures the canvas's picked nodes (subgraphs aside) with the links between them and puts
|
||||
them back (other boxes stored by name and restored untouched, slots = model drop-downs and
|
||||
text boxes, the folder a drop-down lists read from ComfyUI's own `/models` lists, inputs fed
|
||||
from unpicked nodes noted to wire);
|
||||
`combo_slots.js` holds its rules that need no canvas; `ui_combo_structure.js` is the save
|
||||
dialog, the slot editor, the slot model picker and "New"; `ui_translation_peek.js` is the
|
||||
read-only translation panel beside its prompts. `lora_nodes.js` knows the LoRA nodes a default
|
||||
combo puts its LoRAs in (ComfyUI's chain, rgthree's Power Lora Loader, Easy-Use's LoRA stack, the
|
||||
third-party ones only when installed) and a Power Lora Loader's rows (for structures, and as
|
||||
drop-down-like boxes for Model Check); `ui_lora_node_choice.js` is the default combo editor's
|
||||
choice among them.
|
||||
- `detached_graph.js` lays a workflow on a graph of its own, off the canvas, under an id of its own: ComfyUI keeps widget
|
||||
values by graph id, so a copy under the canvas's id would write its values onto the canvas.
|
||||
- `recipe_save.js` saves Workflow Recipes for the canvas save and for an output image's workflow (the gallery's keep menu,
|
||||
@@ -366,42 +913,79 @@ covered by the `styles.css` manifest.
|
||||
- `ui_apply_receipt.js` is the receipt of values written to a node from a panel (Current node's parameters) with its Undo. `node_material_actions.js` owns prompt envelope extraction (`extractMaterialPromptEnvelope`) and the node writes shared by those panels and prompt drops.
|
||||
- `ui_update_guide.js` and `update_guide_data.js` own the non-intrusive update guide modal (accessible via header button `#anomalous-update-notice-btn`, the Help modal and Home's "What's new"; the current guide's ID and steps live in `update_guide_data.js`) with full bilingual localization. `ui_spotlight_tour.js` provides the interactive spotlight mask tour (`startSpotlightTour`), gliding smooth focal box highlights across the rail's pages and tools with directional tooltip cards and keyboard navigation; steps whose target is not on screen are skipped. The browser tour goes in the order the pages are used (rail, Gallery and what its ☆ keeps, Workflows, Combos, Prompts, then the tools), with its text in `locales.js`. Other views pass their own `steps` (text from locale keys) and an optional `onClose`; the GPT-SoVITS import window does.
|
||||
- `tool_registry.js` holds the tool icons shared by the rail and Home.
|
||||
- The rail's tool slots (`ui_rail_tools.js`, fixed) hold scan, doctor and current node; settings sits at the rail's bottom. The rail's Prompts entry opens Prompt Studio beside the canvas (the browser folds away and comes back when it closes). There is no toolbox: the other tools open from their pages (workflow share codes on Workflows, Model Sources as a view of Models, opened from the doctor too, translation in Current node).
|
||||
- The rail's tool slots (`ui_rail_tools.js`, fixed) hold scan, doctor and current node; settings sits at the rail's bottom. The rail's Prompts entry is Prompt Studio's page (`ui_prompt_composer.js`); its "Beside the canvas" moves the studio into a drawer by the canvas with the browser folded away, and "Back to the window" returns it. There is no toolbox: the other tools open from their pages (share and import on Workflows, Model Sources as a view of Models, opened from the doctor too, translation in Current node).
|
||||
- `ui_share.js` is share and import (the Workflows page's ⇅): the canvas workflow as a share
|
||||
code in Chinese characters or letters, and one place to paste a code or workflow JSON or drop
|
||||
a workflow file, an image, a recipe package or a backup .zip. `ui_recipe_package.js` exports
|
||||
a recipe package from a card or the detail's More menu and takes one in as a new card. `share_code.js` is the AMB2 code (lean workflow
|
||||
checked by rebuilding it, else the whole workflow; AMB0/AMB1 still open); details in
|
||||
`docs/architecture/frontend.md`.
|
||||
- `ui_model_types.js` owns the models page's type chips (one per models folder, with its count) and
|
||||
`owner.modelScope`, what the grid lists: a whole type, or one list folder shown as a crumb. The
|
||||
grid's cards set `currentType/PathIdx/Subfolder` to their own model's folder (`focusModel`),
|
||||
which the editor, the scanner and "add to canvas" read.
|
||||
which the editor, the scanner and "add to canvas" read. Its Import chip and files dropped on
|
||||
the grid open `ui_model_import.js`, the import window (one card per file: type, base model,
|
||||
where it goes, warnings, progress); `model_import.js` reads each file's header and asks,
|
||||
moves or uploads it, then scans the new files. The page's tabs (All models, Tidy with how
|
||||
many things it found, Sources) head the grid; under All models the type chips and the base
|
||||
model chips (`owner.modelBase`; `model_bases.js` names each model's family from its scanned
|
||||
base model or its header's guess). The Tidy tab is `ui_model_tidy.js` (`owner.modelView ===
|
||||
'tidy'`): cards with covers in two steps — models in a folder whose loader cannot read them
|
||||
(from → to, Move there), identical copies (pick the one to keep, recycle the rest);
|
||||
`model_placement.js` does its requests and keeps the tab's count. Under the two steps
|
||||
`ui_model_usage.js` lists the models not used for 30 / 90 days or never (picked ones go to
|
||||
the Recycle Bin) and leads to Settings → Node packs, where `ui_pack_usage.js` lists the node
|
||||
packs: each opens ComfyUI Manager's node list searched for it (Manager 3; its name is copied
|
||||
for the rest); packs that also add to the page are set apart, those marked "I still use it"
|
||||
listed last. `usage_view.js` is what both share: the check, kept in `owner.modelUsage`, and
|
||||
each page's head (groups, what the run record vouches for). `usage_notes.js` posts what each workflow ComfyUI opens holds (its own
|
||||
`loadGraphData` wrapper, once per content and page load) and the open tabs just before a
|
||||
check, so opening a workflow or putting a node on the canvas counts as use. `ui_model_search.js` is the models
|
||||
search in the header (shown on the models page through the shell's `data-page`): the grid
|
||||
lists the models of its type matching every word (`owner.modelQuery`).
|
||||
- `model_source.js` shows where a model's information came from: the card badge (marked only
|
||||
when inferred from the file, ≈, or not scanned yet) and the detail header's source line with
|
||||
the fields the user set.
|
||||
when inferred from the file, "推断", or not scanned yet), the details' source line with
|
||||
the fields the user set, its links (source link, Civitai) and, for a model Civitai
|
||||
does not know, the note on what was read from the file.
|
||||
- `ui_grid.js` and model-detail modules own model presentation: `ui_grid.js` manages chunked card rendering,
|
||||
card placeholder ergonomics (eliminating misleading unclickable text in favor of pure centered icon and status),
|
||||
card action buttons (one-click canvas addition with plus icon, model metadata editor, direct precision scanner without wizard modal popups)
|
||||
with absolute positioning cascades immune to tooltip target conflicts, vibrant hover affordance,
|
||||
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
|
||||
coordinates detail display (the file card with Show in folder in `model_location.js`), `ui_model_editor.js` owns metadata editing, and
|
||||
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` (cards in
|
||||
`ui_gallery_card.js`) and `ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
|
||||
coordinates detail display (cover column, then the head from `model_detail_head.js`: back crumb, name, source line,
|
||||
Add node / Edit / Folder / Outputs; the foot with the path, Copy and Delete, and Show in folder, in
|
||||
`model_location.js`), `ui_model_editor.js` owns metadata editing (cover, name, source link, notes;
|
||||
styles `styles/31-model-info.css`), and
|
||||
`model_widget_path.js` puts a model path into a loader's widget. `ui_gallery.js` (cards in
|
||||
`ui_gallery_card.js`, filters in `gallery_filter.js` / `ui_gallery_filter.js`) and `ui_gallery_detail.js` own generated-output browsing and workbench lifecycle,
|
||||
with stage interaction in `ui_image_stage.js` and metadata tabs in
|
||||
`ui_image_inspector.js`.
|
||||
- `ui_recipes.js` / `ui_recipe_detail.js` and `ui_notebooks.js`
|
||||
own their respective workspace surfaces and persistence flows. `ui_recipes.js` owns
|
||||
the Workflow Recipe studio catalog workspace with search/filter tags, grid/list layout toggle,
|
||||
dedicated top-right modal close anchor (permanently decoupled from the tool button row to prevent wrapping displacement),
|
||||
streamlined action header (preserving active workflow saving while pruning unfinished package
|
||||
import entrypoints), and card browsing. `ui_notebooks.js` owns the Combos workspace (its list or one combo's editor,
|
||||
with Back to the list) and combo persistence; `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
|
||||
own their respective workspace surfaces and persistence flows. `ui_recipes.js` hands the
|
||||
Workflows page (its catalog) the recipe services (save, edit, fetch) and opens a recipe by name. `ui_notebooks.js` owns the Combos workspace (filling the page; its list or one combo's editor,
|
||||
with Back to the list) and combo persistence; `ui_combo_hero.js` is the head of a combo's editor (its picture: the image it was kept from with See the picture, else its model's cover; the name with Rename; chips of what it holds; Put on canvas and More; no Save, every change saves itself), also over node-structure combos; `ui_combo_models.js` is the editor's models (the base model, the chosen main model and LoRAs as rows, Change / Add opening a grid of compatible models read only then); `ui_notebook_editor.js` puts them together with the prompt and owns unfolded card editing (modularized into single-responsibility
|
||||
sub-functions adhering to the 50-line rule: sticky top action toolbar with floating More popover dropdown and timed two-step delete safety guard,
|
||||
unfolded companion models card with unconstrained multi-column tile flow eliminating nested gallery scrollbars,
|
||||
prompt composer with dynamic field-sizing and compact inline find & replace toolbar, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
|
||||
`notebook_canvas.js` puts a combo on the canvas as a new wired group of nodes (following the pointer, or at a dropped
|
||||
card's position; Esc takes a following group off again); it never changes nodes already there. Combos are a rail page. `ui_recipe_detail.js`
|
||||
coordinates the Workflow Recipe detail session and model composition. `ui_recipe_overview.js`
|
||||
owns the Overview prompt showcase (with `entry.text` fallback, guarded non-shrinking primary action CTA, and floating Popover More dropdown menu), and `ui_recipe_parameters.js`
|
||||
owns the responsive Parameter Presets workspace (featuring default-expanded raw node parameter inspection,
|
||||
a `clamp(230px, 24vw, 290px)` sidebar with guarded card actions, uncluttered console action bars with deferred status feedback,
|
||||
`minmax(130px, 1fr)` Bento Grid with universal click-to-copy, LoRA cards with flexbox truncation guards,
|
||||
and sticky editor headers).
|
||||
is a workflow's details, built around its values (styles `styles/29-workflow-detail.css`): a bar
|
||||
(Back, the name, the models' state, Open, ⋯ edit and export), then `ui_recipe_sets.js`'s left
|
||||
column (the workflow's own values, the saved sets unlike them with their key values, Save the
|
||||
canvas, New; then all pictures and versions) and the chosen view on the right.
|
||||
`ui_recipe_set_view.js` is one set: Apply to canvas, ⇄ (with the workflow's own or the canvas),
|
||||
⋯ (copy as a new set, rename, delete); the cover, notes and tags for the workflow's own
|
||||
(`ui_recipe_info.js`, which also holds the name and the models' state); the workflow's focus
|
||||
values (重点参数: `recipe_focus.js` keeps them in the recipe's params and tells comparisons which
|
||||
values they are, `ui_recipe_focus.js` draws them with their notes and the ☆ on each node
|
||||
value); the main values; the
|
||||
prompts; the pictures made with these values; the models (the fold says whether all are here)
|
||||
and every node's values, folded, each opening as the viewer last left it (this browser's
|
||||
storage); a tip while the workflow has no set, and one pointing at ⇄ on a set until it is used.
|
||||
Narrow (docked) details turn the left column into one row of pills (a container query). A new
|
||||
set is named after the workflow with the next number (`nextSetName`). `ui_recipe_parameters.js`
|
||||
holds what the views share: prompt roles (automatic or set by hand) and their boxes, every node's
|
||||
values (unnamed ones labelled by their node type's widgets, `comfyWidgetLabels`), the new set's editor.
|
||||
`prompt_boxes.js` is the one place that finds prompt boxes on the live canvas: a
|
||||
multiline STRING input by the node's ComfyUI definition, whatever it is called; its
|
||||
role is the box's own name (positive / negative) or else the wiring (outputs followed
|
||||
@@ -413,20 +997,31 @@ covered by the `styles.css` manifest.
|
||||
extracts prompt envelopes without model file paths and writes them (`fillPrompt`);
|
||||
text never crosses roles unless the user picked the box; writes are one undo step.
|
||||
Within recipe detail, `ui_recipe_versions.js` owns history comparison/restore,
|
||||
`ui_recipe_gallery.js` owns result cards and direct Image Detail Workbench handoff,
|
||||
`ui_recipe_gallery.js` owns the picture grid (thumbnails, 参数, ⇄ to compare) and the all-pictures view,
|
||||
`ui_recipe_models.js` owns the overview's model list (preview, presence under the saved name,
|
||||
note, download page; finding missing models is Model Check's) and the recipe cover,
|
||||
`ui_recipe_metadata.js` owns inline persistence, and `ui_recipe_detail_dom.js` owns
|
||||
the DOM/copy helpers shared by detail subviews. `recipe_identity.js` derives model
|
||||
references from native loaders plus a table of verified all-in-one loader layouts
|
||||
(`ALL_IN_ONE_LOADER_SPECS`, mirrored in `api/recipe_schema.py`); other third-party
|
||||
widgets stay parameters. References are keyed by
|
||||
(`ALL_IN_ONE_LOADER_SPECS`) and two stack nodes by their rows (rgthree's Power Lora Loader,
|
||||
Easy-Use's LoRA stack; `nodeModelValues`), all mirrored in `api/recipe_schema.py`; other
|
||||
third-party widgets stay parameters. References are keyed by
|
||||
`(node_id, widget_index, category, saved_value)`. `recipe_parser.js` summarizes
|
||||
models, LoRAs, samplers and prompts (linked prompt nodes before embedded loader
|
||||
prompts) and keeps the summary in step with widget edits using the same node rules.
|
||||
`ui_recipe_catalog.js` owns recipe
|
||||
filters, navigation, dismissible topbar drag guidance strip with localStorage persistence, the 3-step empty-state onboarding blueprint (`renderRecipeEmptyGuide`), and background catalog-wide model readiness resolution (`resolveCatalogRecipeReadiness`), `ui_recipe_cards.js` owns cards and card actions
|
||||
(including `grab` drag affordance, cover `可拖拽` badge, harmonized multi-state model readiness pill with `getRecipeReadiness` synchronizing available, missing, and pending matches with detail overview, and direct canvas drag-and-drop), `ui_recipe_dialogs.js` owns save/edit dialogs, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
|
||||
prompts) and keeps the summary in step with widget edits using the same node rules; which
|
||||
LoRAs a node loads is `recipe_loras.js`, and reading a node's boxes `recipe_widgets.js`.
|
||||
`ui_recipe_catalog.js` is the Workflows page: one bar (search, the filter menu with kind and tag,
|
||||
the count, grid or list, share and import, save the canvas), the list, the empty page's three-step
|
||||
guide (`renderRecipeEmptyGuide`) and the catalog-wide model readiness check
|
||||
(`resolveCatalogRecipeReadiness`). `ui_recipe_cards.js` is one card: the cover with the models'
|
||||
state (`getRecipeReadiness`: a dot, or words when some are missing) and 功能模块 for a partial
|
||||
workflow, the name, a line of steps, sampler, LoRAs and size, then its model; Open and ⋯ (edit, export, delete)
|
||||
as two small buttons in the cover's corner on hover; a press anywhere else opens the details, a drag opens it on
|
||||
the canvas. Opening a workflow on the canvas hides the browser and leaves the Workflows list in place. Its styles are
|
||||
`styles/28-workflows.css`. `ui_popup_menu.js` is the small menu under a button they share,
|
||||
`ui_recipe_save_dialog.js` is saving the canvas as a workflow (the cover chosen on the left from the canvas,
|
||||
the current cover or recent pictures as thumbnails; name, tags, notes and the fingerprint choice on the right;
|
||||
the name defaults to the canvas's saved workflow name), `ui_recipe_dialogs.js` the edit dialog, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
|
||||
- `ui_prompt_composer.js` owns the standalone Prompt Studio drawer (dock side, width, Esc);
|
||||
`ui_prompt_workbench.js` fills it: the top bar (tags or text view, the meanings' language) and
|
||||
saving a box as a card. `ui_prompt_target.js` decides what is edited, the prompt boxes of
|
||||
@@ -435,9 +1030,12 @@ covered by the `styles.css` manifest.
|
||||
box as tags (select, weight, edit, remove, reorder, drop, type, translate to English) or text.
|
||||
`prompt_tags.js` splits and joins a prompt's tags keeping their separators, and
|
||||
`prompt_gloss.js` looks up and keeps the tags' meanings in the picked language. `ui_prompt_source_deck.js`
|
||||
owns the card list (three built-in cards and the saved prompts), search, new-card form,
|
||||
owns the card list (three built-in cards and the saved prompts, drawn as notebook pages: the role
|
||||
on the spine, the first tags, a ribbon on the one open as the reference), search, new-card form,
|
||||
library sync and renaming / deleting a saved prompt; `prompt_material_source.js` reads
|
||||
saved prompts as cards and saves one; `ui_prompt_card_popover.js` owns the card preview (hover
|
||||
saved prompts as cards and saves one; `ui_prompt_reference.js` is the reference column a
|
||||
clicked card opens in, between the cards and the boxes (its tags taken one by one or all, ticks
|
||||
on those the box has; it widens the drawer while open, and takes the cards' place where three columns do not fit); `ui_prompt_card_popover.js` owns the card preview (hover
|
||||
corridor, pin, Copy / Add / Rename / Delete); `prompt_card_drag.js` lets a card or a box be
|
||||
dragged out of the drawer onto a canvas prompt box or empty canvas (through `material_drag.js`
|
||||
and `prompt_drop.js`), passing through inside the drawer so the boxes still take it.
|
||||
@@ -447,7 +1045,8 @@ covered by the `styles.css` manifest.
|
||||
cancellation and resize cleanup. Translation requests go through `translation_service.js`.
|
||||
- `ui_dialog.js` owns the plugin's own alert / confirm / prompt dialogs
|
||||
(`anomalousAlert`, `anomalousConfirm`, `anomalousPrompt`), used instead of the
|
||||
browser's native ones. `ui_prompt_toast.js` is the short toast (optionally with an
|
||||
browser's native ones; an alert can carry a line to copy (`copy`), and `copyText`
|
||||
also works on a page served over plain http. `ui_prompt_toast.js` is the short toast (optionally with an
|
||||
action, such as Undo after a drop) shared across the plugin.
|
||||
- `canvas_history.js` asks ComfyUI's Ctrl+Z history for a step after each canvas write
|
||||
Anomalous makes (applied values, drops, created or inserted nodes, recipes): ComfyUI
|
||||
@@ -456,18 +1055,33 @@ covered by the `styles.css` manifest.
|
||||
types a loader widget holds, which are never physically renamed, and which
|
||||
need a workflow-carried hash before Model Check recovers them.
|
||||
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns the image
|
||||
workbench's metadata and node-parameter rendering.
|
||||
- `model_check.js` checks the open workflow's models against this computer (Model
|
||||
workbench's metadata and node-parameter rendering; `png_metadata.js` reads the workflow a
|
||||
PNG's text chunks carry, in the browser (the gallery detail's fallback).
|
||||
- `ui_first_run.js` is Home's first-run setup while nothing is scanned (check that Civitai answers
|
||||
through `api/network_check.py`, the optional key, a scan of every model, the tour); a card, never
|
||||
a popup, hidden per browser.
|
||||
- `model_check.js` checks the open workflow's models (drop-downs naming model files, and
|
||||
switched-on Power Lora Loader rows through `lora_nodes.js`) against this computer (Model
|
||||
Check's verdicts, no DOM; the feature was called Model Doctor, and code and CSS still
|
||||
say "doctor") and puts a found file into its node; `ui_doctor.js` is the
|
||||
Model Check page and `ui_doctor_banner.js` the bar over the canvas when an opened workflow
|
||||
misses models (it also owns the check-on-open preference that Settings → Workflows and the
|
||||
bar's "Don't show again" set). `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
|
||||
bar's "Don't show again" set). `model_download.js` finds a missing model's source,
|
||||
decides where its file goes (the download settings, `{base}` = its base model's folder),
|
||||
follows the downloads and puts each finished file into its nodes (the placement rules
|
||||
are `download_places.js`, without imports so they are tested alone); `ui_model_download.js`
|
||||
is the page's Download buttons, progress and the dialog that shows and changes where
|
||||
each file goes. `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
|
||||
insertion, model cards and history), `ui_node_model_picker.js` owns native combo
|
||||
replacement, `ui_node_parameters.js` renders the panel's parameters section, and
|
||||
replacement, `ui_node_parameters.js` renders the panel's parameters section (each saved
|
||||
set as a card with the picture made with it and where it came from, values as bars or
|
||||
old → new, or all sets side by side), and
|
||||
`node_parameter_sets.js` merges the node type's saved values (material files and recipe
|
||||
parameter sets) and works out what each would change on the node, keeping seeds,
|
||||
model files and missing choices; saved values (not a recipe's) are deleted there. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
|
||||
parameter sets, each source with its origin) and works out what each would change on the node, keeping seeds,
|
||||
model files and missing choices, and asks `/anomalous/parameter_pictures` for the recent pictures made with
|
||||
the sets' values; saved values (not a recipe's) are deleted there. It also keeps, in this
|
||||
browser's storage (`anomalous_applied_sets`, the newest 100 by workflow and node), which set
|
||||
was last put into a node, for the panel's line on where the values came from. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
|
||||
own the remaining explicit graph changes.
|
||||
- A model's download link lives in its user layer (`source_url` in `<model>.anomalous.json`, via
|
||||
`/anomalous/update_metadata`), the same field the model editor edits; for the open workflow's
|
||||
|
||||
+638
@@ -1,5 +1,643 @@
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.62.2 (Fix: workflow cards squeezed into strips) — 2026-11-05
|
||||
|
||||
- 工作流很多时,卡片不再被压成细条,列表正常滚动。
|
||||
- With many workflows, the cards are no longer squeezed into thin strips; the list scrolls.
|
||||
|
||||
## v1.62.1 (Fix: Anomalous sometimes did not start) — 2026-11-05
|
||||
|
||||
- 修好一个偶发的启动故障:有时打开 ComfyUI,AMB 整个没加载,悬浮按钮和窗口都没有,刷新一下又好了。
|
||||
- Fixed a startup failure that came now and then: ComfyUI opened with no Anomalous at all, no button and no window, until a reload.
|
||||
|
||||
## v1.62.0 (AI Workbench, AI Builds Workflows on the Canvas, Repaint an Area, Materials Side Panel) — 2026-10-29
|
||||
|
||||
- 音频库搬进了“角色语音”页:页面顶上的标签现在是 **角色 | 配音 | 音频库**,左边栏少了一项。加了 AI 工作台以后左边栏有 10 项,数字键只有 1–9,音频库就没键可按了;现在 9 打开角色语音,并回到你上次停的那个标签。
|
||||
- The audio gallery moved into Voices: the tabs at the top read Characters | Voice-over | Audio, and the rail is one entry shorter. With the AI workbench the rail had ten entries for nine number keys, leaving the gallery without one; 9 now opens Voices on the view used last.
|
||||
|
||||
- AI 能拿刚出的图接着改了:搭“在这张图上重画”的工作流时,加载图像节点也认输出文件夹里的图(`xxx.png [output]`,ComfyUI 本来就支持);以前 input 文件夹里没有这张图就被拦下。左侧栏的“模型”“图库”不再显示成英文 models / gallery。
|
||||
- The AI can redraw a picture it just made: a Load Image it builds now takes an output picture (`name.png [output]`, as ComfyUI itself does) instead of being refused when the input folder lacks it. The rail shows Models / Gallery again instead of the raw keys.
|
||||
|
||||
- 修复完成后的任务接续:AI 带着原任务 ID 查看结果、补面板,也会留在刚完成的任务里,不会另开空任务或把面板放进另一个任务。错误标记只在对应重试完成并成功后消除;等待审批、仍在运行、已取消,以及另一个节点或设置的成功都不算修好。
|
||||
- Fixed follow-ups carrying an explicit task ID: reads and panels immediately after completion stay with that task instead of creating an empty task or placing a panel in another one. Error recovery requires a completed successful retry of the recorded target; pending approval, running or cancelled attempts and success on another node or setting do not clear it.
|
||||
|
||||
- 参数名自动纠正现在也用于审批和任务记录:你批准后,AI 改用正确参数名重试不再重复问,换模型的结果卡片也不会漏掉。展台和底部提问条同步同一道题的草稿,刷新时保留原来的光标、选区和编辑位置。
|
||||
- Corrected argument names now carry through approval and task recording: retrying with the proper name reuses an unused approval, and model changes keep their result cards. The stage and bottom question strip share each answer draft; refresh preserves the caret, selection and editing surface.
|
||||
|
||||
- 修复任务接续与等待回答:按你最近一次未读回复接续任务,后台进度更新不再打乱顺序;选择题回答也能接回原任务,仍有工具正在执行的任务不会被新会话自动接走。读取答案不指定面板时也会正确处理超时;到时间自动选择或恰好收到你的回答,都会在当次调用里交给 AI。等选择题时发的留言和图片不再被读走却漏传。
|
||||
- Fixed task resumption and answer waiting: resume by the latest unread user reply, including panel answers, rather than background updates; a task with a tool still running is excluded from automatic takeover. Reading answers without a panel now handles expired questions too. Timeout choices and user answers arriving at that boundary reach the AI in the same call; messages and pictures sent while it waits for a question are no longer consumed without delivery.
|
||||
|
||||
- 任务列表更准:AI 改正过来的错(比如参数写错、下一次就对了)不再让整个任务显示成“出错”,只有 AI 没跨过去的才算,红标改叫“有一步没做成”;AI 说做完以后马上再看一眼结果,这一眼记在原来的任务里,不再多出一个只有一步的空任务;接着给你看前后对比、问你满不满意,也回到原来那个任务里(任务重新打开),不再另起一个;你点“新任务”写好的任务一直等到 AI 来接(或者你再开一个新的),不会过了 12 小时悄悄失效、而页面还写着“等 AI 接手”。
|
||||
- AI 少走弯路、少猜:
|
||||
- AI 把必填参数写成了常见的另一个名字(比如打开工作流时把 `workflow` 写成 `name`、看图时把 `image` 写成 `file`)时,AMB 直接照它的意思做,并告诉 AI 正确的名字,省一次来回。只认考试里见过的这几种叫法;别的名字照旧报错、列出该写什么,免得猜错意思。
|
||||
- 时间线里 AI“放大看图”那一步显示成代码名,改成了中文名。
|
||||
- 去掉了“给 AI 的工具”里的 **日常**:所有工具都直接给。Antigravity、Claude Code、Cursor、VS Code 这些软件会自己按需读工具说明,AMB 再做一层“现查现用”只会让 AI 多绕路(一次实测 29 次调用里 18 次花在查工具和转一道上)。
|
||||
- AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,任务标题下面会出现一行黄字,告诉你怎么让它重新连接;AI 那边也收到同样的提醒。AI 软件重新取了列表,黄字自己消失。
|
||||
- AI 加 LoRA 或换模型时,AMB 对比 LoRA 和主模型的底模:一个是 SD 1.5、一个是 SDXL 这种用不了的,会提醒 AI 并告诉它按底模去找;Pony 的 LoRA 用在 Illustrious 上这种能用但可能效果差的,也会轻轻提一句。只提醒,不拦着。
|
||||
- AI 看模型详情时,能看到作者示例图用的采样器、步数、CFG、尺寸,LoRA 还有作者用的权重(来自扫描时存下的 Civitai 资料),还有版本名。
|
||||
- 跑图被 ComfyUI 拒绝时,AI 收到的不再是原样的英文报错,而是“哪个节点、缺什么、该用哪个工具修”。
|
||||
- 用 Gemini 3.8 Flash 实际考了 7 道常见任务(出图、换模型加 LoRA、问你再改、缺模型、找图看参数、搭放大分支、修断线),失败多半出在第一次用某个工具时参数写错,于是:参数写错时,AI 收到的报错会列出这个工具该写哪些参数,下一次就对;同一个东西在各工具里叫同一个名字(写提示词和跑图都叫 `prompt`,打开工作流和跑图都叫 `workflow`);等待时间写超了(比如 120 秒)按最长的 55 秒等,不再报错;按类型找模型时写单数(checkpoint、lora)也能找到。
|
||||
- 很久以前(两个多小时前)留下、AI 一直没收到的留言或标注,不再把一个新对话拉进那个旧任务。
|
||||
- 关卡窗口新增 **等不到回答时**:**停下等我**(默认)或 **按推荐的继续**。选后者时,到了等待时间你还没答,AI 标了推荐项的选择题就按推荐的选,展台和“决策”里都写着“到时间,按推荐选了”;没有推荐项的问题、你已经动手写了回答的问题、关卡里“先问我”的事,永远不会替你答。
|
||||
- The task list says what is true: an error the AI got past (a wrong argument right on the next call) no longer marks the whole task as failed, only one it did not get past does, and the red mark reads "A step did not go through"; a look the AI takes right after saying it is done goes into that task instead of making an empty one-step task, and a before and after or a "happy with it?" it shows next opens that task again instead of starting another; a task you start with New task waits until an AI takes it (or you start another), instead of quietly lapsing after 12 hours while its page still says it waits.
|
||||
- The AI takes fewer detours and guesses less:
|
||||
- When a call writes its one needed argument under a known other name (`name` for open_workflow's `workflow`, `file` for `image`), Anomalous takes it as meant and tells the AI the right name, saving a round trip. Only the names seen in the exams count; any other is answered with what the tool takes, so no meaning is guessed.
|
||||
- The timeline names the AI's "look closer at a picture" step instead of showing its code name.
|
||||
- The tool set's **Daily** choice is gone: AI apps get every tool. Antigravity, Claude Code, Cursor and VS Code load tool descriptions as they need them, so looking tools up through Anomalous only added detours (in one run 18 of 29 calls).
|
||||
- When an AI app still calls by the tool list of an older Anomalous, a yellow line under the task header says how to reconnect it, and the AI is told too; it goes once the app fetches the list again.
|
||||
- Adding a LoRA or swapping a model, Anomalous compares the base models of the LoRA and the main model: one that cannot work (SD 1.5 on SDXL) is pointed out with how to find a fitting one; one that loads but may look off (a Pony LoRA on Illustrious) gets a softer note. A note only, never a block.
|
||||
- Model details give the AI the sampler, steps, CFG and size the author's example pictures used, and for a LoRA the weight (from the Civitai record a scan keeps), and the version name.
|
||||
- When ComfyUI refuses a run, the AI gets which node, what is missing and the tool that fixes it, instead of ComfyUI's raw report.
|
||||
- Gemini 3.8 Flash took seven everyday tasks as an exam (a run, a model and LoRA swap, asking you first, a missing model, a picture's settings, an upscale branch, a broken link); most failed calls were a tool's first use with wrong arguments, so: an argument error now lists what the tool takes, so the next call is right; one thing has one argument name across tools (`prompt` for set_prompt as for run_workflow, `workflow` for open_workflow as for run_workflow); a wait longer than 55 s waits 55 s instead of failing; a model type in the singular (checkpoint, lora) finds them too.
|
||||
- A message or marks the AI never got, left more than two hours ago, no longer draws a new chat into that old task.
|
||||
- The Gates window has **When I don't answer**: **Wait for me** (the default) or **Go with the recommendation**, where a choice the AI marked a recommended option for takes it once your waiting time runs out, shown on the stage and under Decisions; a question without a recommendation or one you began to answer, and anything a gate asks first, is never answered for you.
|
||||
|
||||
- AI 问你的问题更好答:选项是一按就发出去的按钮,右边有箭头,问题下面写着“点一个就发给 AI”,点了马上变成选中的样子;**让 AI 定** 挪到问题右上角,不再跟选项挤在一起被当成“确认”;“都不是”那一行默认收起成 **都不是,我自己说…**,关卡卡片上的原因也收起成 **说说想怎么改…**,点了才出输入框。展台、留言框上方的提问条、关卡卡片都一样。关卡窗口里几条说明改对了:改画布也管加节点、连线、新建工作流;跑完以后那几句话在留言框上方;“先问我”和按钮上的字对上了。写了一半的回答不再被清掉:AI 又问了新问题、读走了另一个回答时,字和光标都还在,那个问题一直排在提问条最前面;窄窗口里你正在打字时,新问题只点亮展台标签,不把你切过去;用输入法按回车选字时,不再把半句回答发出去。
|
||||
- Questions from the AI are easier to answer: options are buttons that send at a press, with an arrow and "One press sends it to the AI" above them, and the pressed one shows at once; **Let the AI decide** sits at the question's top right instead of under the options where it read as a confirm button; a line of your own stays folded as **None of these, I'll say it…**, and the Gates card's reason as **Say how to change it…**. The same on the stage, the question strip over the message box and the Gates card. Gates window texts fixed: changing the canvas includes adding nodes, wiring and new workflows; the lines after a run are over the message box; the intro names "Ask me" as the button does. A half-typed answer is no longer wiped when the AI asks something new or reads another answer: the text and the cursor stay, and that question stays first in the strip; in a narrow window a new question lights the stage tab instead of taking you there while you type; Enter that picks a word in an input method no longer sends half an answer.
|
||||
|
||||
- 局部重绘前先对一下范围:你圈得不一定准,AI 看了标好的图可以挪动、改形状(方框 / 椭圆)、往外扩、把边缘做软,然后在展台把 **你圈的** 和 **AI 调整后** 并排给你看,你点 **用 AI 调整的 / 用我圈的 / 我重新圈**,或者写一句想怎么改。选好以后 AMB 把带这个遮罩的图放进 input 文件夹,AI 收到文件名和一句常用重绘节点的提示;画布上怎么加节点、怎么连线仍然由 AI 自己搭,不套固定模板。
|
||||
- Agree on the area before repainting: your marks are rough, so the AI can move, reshape (box or ellipse), grow and soften the area and show **Your marks** beside **The AI's area** on the stage; you pick **Use the AI's / Use mine / I'll mark it again** or write what to change. Anomalous then puts the picture with that mask into the input folder and the AI gets its name with a short note on the usual repaint nodes; how to build the repaint on the canvas stays the AI's, no fixed template.
|
||||
|
||||
- AI 问你问题后会等你:关卡窗口新增 **等我回答**(1 分钟 / 3 分钟 / 10 分钟,默认 3 分钟)。一次调用最多等 55 秒,你没答它就接着等,直到这个时间;你在回答框或留言框里打字时,停手一分钟内都算还在答。AI 写完说明或放出展台面板后会被提醒:收工前先问你下一步做什么、要不要开始(跑图、下载、扫描时除外),说明里写了问句的,提醒它改用提问。问题上的“这题跳过”改叫 **让 AI 定**,AI 收到后自己决定、记下决定,不再把同一个问题再问一遍。
|
||||
- The AI waits for your answer: the Gates window has **Waiting for your answer** (1, 3 or 10 minutes, 3 by default). One call waits up to 55 s and the AI then waits on until that time, and while you type in an answer or the message box until a minute after your last key. After a note or a panel it is reminded to ask what next or whether to go ahead before it ends its turn (not while a run, download or scan goes on), and to put a question in its note into ask_user. "Skip this one" is now **Let the AI decide**: the AI decides, records it and does not ask again.
|
||||
- 记录里不再出现“你在 AMB 里改了:保存图像 $$canvas-image-preview”:那是 ComfyUI 显示预览图时自己改的内部字段,不是你的改动,以前会被当成你的改动发给 AI。
|
||||
- The record no longer says you changed a Save Image node's `$$canvas-image-preview`: ComfyUI sets it to show a node's picture, and it used to reach the AI as your change.
|
||||
|
||||
- AI 已经把任务做完了,你再留言或在图上标注:任务重新打开,留言框上方出现那句一键复制的话(“我在 Anomalous 里回复了,看看再接着做”),AI 回来接着在这个任务里做、回复也挂在你那条下面,不再另开一个新任务。给更早的任务留言也一样:AI 手上的任务做完或停下后,会回到你留言的那个任务。
|
||||
- When the AI had already finished a task and you write to it or mark a picture, the task opens again: the line to copy for the AI shows over the message box, and the AI goes on in this task, answering under what you wrote, instead of starting a new one. The same holds when you write to an older task while the AI had moved on: once its own task is done or paused, it comes back to the one you wrote to.
|
||||
|
||||
- 同一个东西只用一个名字:界面上的“配方”统一叫 **工作流**(工作流页的卡片、保存和导出、操作记录、备份说明),“配方包”保留,它是发给别人的 .zip;AI 工作台里统一用“素材”“留言”“节点结构”“连接”“副本”“撤销”。
|
||||
- One name for each thing: what the screen called a recipe is a **workflow** everywhere (the Workflows page, saving and exporting, the activity log, backups); a recipe package stays the .zip you give to others. The AI workbench says materials, message, node map and copy throughout.
|
||||
|
||||
- 标注窗口的“同时做成重绘遮罩”勾选框换成 **怎么改** 三选一:交给 AI 判断、只改标出的地方(其余保持原样,AMB 做好遮罩,AI 只重绘这些地方)、整张重新出图。你选的会作为你的要求写在 AI 收到的第一句,时间线里那条标注也写着“改法:…”;用“涂重绘区”笔刷时自动选中“只改标出的地方”。
|
||||
- The marker's "make a repaint mask" checkbox is now **How to change it**, one of three: leave it to the AI, only the marked areas (the rest stays; Anomalous makes the mask and the AI repaints just those), or the whole picture anew. The choice is the first thing the AI reads, as yours, and the timeline row says it; painting with the repaint brush picks "only the marked areas".
|
||||
|
||||
- 任务列表更跟手:收起时列表也提前排好藏在左边,鼠标碰到左上角按钮(或从页面往左移到最左边缘)下一帧就滑出来;每几秒的自动刷新只重画变了的任务。点任务马上显示:看过的任务直接出来,鼠标停在任务上时先读好,再悄悄补上最新内容;展台里的图先用缩略图,显示得比缩略图大时才换原图。
|
||||
- The task list keeps up: folded, it stays laid out out of sight and slides in from the next frame when the pointer reaches its button (or moves left onto the page's left edge); the regular refresh redraws only the tasks that changed. A picked task shows at once, from the copy read before or read ahead while the pointer rested on it, then fills in what changed; the stage's pictures load as thumbnails first and as the file itself only when shown larger.
|
||||
|
||||
- 任务标题栏的“专用标签页”改叫 **AI 改副本 ▾**:点开是带说明的二选一(改副本 / 改当前画布),不再点一下就悄悄切换;旁边的按钮叫 **切到副本**。快捷指令和关卡窗口用同样的说法。
|
||||
- The header's "Own tab" is now **AI edits a copy ▾**, opening the two ways with what each means instead of switching at a click; beside it, **Show the copy**. The / commands and the Gates window say the same.
|
||||
|
||||
- 素材的“来源”列表:鼠标移到哪一行,哪一行就亮起来,当前选中的那项有底色和勾(之前被一条样式盖掉了);名字没被截断时不再弹白色小提示;搜索框里能用 ↑ ↓ 选、回车确定。
|
||||
- The materials' Source list lights the row under the pointer and tints and ticks the one shown (a later style had hidden both); a name gets a tooltip only when it is cut short; ↑ ↓ and Enter work from the search.
|
||||
|
||||
- 展台入口显示面板数量;收起或查看其他栏时出现新增、更新,会亮主题色小圆点,打开查看后消失。
|
||||
- The Stage button shows its panel count and a theme-colored dot for new or updated content while hidden; viewing the stage clears the dot.
|
||||
|
||||
- “回到最新一步”改为约 240 毫秒的短滚动,工作台刷新不会打断它;滚轮或触摸可随时接管,减少动态效果时直接定位,并补上按钮按下反馈。
|
||||
- Jump to latest now uses a short 240 ms scroll that survives workbench refreshes, yields to user navigation, respects reduced motion, and gives immediate pressed feedback.
|
||||
|
||||
- AI 工作台新增右侧“成果”页:按步骤汇总图片,集中查看工作流、搭配、最新图片的提示词、模型下载请求和音频;复用图库的保存、参数对比与提示词复制入口。时间线上方改为简洁的成果统计,右侧按钮提示尚未查看的新成果。
|
||||
- The AI workbench now collects pictures, workflows, combos, prompts, model download requests and audio in a Results side view, reusing gallery keep/comparison actions. A compact timeline summary and unseen-result badge replace the old results strip.
|
||||
|
||||
- AI 搭工作流支持一次添加并自动接线:`insert_after` 接回选中的下游,`wire_from` / `wire_to` 按唯一类型批量连接。正负条件歧义、多分支、已有输入冲突和循环在写入前报明;连接失败恢复原接线。成功回执包含实际连线、缺失输入与输出可达性,支持原生 Ctrl+Z 撤销。
|
||||
- 现状简报新增有长度限制的流程链;节点搜索只返回少量摘要,具体参数按需读取,减少逐线调用和重复上下文。
|
||||
- Canvas tools can add and wire a node in one call, safely splice a selected branch, and connect unique concrete types by node IDs. Ambiguity/conflicts/cycles are rejected before mutation; failed connections restore previous wiring. Compact receipts, bounded flow chains and brief node search reduce context.
|
||||
- AI 加节点时能插在已有的线前面:`wire_to` 指向的输入原来接着线的,改接新节点,原来的来源接进新节点,比如在采样器前加 ControlNet(正向接正向、反向接反向),或在采样器和两个提示词前加 LoRA,一次调用接好。同类型的几个插口名字相同时自动配对;`insert_after` 会把 LoRA 的模型线和 CLIP 线一起接进去。
|
||||
- add_node splices in front of a node: an input `wire_to` feeds that was already wired now goes through the new node, its old source feeding the new node (a ControlNet before a KSampler, positive to positive and negative to negative; a LoRA before the sampler and both prompts). Same-type sockets of the same name pair up, and `insert_after` splices every type a node passes through (a LoRA's MODEL and CLIP).
|
||||
- 改参数不再靠固定的填空:`run_workflow` 和 `set_values` 都能按参数名改任何节点的参数,第三方节点也一样(比如 `{"steps": 30}`)。只有一个节点有这个参数就直接改;几个节点都有时(高清修复的两个采样器)什么都不改,列出是哪几个,让 AI 写上节点号。几个正向提示词框接进同一个采样器(分区提示词)时,`prompt` 也不再把同一句话填进所有框,而是停下来让 AI 分别写。
|
||||
- Settings go by name instead of a growing list of blanks: run_workflow's and set_values' `values` set any node's setting, a third-party node's too ({"steps": 30}). The one node that has it is changed; a name several nodes have changes nothing and lists them, for the AI to name the node ({"12": {"denoise": 0.5}}). `prompt` no longer gives one text to several positive prompt boxes combined into one sampler (regional prompts).
|
||||
- 一次调用加一组节点:`add_nodes` 按 ComfyUI 的 API 格式写一组节点,组里互相连,也能连画布上已有的节点(`"#12"`),AMB 先检查类型,排成一块放在空位上,接好线,整组算一步撤销。原来要十几次调用的局部重绘分支,现在一次。
|
||||
- add_nodes adds a group of nodes in API form to the open canvas in one call: links inside the group, to canvas nodes as "#12" (outputs by slot or name) and wire_to into canvas nodes, checked first, laid out as one block and undone in one step.
|
||||
- 图跑完后,第一个告诉 AI "跑完了" 的回复(不管是查进度还是别的工具)直接带着这次出的图,最多 4 张缩略图,每次运行只带一次。AI 不用再去取图,看完就能留言。
|
||||
- The answer that first tells the AI its run ended, run_status or any other, carries up to four thumbnails of the run's pictures, once per run, so it need not fetch them with get_image_info.
|
||||
- 跑完叫我:关卡窗口新增“等跑图”,默认“跑完叫我”。AI 提交后最多等 1 分钟(跑得快的当场就拿到结果),还没跑完就告诉你在跑,然后收工,不再一遍遍查进度(以前一张图要查 10 次左右)。跑完后,留言框上方有几句话,点一下复制,粘到 AI 的对话框里,它就接着看结果和图;AI 知道结果后这几句话自动消失。选“AI 自己等”就和以前一样。
|
||||
- Tell AI when done: a new setting in the Gates window, on by default. The AI waits up to a minute after queueing (a quick run comes back with its pictures at once) and, if the run still goes, says so and ends its turn instead of polling run_status. When the run ends, lines over the message box are ready to copy into the AI's chat; they go once the AI knows. "AI waits" keeps the old way.
|
||||
- 工具说明压短:常用工具的说明重写,去掉重复和绕的话,规则和“不要做”的都保留;开始任务时收到的规则从约 1900 字减到约 900 字。
|
||||
- Shorter tool descriptions: the everyday tools' descriptions are rewritten without repeats, keeping their rules; begin_task's rules are about 900 characters instead of 1,900.
|
||||
- 工具目录:AI 开始任务时,回复里附带一张按“想做的事”排好的工具目录(出图、画布、模型、提示词、图片、和你沟通……),AI 看一眼就直接去用对的工具,不用先到处试。目录从工具登记表自动生成,只列当前给 AI 的工具,新工具会自己出现。
|
||||
- A directory of the tools by what the AI wants to do comes with begin_task's answer (and get_situation's while there is no task): each tool offered under its stage of the work with its title, built from the catalog so new tools show up by themselves.
|
||||
- 给 AI 的话常驻在消息框上方:AI 停下来的时候(没在调用工具、没在跑图、没在等你回答),AI 工作台消息框上方总有一行话,点一下就复制,粘到 AI 的对话框里。话跟着情况变:图跑完了是“跑完了,看看结果”这几句;你在 AMB 里回复了、AI 还没收到,是“我在 Anomalous 里回复了,看看再接着做”;别的时候是“继续”。最后一句“用 Anomalous 接着做任务:…”给新开的 AI 对话用。原来挂在跑图那一步下面的几句话挪到了这里。“交给 AI”给你的那句话也不再叫 AI 用旧的 show_panel 问你。
|
||||
- Lines for the AI's chat stay over the message box: whenever the AI is not in a call, a run or a question, the AI workbench offers a line to copy that fits what waits (about the run that ended; "I replied in Anomalous, take a look and go on" when it has not got your reply yet; else "Go on"), and one that picks the task up in a new chat. The lines under a finished run's step moved here. The Hand to AI sentence no longer tells the AI to ask with show_panel.
|
||||
- AI 在 AMB 里问你时至少等 30 秒:就算它自己说不等(Gemini 常这样),你在 AMB 里点了选项,它在同一步里就收到,接着做,不用再回 AI 的对话框说“继续”。“加节点”工具的说明也提醒 AI:要加一组互相连着的节点(重绘、放大一段),用一次调用的“加一组节点”。
|
||||
- ask_user waits at least 30 s even when the AI asks it not to, so an answer given in Anomalous right away reaches the same call and the AI goes on without a word in its chat. add_node's description points to add_nodes for several nodes wired to each other (an inpaint or upscale branch).
|
||||
- 时间线更短:AI 连着做的事(查资料、加节点、连线、写提示词、改参数、记决策)三步以上、其中至少两步不是查资料时,叠成一行“做了 N 步”,下面一排小标签写做了什么(加了节点 ×5、连了线 ×10……),点开就是原来的每一步。行上有“撤销这一串”,一下撤掉这一串在画布上的改动。你的话、AI 留言、问你的、面板、出图、出错的、等你允许的、正在做的一直露在外面。AI 还在做时,这一行写着最后一步是什么。一个修手的任务从 43 行变成 18 行。
|
||||
- A shorter timeline: three or more of the AI's working steps in a row (reads, canvas changes, prompts, settings, decisions; two of them not reads) fold into one row, "Did N steps", with chips of what they did and "Undo these" for their canvas changes; it opens to the steps as before. Your words, the AI's notes and questions, panels, runs, errors, steps waiting for you and running ones stay in sight. While the AI is at it, the row names its last step. A hand-fix task went from 43 rows to 18.
|
||||
- 展台自动打开:AI 往展台放了新面板,右栏自己打开并跳到它,你关掉了或者在看节点结构、决策、素材也一样;AI 只是改已有的面板(比如进度)时不会。展台顶上有开关“有新东西就打开”,默认开着。窗口窄时不抢时间线,只在“展台”标签上亮个点;AI 问你问题时仍然切过去。
|
||||
- The stage opens by itself: a new panel from the AI opens the side column at it, even when you closed it or were on another view; the AI changing a panel (a progress bar) does not. The switch "Open on new panels" in the stage's head turns it off. In a narrow window it lights the stage's tab instead of taking the timeline's place; a question still brings the stage forward.
|
||||
- 打开右栏的按钮更醒目:时间线里每个面板那一行变成卡片,左边小图标表示里面是什么(问题、图、对比、节点结构),标题下一行写内容(“1 张图”“问你 · 2 个选项”),右边是实心按钮“在右侧打开”;右栏正在显示的那个面板,按钮换成绿点“正在右侧显示”。跑图那一步的“在右侧看大图”也换成同样的按钮。“在画布上找到”“撤销”“在图库里看”这些蓝色小字换成带图标的小按钮。
|
||||
- Ways to the right are easier to see: each panel's line in the timeline is a card with an icon of what it holds, a line of what is in it ("1 picture", "Asks you · 2 options") and a solid "Show on the right" button, which turns into a green "On the right now" while that panel is in view there. A run's "See it large on the right" is the same button. "Find on canvas", "Undo" and "See it in the gallery" are small buttons with icons instead of blue text.
|
||||
|
||||
- 修复任务画布标签关闭或切换失败后误用当前工作流的问题:操作会明确报错,重新打开或明确指定新画布后才能继续。下载缺失模型现在也跟随任务画布;留言里的 `@` 搭配和工作流按完整名称查找,不再因模糊搜索前几项占满而误报缺失。
|
||||
- Task canvas actions now fail when their recorded tab is closed or cannot be activated instead of using another workflow. Missing-model downloads follow the task tab, and message references resolve exact combo/workflow names before search limits.
|
||||
|
||||
- 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.
|
||||
|
||||
### 右边一栏一次一样,按钮固定在右上角 / One view at a time on the right, switched from the top right
|
||||
- 工作台右边那一栏一次只显示一样东西,用窗口右上角的三个按钮切换:**展台**、**节点结构**(AI 跑过的节点结构图,大图,可以选看哪一次运行)、**素材**(原来停在屏幕右边的素材小窗,现在就在这一栏里)。再点一下亮着的按钮收起这一栏;切走再切回来,内容都还在。时间线里每次运行都有 **在右侧看大图**。
|
||||
- 任务标题和那一排按钮在时间线往下翻时一直停在顶上。
|
||||
- 右边这一栏改成一张单独的卡片,不再用一条线硬隔开;默认窄一些,给中间的时间线留出地方。它左边的缝里有三个小点,鼠标移上去变成一条竖线,拖动调宽窄(记得住,双击恢复)。这条缝和时间线的滚动条分开,不会再一抓就抓错。
|
||||
- 左边的任务列表用窗口左上角的按钮收起 / 展开;收起后鼠标移到按钮上,列表会临时浮出来,点任务或移开就收回。
|
||||
- 素材在右边这一栏里时,鼠标移过卡片不再自动跳进详细说明(之前很难操作),点卡片上的 ⓘ 才看。
|
||||
- 左边任务列表收起后,鼠标从按钮往下移去点任务时列表不会再中途消失(之前移得慢一点就会收回):鼠标在按钮和列表围成的范围里都算还在用,离开后才收回。
|
||||
- The folded task list no longer vanishes on the way down from its button: it stays while the pointer is anywhere in the box around the button and the list, however slowly it moves.
|
||||
- **模型补丁不再被当成 ControlNet**:Anima LLLite、Qwen-Image 分块 ControlNet、Z-Image Fun ControlNet 这类文件名字里常带 ControlNet,但只有「加载模型补丁」节点能读,它只看 `model_patches` 文件夹。现在拖进模型页会自动放进 `model_patches`;已经放在 `controlnet` 里的,模型页的“整理”会提示挪过去。
|
||||
- Model patches (Anima LLLite, the Qwen-Image block-wise and Z-Image Fun ControlNets) are told from ControlNets by their header: imported into `model_patches`, and Tidy offers to move those already in `controlnet`.
|
||||
- **AI 找模型时直接知道该用哪个节点**:`search_models` / `get_model` 给每个模型写上能加载它的节点;放错文件夹的会说该放哪、放在这里加载不了,同名文件已经在对的地方时会直接指给 AI。新工具 `move_model` 把放错的模型挪过去(新关卡“挪动模型文件”,默认先问你;从不覆盖文件)。AI 不必再去命令行翻 ComfyUI 的源码。AI 加节点时如果下拉列表是空的,会被告知“ComfyUI 没找到文件,可能放错了文件夹”。
|
||||
- search_models and get_model say which node types load each model, and where a misfiled one belongs; `move_model` moves it there behind a new "files" gate (asks by default, never overwrites). An empty drop-down in add_node now says ComfyUI found no file for it.
|
||||
- AI 第一次在任务的副本标签页上动手时,切换标签页不再被记成“你在画布上的改动”告诉 AI。
|
||||
- The AI switching to its task tab is no longer reported to it as a canvas change of the user's.
|
||||
- **工作台里直接用图库的图**:留言框左边新增图片按钮,打开图库(最新的在前,可按提示词、模型、seed 搜索),点选几张后 **加进留言**;打 **@** 时也能看到图库最新的图,打字会顺带在图库里搜。AI 收到的是图片本身和它的生成参数。
|
||||
- Gallery pictures in the AI workbench: the message box's picture button picks them from the gallery (newest first, searchable), and "@" offers the gallery's newest pictures and searches it as you type; the AI gets each picture with what made it.
|
||||
- **等 AI 接手时先备好素材**:“等 AI 接手”的任务是一页:最上面是 **把这句话发给你的 AI** 和 **复制这句话**,往下滚时它停在顶上,挑完图不用翻回去;下面的 **写一句要做什么**、**从图库挑图给 AI**(大图库可搜索,点一张就放到最下面的留言框,再点一下拿掉)都是可选的,点了才展开,行上写着已经放了几张;备好的都列在下面。AI 接手时一起收到。那句话改成让 AI 照任务里的交代做,没有交代才问你,不会一接手就停下来问。
|
||||
- A task waiting for an AI is one page: **Send this to your AI** with **Copy** leads and stays at the top while the page scrolls; **Say what to do** and **Pictures from the gallery** (a big searchable grid feeding the message box) are optional folds that open when pressed and say how many pictures are in; what is ready is listed under them. The sentence now has the AI do what the task says, asking only when it says nothing.
|
||||
- **不全屏时中间更宽敞**:AI 工作台的窗口不够高时(没有最大化),任务标题、状态、时间步数、“接着”和右边的工具并成一行,上下留白也收紧;输入框下面的提示只在出错时显示(平时鼠标停在输入框上能看到);等 AI 接手的页面图片变小、步骤靠紧。同样的窗口里,中间能多显示约两成内容;最大化时样子不变。
|
||||
- In a low window (not maximised) the AI workbench keeps the task's head to one line, tightens the room around it, shows the message box's hint only for a failure (its words are the input's tooltip) and shrinks the waiting page's picture: about a fifth more room for the timeline.
|
||||
- **换类型**:模型详情页多了 **换类型** 按钮,给“导入时类型选错了 / 代码认不出、放错了文件夹”的模型用。窗口里写明现在放在哪一类、文件头看起来像什么(看得出就替你选好并标“推荐”,不确定标“可能是”,看不出就不猜),每一类都写着用哪个节点加载;可以改挪到的根目录和子文件夹,下面显示完整路径。挪动和“整理”用的是同一套:封面、信息、备注一起走,从不覆盖文件,挪完 ComfyUI 的模型列表马上更新。导入单个模型后,提示里也有 **类型选错了?**,点一下直接改。
|
||||
- "Change type" on a model's page, and "Wrong type?" in the toast after importing one model: the window says where it is and what its header suggests (picked and marked when it can tell, nothing guessed when it cannot), offers every type with its loader and where it goes, and moves it as Tidy does.
|
||||
- 跨盘挪动、复制模型时,除了比大小,还会比对文件开头、中间、结尾各 1 MB 是否一模一样,对不上就不删原文件(只多花几毫秒,不需要事先有指纹)。
|
||||
- A model copied to another drive is checked at its start, middle and end (a megabyte each) besides its size before the original goes; no fingerprint needed, no felt cost.
|
||||
- 挪动、导入模型时,就算目标位置在最后一刻冒出一个同名文件,也绝不会被盖掉:这次挪动会失败,两边的文件都原样保留(之前在 Windows 上理论上有极小的机会覆盖)。
|
||||
- Moving or importing a model never replaces a file that took the name at the last moment: the move fails and both files stay (on Windows a rename could replace it before).
|
||||
- **AI 能放大看图、看预览,不用再自己写代码**:新工具 `zoom_image` 把一张图的某一块放大给 AI 看,还能把两张图的同一块并排比较(改之前和改之后);输出、输入、临时预览的图都能看,只看不改。出图结果里会列出这次运行的预览图(比如 OpenPose 骨骼、遮罩的预览),AI 用它们就能检查中间结果,不必自己去跑预处理器。AI 开任务、收到遮罩时都会被明确告知:要改图就跑 ComfyUI 的工作流,绝不用自己的代码去画、去改、去覆盖图片文件。
|
||||
- zoom_image shows part of a picture larger (output, input or temp folder), with the same part of a second picture to compare, reading only; run_status lists a run's PreviewImage pictures; begin_task and the repaint note tell the AI to change pictures only by running workflows, never by editing picture files with its own code.
|
||||
- **AI 能直接看输入文件夹里的图**:`get_image_info` 加了 `folder=input`,能看 ComfyUI 输入文件夹里的图(你标注时 AMB 存的重绘遮罩图就在那里):尺寸、要重画的区域在哪、占多大,也能附上图本身。交给 AI 遮罩图时也明说“它已经在输入文件夹里了,不用跑命令检查”,Gemini 不会再为这个弹窗问你。
|
||||
- get_image_info with folder=input reads a picture in ComfyUI's input folder (the inpaint picture Anomalous writes for a repaint): its size and the area LoadImage gives as MASK, optionally attached; the marks message says the inpaint picture is in place, so the AI need not check it with a command.
|
||||
- **AI 少去翻文件**:AMB 工具返回给 AI 的内容去掉了多余的空格、画布里 ComfyUI 自己的内部字段不再列出、操作记录默认只给最近 10 条,这样 Antigravity 不必把结果另存成文件再让 Gemini 去读。AI 开始任务时也会被告知:模型、图片、节点、画布和运行都有 AMB 的工具,不要自己去读 ComfyUI 的文件夹、源码或接口(那样每一步都要你点允许)。
|
||||
- Tool results reach the AI as compact JSON, without the canvas's internal "$$" widgets, and recent_activity gives the last 10 by default, so apps such as Antigravity keep more of them in the chat instead of a file the AI must open; begin_task tells the AI that AMB's tools cover models, pictures, nodes, canvas and runs, so it need not read ComfyUI's folders, source or routes.
|
||||
- 修复输入框上方的出图进度停在“节点 0/29 0%”不动的问题:现在它和时间线里那一步、标题栏的进度按钮一起更新。
|
||||
- The run line over the message box now follows the run's progress with the timeline's bar and the header's button, instead of staying at the start.
|
||||
- **最下面就能看到 AI 在做什么、要你做什么**:输入框上方多了一条“现在”栏:AI 出图时显示进度(节点、步数、百分比、进度条)和停止按钮;AI 某一步还在进行时说在做什么;AI 想做要你点头的事(比如挪模型、下载)时,直接在这里 **允许 / 不要**;AI 问你问题时,问题和选项就在这里,点一下就回答(有推荐的带 ☆,也能自己写一句或跳过),多选和挑图会带你去展台。
|
||||
- A "now" strip over the message box: a run's progress with stop, the AI's step still going, a gate to allow or refuse right there, and the AI's open question with its options as buttons (one's own words or skip too; several picks and pictures to pick lead to the stage).
|
||||
- **拖进来、粘贴进来的图片也能发给 AI**:把电脑里的图片拖到输入框上,或者直接 Ctrl+V 粘贴截图,它会存进 ComfyUI 输出文件夹里的 `ai_inbox`(图库里也看得到,从不覆盖文件,只收 PNG、JPEG、WebP、GIF,最大 25 MB),并加到这条消息上。从 ComfyUI 页面里拖来的输出图片直接加上,不另存。
|
||||
- Pictures dropped on the message box or pasted are kept in the output folder's `ai_inbox` (seen in the gallery; never over a file; PNG, JPEG, WebP or GIF up to 25 MB) and attached; an output picture dragged from ComfyUI's page is attached as it is.
|
||||
- **很长的 AI 留言先折叠**:超过八行左右的留言只显示开头,点 **展开全文** 看全部。
|
||||
- Long AI notes are folded to their first lines with "Show all".
|
||||
- **输入框固定在最下面,像聊天软件一样**:不管时间线多长、翻到哪里,留言框都停在任务这一栏的最下方。它上面是这条消息要带的东西:图片显示成小图,每张有 **✎ 标注**(画框、圈、写要改哪里,标好后单独发给 AI)和 **×**;@ 提到的模型、搭配显示成小标签。从图库挑的图不再往文字里塞“@文件名”。左边是 **图库** 和 **素材** 两个按钮,给了东西就亮起并显示数量。输入框会随内容变高:Enter 发送,Shift+Enter 换行;只发图片不写字也可以。往上翻了一屏以上,或者往上翻着看时 AI 有了新步骤,输入框右上方会出现圆形 **↓** 按钮(有新步骤时角上带数字),点一下回到最新;自己发出的消息会自动滚到眼前。时间线里你发的图片和 AI 出的图一样,可以查看、对比、收藏、标注。
|
||||
- The message box is docked at the foot of the task column like a chat app's: over the input what the message carries (pictures with ✎ to mark up and ×, chips for what "@" names; gallery pictures are attached, not typed in), beside it the gallery and materials buttons with their counts, an input that grows (Enter sends, Shift+Enter breaks the line; pictures alone are fine), a round ↓ button back to the newest step (with the count of new ones) when you read more than a screen up or the AI adds steps meanwhile, and your own message scrolled into view. Pictures you sent carry the same tools as the AI's. The side column's grip sits in its own gap, three dots, beside the timeline's scrollbar instead of over it.
|
||||
- **打开任务就停在最新一步**:像聊天软件一样,打开任务直接看到最新的进展。你停在最下面时,AI 的新步骤来了就跟着往下走,不管任务现在是什么状态(之前只在 AI 正在干活时才跟,打开任务时还会停在上一个任务翻到的位置);往上翻着看就不动你。
|
||||
- A task opens at its newest step; read at the foot, the timeline follows new steps whatever the task's state (it used to follow only while the AI worked, and opened where the previous task was scrolled to); read further up, it stays put.
|
||||
- **面板上的进度条不再冒充实时进度**:AI 画在面板上的进度条是它自己写的数,不会自己动。任务往前走了(那次出图结束了、AI 又画了新面板或留了言)而它没去更新,这条进度条会变成灰色,并写明“这是 AI 在几点写下的数,之后没有再更新”。出图的真实进度一直在时间线那一步、输入框上方和标题栏的运行状态里。AI 也会被告知:出图不用另画进度条,自己画的要自己更新。
|
||||
- A panel's progress bar is the AI's own numbers: once the task goes past it without an update (a run ends, a later panel or note), the stage greys it and says when it was written. The AI is told a run's real progress shows already and to keep its own bars current.
|
||||
- **还没出图就问进度不再算“出错”**:AI 在出图前调用 `run_status`,现在得到“这个任务还没有出图”的回答,任务不会因此标上“有一步出错”。
|
||||
- run_status before any run answers state "none" instead of failing, so the task no longer shows a failed step for it.
|
||||
- **宽窗口里时间线收在中间**:右边一栏关着、窗口又很宽时,时间线、标题和输入框不再横跨整个窗口,而是收成中间约 880 像素宽的一列(像聊天软件那样),滚动条仍在最右边。时间线里的图片按自己的大小从左排开,一张图不再被撑成横跨整行的大灰框。
|
||||
- **AI 每一步都知道下一步做什么**:开始任务、出图和看进度、加节点连线、问你问题得到回答,这些回复里都多了一句 `next`:比如“还在跑,等 55 秒再看”“跑完了,先放大看图再告诉你结果”“还缺这几根线,照清单连”“缺模型,先查缺哪个”。小模型不擅长自己规划,但很会照着做。
|
||||
- Answers of begin_task, run_workflow and run_status, the canvas changes and an answered ask_user carry next: the one thing to do now.
|
||||
- **照着跑:日常出图一步完成**:AI 出图时可以直接说“用哪个存好的工作流、改什么”:`run_workflow` 带上工作流名,再带上要改的提示词、反向提示词、种子、尺寸或一张图(比如你标注过的重绘图),AMB 就在任务的副本标签页里打开它(第一次打开,之后接着用同一个标签页),填好这几处,其余照存好的样子不动,然后出图、等结果。尺寸只改起始的空白潜空间,高清修复里的 Latent 放大保持原来的放大尺寸。原来要 6、7 步(找工作流、打开、看画布、改提示词、改种子……),现在一步,小模型也不容易绕晕。填的内容和出图一起算一步,改画布那部分同样能 Ctrl+Z;如果你给“改画布”设的关卡比“用显卡”严,这一步按改画布的关卡来问你。画布报错是因为缺模型时,“现状一览”会让 AI 先查缺哪个模型,而不是乱换选项。
|
||||
- run_workflow takes blanks for a daily run in one call: workflow (a saved one, opened in the task's tab once and reused), prompt, negative, seed, width, height and image (image_node for several LoadImage nodes); the rest stays as saved. The size goes to the latent a run starts from, never to a hires fix's latent upscale. With blanks a run asks as the stricter of the GPU's and the canvas's gate.
|
||||
- **现状一览,AI 一次就知道到哪一步了**:新工具 `get_situation`,AI 调一次就拿到:任务(你最后说了什么、有几个问题在等你、定了哪些决策、出过哪些图、“AMB 之外”几次)、画布简报(每个节点一行:谁接到谁;**还缺哪几根线、各要什么类型、画布上哪些现有输出能接上**;ComfyUI 报错的节点)、最近一次出图,以及**下一步该做什么**。回复控制在约 3600 字以内,Antigravity 不会把它存成文件再让 AI 去读。新开对话或拿不准时先调它;连接说明里的那句话和规则也写上了。小模型(比如 Gemini Flash)不用再把整张图记在脑子里。
|
||||
- `get_situation`: the task, the canvas in brief (a line per node, the inputs still unwired with the outputs that fit, ComfyUI's errors), the latest run and the next step in one answer under about 3,600 characters, for a new chat or an AI that lost track.
|
||||
- **AI 每连一根线都知道还缺什么**:`add_node`、`connect_nodes`、`remove_nodes` 的回复里附上画布上还没接的输入,以及哪些现有输出能接上,像一张清单,照着补完就能跑。
|
||||
- add_node, connect_nodes and remove_nodes answer with the inputs still unwired and what fits them.
|
||||
- **AMB 之外做的事也看得见**:AMB 只看得到 AI 发给它的调用,AI 软件自己跑的命令和脚本它看不到。现在 AI 每调用一次 AMB,AMB 就看一眼输出和输入文件夹:上次调用以来有没有不是出图产生的新图片、有没有图片被原地改写(比如之前那张被覆盖了 6 次的图)。有的话,时间线上多一行橙色的 **AMB 之外**,列出是哪些文件,输出文件夹里的图直接显示;AI 同时收到提醒:可能是你自己做的,如果是它做的,改图只能跑工作流。只在 AI 调用时看,不在后台扫描;ComfyUI 正在出图时,新图等出图结束再判断,不会把正常出的图当成外面来的。AI 也多了一个工具 `log_outside`:用了别的工具(命令、脚本、读写文件)后说清做了什么、为什么、动了哪些文件,同样显示为一行 **AMB 之外**;任务的统计里会写“AMB 之外 N 次”。
|
||||
- Pictures changed outside Anomalous show in the timeline: when the AI calls, Anomalous looks at the top of the output and input folders for pictures no run saved and pictures rewritten in place since its previous call, and tells the AI with its answer; new pictures wait while ComfyUI runs a prompt. `log_outside` is the AI's own word on a command or script it ran. Both are "Outside AMB" steps, counted on the task.
|
||||
- **连接说明里多了一段给 AI 的规则**:第 ④ 步可以复制一段规则(先用 AMB 的工具、只通过跑工作流改图、在 AMB 里问和说明、用了别的工具就说一声),放进 AI 软件的“规则”里(Antigravity:Customizations → Rules → + Global),它每次对话都会读到。
|
||||
- The connect guide has rules to paste into the AI app's own rules: use AMB's tools, change pictures only by running workflows, ask and explain in AMB, and say what was done outside it.
|
||||
- In a wide window the task (its head, timeline and message box) keeps to a centred column of about 880 px, as chat apps do; the scrollbar stays at the edge. A step's pictures are cards of about their own size from the left, no longer one stretched across the row.
|
||||
- **点得到、看得见**:选中的图片有粗框、发光和大对勾,图片罩上一层颜色,和鼠标停留一眼分得开;留言框左边的图片按钮在图库面板打开时亮起,留言里有图时显示张数。素材列表、AI“看了资料”那一行(带 › 可展开)、关卡选项、优先用 / 只能用、来源下拉、发送和复制按钮、模型页的标签、首页的“一键准备”、记录里可展开的一行,鼠标移上去都有明显的变化。点 **展台** 时即使还没有内容,也会打开并说明会放什么,不再没反应。“交给 AI · 0”在什么都没选时不能点。挪模型那一步的标题写文件名和挪到哪,不再是一串编号;只改了一个节点时不再出现两个“在画布上找到”;“接着哪个任务…”的字号和旁边一致。
|
||||
- Clearer feedback: picked pictures get a thick glowing frame, a tint and a big tick; the picture button lights while its panel is open and counts the message's pictures; materials rows, the folded reads row (now with a ›), gate options, the mode switch, the source menu, send and copy buttons, the models page's tabs, the home page's set-up button and expandable activity rows all react clearly to the pointer. The Stage button opens an empty stage that says what will appear there; "Hand to AI · 0" is disabled with nothing picked; move_model's step names the file and where it goes; a one-node canvas change shows one "find on canvas"; the "Goes on from…" chip matches the size around it.
|
||||
- 右边那一栏的宽度把手更好抓了:卡片左边整条空隙(从上到下)都能拖,把手平时就淡淡地显示,鼠标靠近时整条边亮起;点一下后也能用 ← → 调宽窄。
|
||||
- The side column's grip catches the pointer across the whole gap left of the card, top to bottom, shows faintly at rest and lights the edge when pointed at; focused, ← → resize it.
|
||||
- **节点结构的简单看法**:除了完整的方块图,现在默认是一张一眼能看懂的说明:每一段采样用哪个模型加哪些 LoRA(带权重)、正负提示词、从空白画布还是从上一段的结果开始(用哪个放大模型、缩放多少),最后保存在哪;和上一段一样的就写“和第 1 段一样”。点任意一项定位到画布上的节点;“简单 / 完整”随时切换,AMB 记得你的选择。
|
||||
- The run map has a Simple view (now the default): per sampler, the model and LoRAs feeding it, its prompts, where it starts (an empty canvas, a picture, or the part before, upscaled) and where it is saved, "same as part N" when nothing changed; Simple / Full is kept on this browser.
|
||||
- **删掉任务里的图以后**:时间线、本次成果、展台上的那张图显示成“图片已删除”的占位(鼠标停上去说明可在回收站找回),不再是破图,也点不开;@ 列表里不再出现它。AI 那边会收到“你删除了这张图,别再用它”;它要是还去读,会被告知“可能已被用户删除”。
|
||||
- A task's pictures deleted since show as "Picture deleted" instead of a broken picture, and "@" no longer offers them; the AI hears plainly that the user deleted one, and a tool reading a gone picture says it may have been deleted.
|
||||
- **关联任务**:从一个任务新开的任务会记住它“接着”哪个任务(AI 开的新任务、你看着某个任务时点的“新任务”都会)。标题下面显示“接着:…”“后续:…”,点一下跳过去;任务列表里后续任务带 ↳。可以解开,也可以自己选“接着哪个任务…”。AI 接手后续任务时会先收到上一个任务的决策。
|
||||
- Tasks remember the task they go on from (an AI's new task while on another, or one the user starts from a task): links under the title lead both ways, the list marks follow-ups, the user can link or unlink, and an AI beginning a follow-up hears what was decided before.
|
||||
- **决策记录**:聊天在哪里都行,结论留在 AMB。AI 用新工具 `log_decision` 把商量好的结论记成时间线上的 📌 卡片(定了什么、为什么、没选哪些、谁定的、跟哪几步有关);你在 AMB 里的选择(面板回答、允许或不允许、交出去的素材、AI 改画布的地方、停止)自动算作决策。右上角新增 **决策**,按时间列出全部决策,点一下回到那一步;AI 很久没记录时会轻轻提醒。AI 重新接手任务时会先收到这些决策。
|
||||
- Key decisions stay in Anomalous: the AI records what was settled with `log_decision` (a card on the timeline: what, why, what was left out, who decided, the steps it is about); the user's own choices in Anomalous count as decisions too; a Decisions view in the side column lists them all with a link to each step, notes work done since the last one, and begin_task repeats them to an AI picking the task up.
|
||||
- **留言框里的 @ 和 /**:打 **@** 弹出能搜索的列表(这个任务的图、搭配、模型、工作流、提示词、节点包、节点),选中的写成“@名字”并在下面显示成小标签,AI 收到的是确切的那个东西(图片连同生成参数一起)。开头打 **/** 是快捷指令:停止、撤销、展台、节点结构、素材、副本、当前画布、新任务马上生效;重跑、解释、继续帮你写好一句话。
|
||||
- "@" in the AI workbench's message box names the task's pictures, combos, models, workflows, prompts, node packs and node types; the AI gets each in full with the message (pictures attached). "/" lists commands that act at once (stop, undo, side views, new task, canvas mode) or fill in words to send.
|
||||
- **整个自定义节点包交给 AI**:素材里点“节点”,再在来源那一排点某个自定义节点包,上面会出现“整包交给 AI”。不知道该选哪个节点时,把整个包给它,AI 自己从包里挑;选“只能用”时,这个包里的节点都允许,别的第三方和付费节点照样拦下。AI 用 `get_toolbox` 能看到包里有哪些节点,`find_nodes` 搜索时也把它们排在前面。
|
||||
- A whole custom node pack can be picked (from its source in the nodes' second column): the AI gets its node types in get_toolbox, finds them first, and with "only" every node of the pack is allowed.
|
||||
- 小窗里的“来源”不再是一条要横着拖的长条,改成一个“来源:全部 ▾”按钮:点开是一张列表(来源多时上面有搜索框),点一个就选上并收起;Esc 或点别处也收起。
|
||||
- In the side panel the sources (or types) are one "Source: All ▾" button opening a list to pick from, with a search when it is long, instead of a strip to scroll sideways.
|
||||
- 素材在右边这一栏(小窗)里时改成一行一个的列表:左边是图标或小封面,中间是名字和来源,右边是勾选;搜索框和“全部 / 已选 / 常用”并成一行。同样的高度能看到的条目多了好几倍;展开成大窗口还是原来的卡片。
|
||||
- In the side panel the materials picker lists one line per item (badge or small cover, name, where it is from, the tick) and puts the search and the filters on one line; the large window keeps the cards.
|
||||
- AI 工作台和素材窗口里的滚动条变细,平时看不见,鼠标移到能滚动的那一块上才显出来。
|
||||
- Scrollbars on the AI page and in the materials picker are thin and show only while the pointer is over what scrolls.
|
||||
- The side column is a card of its own, narrower by default and resizable from its left edge (kept; double-click resets). The task list folds from a title-bar button and peeks over the page while pointed at. In the docked materials picker, hovering a card no longer opens its detail; its ⓘ does.
|
||||
- The AI workbench's side column shows one view at a time, switched from the window's title bar: the stage, a run's node map at full size (pick the run), or the materials picker docked there. Pressing the shown one hides the column; each view keeps its state. Each run's step has "See it large". The task's header stays on top while the timeline scrolls.
|
||||
|
||||
### 回答发出去了没有,一眼看清 / See at a glance whether the AI has your answer
|
||||
- 面板上每个回答、你的留言、批注、发给 AI 的图片下面都有醒目的状态标签:琥珀色 **已发送 · 等 AI 来取**,AI 拿到后变成绿色 **AI 已收到 · 时间**(取代原来那行灰色小字)。鼠标停在“已发送”上会提示 AI 停下时该怎么办。
|
||||
- 面板上的问题可以点 **这题跳过**:可选的问题不再一直显示“等你回答”,AI 会收到“你跳过了”,自己拿主意。
|
||||
- Each answer, message, set of marks and picture handed to the AI shows a pill: "Sent · waiting for the AI", then "The AI has it" with the time. Panel questions can be skipped; the AI reads `skipped: true`.
|
||||
|
||||
### 看得见 AI 跑了什么、AI 改副本、图片直接给 AI / What the AI ran, a copy for the AI, pictures straight to the AI
|
||||
- 每次运行下面都有 **节点结构**:一行写明底模、几个 LoRA、尺寸、步数,点开是方块图(LoRA 带权重、正负提示词、采样器设置……,点方块定位到画布上的节点),还用黄色小块标出 **比上次改了什么**,比如“步数 20 → 30”“LoRA 权重 0.65 → 0.8”“新增「加载LoRA」”。图是 AMB 按 ComfyUI 实际收到的工作流画的,不靠 AI 描述;AI 讲解时用新工具 `show_run_map` 一次把它放上展台。
|
||||
- **AI 改副本**(默认开):AI 第一次改画布前,AMB 把你正开着的工作流复制到以任务命名的新标签页(副本),之后它只在副本里改和运行;你切去别处,它下次动手前会切回来;你原来的工作流一笔不动。任务标题栏的“AI 改副本 ▾”能换成“改当前画布”,“切到副本”跳过去;关卡窗口里设定新任务的默认做法。
|
||||
- **图片直接发给 AI**:图库右键“发给 AI”或看图窗口的 ✦,可以发给 AI 正在做的任务(也能开新任务),可附一句话。AI 收到图片本身和从图里读出的模型、LoRA、提示词、尺寸、采样设置。AI 搜图库时也能让结果直接带上前 6 张图。
|
||||
- Each run shows the shape ComfyUI actually ran (boxes, links, settings; boxes locate their nodes) and what changed since the run before; `show_run_map` puts it on the stage. By default the AI works in a tab of the task's own: the open workflow is copied before its first change and brought back before later ones, the user's tab untouched (per task, and a default in the Gates window). Output pictures can be handed to the task an AI is on (gallery menu, inspector): the AI gets them attached with what made them; `search_images(with_pictures)` attaches the first six.
|
||||
|
||||
### 运行时看得更清楚,也能一键停 / 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.
|
||||
|
||||
### 局部重绘:涂一涂就交给 AI / Repaint an area: paint it, hand it to the AI
|
||||
- 标注窗口多了“▧ 涂重绘区”笔刷(粗细可调):涂上要重画的地方(框、圈、画笔圈的地方也算),选“只改标出的地方”发给 AI,AMB 就在 ComfyUI 的 input 文件夹里做好一张带遮罩的原图(`anomalous_inpaint_….png`),AI 用 LoadImage 读进来就能局部重绘;AI 同时收到怎么接、重绘强度怎么选的说明。时间线上你的标注下面写着“已做成重绘遮罩”。AI 搭工作流时如果刚生成的文件还不在下拉列表里,AMB 会自动刷新一次列表。
|
||||
- The marker's repaint brush and "only the marked areas": the picture with the painted area as its alpha goes to ComfyUI's input folder for LoadImage, and the AI is told how to inpaint with it. Canvas tools refresh ComfyUI's lists once when a choice is missing.
|
||||
|
||||
### AI 的问题在 AMB 里问 / The AI asks in Anomalous
|
||||
- 你在图上标注或给 AI 留言后,AI 的分析和修改方案也回在 AMB 里:它的留言会写明“回复第几步”,你那条标注下面出现“AI 已回复 · 第几步”,两边点一下就互相跳过去。AI 的留言现在有排版:小标题、列表、加粗、`代码` 都会正常显示(只认这几种写法,不会运行网页代码)。AMB 告诉 AI:收到标注和留言就在 AMB 里回复,要你选修法就用选择题问。
|
||||
- The AI replies in Anomalous: `report` takes `reply_to` (the user's message or marks), both steps link to each other; its notes are laid out (headings, lists, bold, code) with text only.
|
||||
- AI 接了 AMB 的任务以后,要你做选择(比如“先测哪种题材?”)不再弹在 AI 软件的对话框里,而是出现在 AI 工作台(展台上有提示,你不在工作台时右下角也会提醒):几个选项按钮,AI 推荐的那个标着“推荐”,每个选项可以带一句说明;下面还有一行“都不是?写下你的想法”。你点了或写了,AI 在同一步里马上拿到答案,接着干活。AMB 告诉 AI 的说明里写明了:干 AMB 的活时,问题都这样问。
|
||||
- New `ask_user` tool: a question with options (one may be marked recommended) and a line for an answer of the user's own, shown in the AI workbench; the AI waits for the answer in the same call. The server instructions tell AIs to ask this way, not in their own chat.
|
||||
|
||||
### AI 工作台换新外观 / A new look for the AI workbench
|
||||
- 左边任务栏:“新任务”成了醒目的主按钮,“连接 AI”和“关卡”变成带图标的按钮;下面写着最近连接的是哪个 AI、什么时候;有任务在进行、等你回答或出错时,上面出现对应的筛选按钮和数量;每个任务前的状态换成清楚的图标,选中的任务左边有一条色条。
|
||||
- 任务标题栏:状态标签带图标;“素材”“全部撤销”“显示展台”是统一样式的按钮,“改名”和“从列表移除”换成铅笔和垃圾桶图标(鼠标移上去有说明,删除悬停时变红)。
|
||||
- 等 AI 接手的任务:左边一张插图,右边标题;下面是三个带编号、连成一条线的步骤(想让 AI 做什么 → 把这句话发给 AI,大号“复制这句话”按钮 → AI 的每一步会出现在这里,附淡淡的示意);最下面“第一次用?先把 AI 连上 AMB”是一张带插图的卡片,点“查看连接方法”就在原地展开。没有任何任务时的欢迎页也换成插图加同样的卡片。插图是用 ChatGPT 画的。
|
||||
- The task list leads with a New task button, icon buttons for Connect an AI and the gates, which AI came last, quick filters by state and state icons; a task's header uses icon buttons; a waiting task shows a picture, three numbered steps and a connect card that opens in place; the empty page matches. Illustrations made with ChatGPT.
|
||||
|
||||
### 给 AI 的素材 / Materials for the AI
|
||||
- AI 工作台每个任务上多了“素材”按钮:按来源分组列出这台 ComfyUI 的全部节点(自带的、你装的每个第三方节点包、只在网页里的小工具、会花积分的付费 API 节点单独放在最后),还有你的搭配、工作流、提示词和模型。勾选这次想让 AI 用的,点“交给 AI”;可以选“优先用这些”或“只能用这些节点”(自带节点照常可用,AI 想加别的第三方或付费节点会被拦下)。☆ 设为常用后,没挑素材的任务自动带上常用的。AI 下一步就会收到,时间线上也记着你给了什么、AI 收到没有。
|
||||
- AI 能在新标签页里打开你存的工作流,在它的基础上改;工作流详情页也有“✦ 交给 AI”。AI 查节点时会标出你挑的节点和付费节点。
|
||||
- 素材窗口重做:大窗口,顶部一个搜索框搜所有素材(按 / 直接搜),左边分类显示各自挑了几个、搜到几个;节点可以按来源筛选,每个第三方节点包有自己的彩色徽标;节点卡片画成一个迷你节点,输入输出的颜色和 ComfyUI 画布上一样,写着有几个参数;搭配、工作流、模型显示封面;提示词显示原文,并且能直接挑搭配和工作流里的提示词。鼠标移到卡片上,右边显示完整说明(节点的每个输入、参数、输出,提示词全文)。下面一排是挑好的素材,带缩略图,可以一个个去掉;“优先用 / 只能用”做成了开关。图标和节点包徽标是矢量画的,跟着深浅色主题变;空白时的工具箱插图,以及没有封面的搭配、工作流、模型用的占位封面,是用 ChatGPT 画的透明背景插图(每张几十 KB)。
|
||||
- 打开不用等了:节点列表直接用网页里已有的节点信息,其他素材一次读完、提前读好、留一分钟,打开窗口时先显示留着的,再在后台刷新。
|
||||
- 实测:你装的 10 个第三方节点包(Impact Pack、IPAdapter、Easy-Use、Comfyroll、rgthree、ControlNet 预处理器等)共 1642 种节点,AI 都能放到画布上;用 Comfyroll 节点搭的工作流真实跑出了图。
|
||||
- A Materials button on each AI task: every node type of this ComfyUI grouped by source (built-in, each custom node pack, page-only helpers, paid API nodes last), plus combos, workflows, prompts and models; picks go to the AI as prefer or only-these-nodes (built-in nodes stay allowed), favourites stand in for tasks without picks. The AI can open a saved workflow in a new tab; a workflow's page can hand it to the AI.
|
||||
|
||||
### AI 后端修复 / AI backend fixes
|
||||
- 出图结果按本次提交返回的准确运行 ID 归属,不再通过全局队列差集猜测;其他 AI、其他页面或后续手动排队不会混入。批次只提交成功一部分时,保留这些运行的进度和图片,并明确报告未全部提交;不会自动重跑整批。旧页面需刷新后才能接手出图,不支持请求 ID 的旧版 ComfyUI 前端会在提交前提示更新。同一页面正在提交时另一 AI 收到忙碌提示,待提交结束即可重试。
|
||||
- Workflow runs now follow exact submission IDs in batch order. Partial submissions keep accepted runs/results and report the missing portion; no automatic resubmission. Stale pages cannot claim runs until refreshed, unsupported host frontends fail before queueing, and a second AI waits while the same page is submitting. Native widget/seed behavior is preserved and later manual batches do not delay the AI receipt.
|
||||
- “允许一次”只用于一次调用,不会被相同参数的后续调用重复使用;无法建立确认记录时不会直接执行。修复确认超时后用户刚好回答、工具返回又覆盖答案的问题;重启后的待确认步骤仍能把后续答案交给 AI,回答也会唤醒正在等待的 AI。
|
||||
- 指定任务或会话读取留言时,不再取走同一 AI 客户端其他任务的留言、标记和回答。
|
||||
- 没找到排队记录、无法读取运行记录、跟踪超时均明确标为未完成,不再误报完成或永久显示运行中;批次内出错及时记录,批次进度保留总数。跟踪停止不会取消 ComfyUI 实际运行。
|
||||
- 标图的校验和保存移到工作线程;跨盘图片路径返回校验错误,错误类型的 MCP 工具名返回协议错误。
|
||||
- One-shot approvals cannot be reused, missing approval records fail closed, and timeout/restart races preserve user decisions and wake waiting readers. Task/session input stays isolated. Missing or unreadable run history and monitoring timeouts no longer imply success or leave receipts running forever. Marked-image validation/storage runs off the event loop; malformed image paths and tool names return controlled errors.
|
||||
|
||||
### AI 工作台与插件能力融合 / Integrated task results
|
||||
- 工作台的出图结果和 AI 展台图片:鼠标移到图片上,右上角出现 ⓘ 图片与参数、⇄ 和当前画布对比、☆ 保存为(同一个保存菜单存成工作流、搭配和提示词),不再在每张图下面排三个大按钮。左上角可以勾选:勾两张(不同步骤、不同任务都行),页面底部出现“查看参数差异”,用插件现有的参数对比看真实差异;勾第三张会替换最早那张。“本次成果”直接显示缩略图,不用再点开。视频和临时文件没有这些参数操作,缩略图按需加载。
|
||||
- AI 干活时你在 AMB 里做的改动(画布改动、改模型信息、存搭配和提示词等)会作为“你在 AMB 里改了”出现在同一条时间线上,画布改动附带修改前后;AI 下一次调用任何工具时会收到这条改动,并被告知保留它,不要改回去;时间线上写着 AI 是否已经知道。操作记录页里 AI 做的、以及 AI 干活期间你做的记录,旁边有“AI 任务 →”可以跳到对应任务。
|
||||
- ComfyUI 在 AI 某一步进行到一半时重启,这一步不再永远显示“正在执行…”,而是标成没有结束。
|
||||
- **AI 软件里直接显示 AMB 卡片**(MCP Apps,Claude、ChatGPT、VS Code 等支持的软件):AI 开始任务、出图、画面板、留言时,对话里会出现一张 AMB 卡片,显示它在做哪几步、最新的几张图,和它问你的问题。在卡片里点选项就算回答,AI 马上收到并接着做。为了安全,“关卡”只能在 AMB 里点允许,卡片里只提示你去 AMB。Gemini 命令行和 Claude Code 终端版不显示卡片,照常用 AMB 的工作台。
|
||||
- **在图上标给 AI 看**:AI 工作台里的图片,鼠标移上去多了一个 ✎。点开后可以用框、圈、箭头、画笔(红黄青三种颜色)在图上标出要改的地方,每个标记写一句“这里要怎么改”,再写整体的想法,点“发给 AI”。AI 下一步就会收到这些说明和这张标好的图(能看图的 AI 能直接看到标在哪),时间线上也会显示你标的图和说明。标好的图存在 AMB 自己的记录里,不会混进图库。
|
||||
- **运行进度 AI 也看得见**:AI 让工作流开始运行后,时间线上那一步下面有进度条:排队时写“前面还有几个”,运行时写正在跑哪个节点、第几步(比如 采样器 14/28)和节点做完了几个。AI 多了一个 `run_status` 工具,可以随时问进度,也可以等到跑完(每次最多等 55 秒),跑完直接拿到出的图;运行的回复里也会提醒它用这个,别凭时间猜有没有跑完。进度条右边显示百分比(和 ComfyUI 浏览器标签上的一样:正在跑的节点走到第几步)。AI 不用自己去问:运行期间它调用任何工具,回复里都附带一句当前进度(比如“采样器 14/28,50%,节点 3/9,已经 42 秒,还没跑完”),跑完后的下一次回复会告诉它跑完了、出了哪几张图,只说一次。
|
||||
- **提示词改动一眼看清**:AI(或你自己)改了提示词时,记录里先列出“去掉 N 个”(红色划掉)和“加上 N 个”(绿色)的词,点“看词义”用 AMB 的翻译在每个词下面显示中文意思;“看完整的修改前 / 后”里,两段全文中变化的词也标了颜色,没变的变暗。其他文字改动会把不同的那一段标出来。操作记录里提示词改动最多保留 2000 字(以前 400 字),整段提示词都能对比,不会把被截断的半个词算成改动。
|
||||
- **等 AI 接手的任务**重新排版:中间一个输入框写想让 AI 做什么(就是任务名),下面是要发给 AI 的那句话和“复制这句话”,再加三个小步骤;第一次连接 AI 的说明收进“AI 还没连上 AMB?”里,不再整屏铺开。等 AI 的时候不显示留言框。
|
||||
- **交给 AI**:模型页、图片详情(右上角 ✦)和搭配卡片上多了“交给 AI”。写一句想让 AI 做什么(已经帮你填好,比如“用这个模型出一张图”),AMB 会建好一个带着这个模型、图片或搭配的任务,再给你一句话贴给 AI;AI 接手时会直接拿到它们,不用再自己去找。
|
||||
- **斜杠命令**(MCP prompts):在 Claude 等软件里可以直接选“在工作台开一个任务”“出几张让我挑”“补齐缺的模型”“讲讲现在的画布”,AI 会按适合在工作台里看的方式去做。
|
||||
- **流程图**:任务标题下面一排“模型 › 搭配 › 提示词 › 运行 › 挑图”,按 AI 做事的先后排列,每段标着做完了、正在做、等你点头或出了问题;点一下跳到时间线上那一步。
|
||||
- **关卡**:任务列表上方的“关卡”里,改画布、用显卡出图配音、下载模型、联网扫描四类事,各自可以选“直接做 / 先问我 / 不许做”(默认只有下载要先问)。选了“先问我”,AI 的这一步会停在时间线上,列出它要做什么,等你点“允许”或“不要”(可以写上原因或想怎么改,AI 会收到);勾上“以后直接做”就不再问。你不在工作台时右下角会提示“AI 在问你”。AI 等不及先走了也没关系:你回答后它下一步会收到,允许的话再做一次,不会问第二遍。
|
||||
- 画布操作直接显示记录中的“修改前 / 修改后”、新增或删除的节点;与操作记录页共用展示,保留定位和适用时的撤销。旧记录缺失时明确提示,不用当前画布猜历史。任务页更新隔离迟到请求,并整理了结果卡片、时间线和窄布局。
|
||||
- 修复参数对比窗口遮挡撤销提示的问题,撤销按钮现在可直接点击。
|
||||
- Task outputs and AI panel pictures now use the existing image inspector, Keep menu and image/canvas parameter comparison, as tools over each picture on hover; tick any two pictures (any step or task) and compare their settings from the bar at the foot of the page. The results strip shows thumbnails straight away. Changes you make in Anomalous while an AI works join its timeline, and the AI is told of them (to keep, not undo) with its next call; activity entries link to their AI task. Steps a restart interrupted are marked as not finished instead of running forever. In apps that support MCP Apps (Claude, ChatGPT, VS Code…) an Anomalous card in the conversation shows the task's steps, latest pictures and the AI's questions, answered with a click (gates are only allowed in Anomalous). Four ready-made prompts appear as slash commands. "Hand to AI" on a model's page, an image's inspector and a combo card starts a task carrying it, with a sentence to send the AI. A flow over each task shows its stages (models, combo, prompt, run, pictures) in order with their state. Gates: changing the canvas, using the GPU, downloading and scanning online can each go ahead, ask you first or never happen (downloads ask by default); a step that asks waits on the timeline with what it would do, for Allow or No (with your reason for the AI). Canvas changes share recorded before/after receipts with the activity page. Unavailable history is explicit; stale task responses cannot replace a newer selection. Refined result cards and timeline styling, and fixed Undo toasts being blocked by the comparison overlay.
|
||||
|
||||
### Fixes / 修复
|
||||
- AI 工作台与 MCP 调用实时生命周期及消息送达:每个工具调用开始时立即写入“正在执行…”步骤并接手等待中的任务,完成后原位更新同一步骤(不再等到耗时操作结束后才显示,也不会重复生成步骤);用户在工作台留下的消息和展台面板回答,除了可通过 `get_answers` 读取外,还会在 AI 下一次成功调用任意工具时自动附带送达一次,并立即在界面上更新为“AI 已经看到”;多会话与显式 `task` ID 互不串任务。
|
||||
- Live in-flight MCP step lifecycle and automatic delivery of user messages and panel answers: every tool call now starts a `"running"` step immediately and updates the same step in place on completion or failure; unread user messages and panel answers are piggybacked once onto the next successful MCP tool response (in addition to `get_answers`) and immediately notify the workbench UI; concurrent sessions and explicit `task` ids stay isolated.
|
||||
- 展台内容按出现顺序保留:`update_panel` 新增的成图、说明等接在已有内容下面,即使 AI 把整张看板按“结果在前”重发,也不会倒置旧内容。省略旧内容不再将它删除;重复提交不重复追加。明确指定积木 ID 时仍可原位更新进度、问题或修正内容,已有回答保留。超出 30 块时提示新开面板,不截掉旧内容。旧记录保留其已保存的排版。
|
||||
- Panel updates append new output after what the user already saw, preserve omitted blocks, and avoid duplicating unchanged resubmissions. Stable block ids allow deliberate in-place edits; invalid or over-limit updates leave the panel untouched. Historical records keep their saved layout.
|
||||
- 展台右上角增加“关闭展台”,关闭后时间线展开;任务标题旁的“打开展台”或时间线里的面板入口可重新打开,保留面板和未提交的输入。当前任务更新不会强行重开。面板编号改为“来自第 N 步”,明确它的创建位置。The stage can be closed and reopened without losing panels or drafts; step labels now say “From step N”.
|
||||
- AI 工作台:AI 先查询再调用 `begin_task`,现在会继续已接手的任务,保留用户标题和之前的步骤;重复调用不会拆任务。另一件事可用 `new_task=true` 明确新建,已结束或长时间闲置的任务仍分开记录。
|
||||
- 展台打开历史任务时定位到底部;新增面板、更新最新面板及图片加载后继续跟随底部。往上翻看时保留位置,用“查看最新”回到底部,时间线的定位链接仍跳到对应面板开头。
|
||||
- The workbench keeps preliminary reads and repeated `begin_task` calls in the active task; `new_task=true` explicitly starts separate work. The stage opens at the bottom, follows updates and loaded images while there, and preserves the reader's position when browsing earlier content.
|
||||
|
||||
### ✨ AI workbench: see everything the AI did, open it, undo it (AI 工作台)
|
||||
- **A new page on the rail, *AI workbench***: every step an AI app (Claude Code, Cursor, Cherry Studio… connected through Anomalous's MCP) takes is kept, grouped into **tasks**. The AI names a task when it starts (a new `begin_task` tool); otherwise one app's calls that follow each other make one task.
|
||||
- **A task is a timeline**: what it looked up (folded into one line, a click shows each), what it changed on the canvas, the workflow runs, downloads, scans and spoken lines, and the AI's own notes (a new `report` tool, also for pictures it made another way). Each step says when, how long it took, and why it failed when it did. Steps are **numbered in order** (1, 2, 3… down the line), each dot in its kind's colour; while the AI works, the next number waits at the foot.
|
||||
- **What it made stays, and leads there**: the pictures a run made are kept with the task (watched until the run ends) and shown on top as *What it made*; a click opens the picture, a model opens its page, a combo opens the combo, a spoken line plays, a canvas change is found on the canvas or **undone** (also all of a task's canvas changes at once, stopping at anything you changed since).
|
||||
- **Live**: a task the AI is still working on grows by itself and says *The AI is working…*; the page follows the newest task unless you picked another. While you are elsewhere, the rail's *AI workbench* gets a dot. Home gets *What the AI did lately*.
|
||||
- **New task (新任务)**: start a task yourself (with what the AI should do, or empty): it waits for an AI (*Waiting for an AI*), and the next AI app that calls takes it over, so its work starts there instead of joining its last task.
|
||||
- **Connect an AI (连接 AI)**: always beside the task list, and on an empty page or a waiting task: Anomalous's address, a sentence to copy to your AI so it adds the server itself (and starts on the waiting task), and the way for Claude Code, Gemini CLI, Cursor, VS Code and Cherry Studio.
|
||||
- **Write to the AI from the workbench** (给 AI 留言): a box under a task's timeline. The message becomes a step of the task (*You*) and reaches the AI the next time it asks (`get_answers`, which now also brings the user's messages, and with no panel named waits for one); it says whether the AI has seen it. Anomalous cannot wake the AI: when it has stopped, the box says to ask in the AI app.
|
||||
- The task list is grouped by day (today, yesterday, a date), each task with an icon for where it stands: working ●, waiting for you ?, a step failed !, done ✓.
|
||||
- **The stage (展台)**: the AI's panels are shown in a column on the right, beside the timeline, so they stay in view however many steps come after them; the timeline keeps one line per panel (*… on the stage ▸*), which brings the stage to it. Like the timeline it reads from the top down: the task's panels in the order they came, each with its step number, **the newest at the bottom**; a new one scrolls the stage down to it while you are at the bottom, otherwise *↓ New panel* says it came. The strip of what a task made also runs oldest to newest, left to right. In a window too narrow for three columns, the timeline and the stage are two tabs (the stage tab lights up when a question waits, and comes forward when one arrives). The task list can be folded away (‹) for more room.
|
||||
- While the AI works, only the steps that changed are drawn again: a diagram or a strip of pictures scrolled sideways no longer jumps back, and text half-typed into a panel or the message box stays.
|
||||
- Diagrams read in order: a box that only feeds one further on (a prompt into the sampler) stands right before it instead of at the far left with an arrow across everything, and boxes in a column are sorted by what feeds them, so arrows cross less.
|
||||
- Tasks can be renamed or removed from the list (only the record goes: pictures and the canvas stay). The newest 300 are kept, in ComfyUI's user folder. Without any AI connected, the page shows the address to add in the AI app.
|
||||
- Only what goes through Anomalous's MCP is seen; the AI can report the rest. The rail now has ten entries: the number keys 1–9 open the first nine.
|
||||
|
||||
### 🧩 The AI draws panels, and asks you on them (AI 画面板,在面板上问你)
|
||||
- **A new `show_panel` tool**: the AI lays out a panel in its task, in Anomalous's own style, from fixed blocks: headings and text (also as a tip or a warning), **pictures** (a row, a grid, or two **side by side to compare**), **your models as cards with their covers** (a click opens them), tables, lists, settings, a progress bar.
|
||||
- **It can ask**: buttons (one pick or several) and a line to type into. Your answer is handed over and waits for the AI, which reads it with the new `get_answers` (it can wait up to a minute for you); each question says whether the AI has seen your answer yet. `update_panel` changes a panel (a progress bar going up) or closes its questions.
|
||||
- **Safe by design**: nothing the AI sends is run or read as markup. Every block is checked on the server (only these kinds, plain text of bounded length, pictures only from ComfyUI's output folder, models only from your folders) and written into the page as text.
|
||||
- When the AI shows a panel while the browser is elsewhere, a small card in the corner says so (*The AI asks you: …*, **Have a look**); a task with a question waiting says *Waiting for you* in the list.
|
||||
|
||||
### 🎞️ The AI's stage: marks on pictures, before / after, picks, diagrams (AI 展台)
|
||||
- **Marks on a picture**: the AI circles or boxes places on a picture it made (*① the fingers are fixed*, *② softer light*); each mark has its number, the labels are listed under the picture, and a click on one **zooms the picture there** (again: back).
|
||||
- **Before / after slider**: two pictures over each other, a line to drag across to compare the details.
|
||||
- **Picks**: pictures side by side, each with the AI's reason and **Take this one** (the choice goes to the AI); the AI can mark the one it recommends. Each can also be **put on the canvas** (its saved workflow opens in a new tab, as dropping the picture on ComfyUI does) or dragged onto the canvas.
|
||||
- **Diagrams**: boxes and arrows, laid out by Anomalous (in columns or rows), with a label on an arrow; a box naming a canvas node (◎) finds it on the canvas.
|
||||
- The AI only sends data (places as fractions of the picture, what connects to what); every shape and line is drawn by Anomalous, so it looks the same whichever AI is connected, and no drawing code of the AI's is taken.
|
||||
|
||||
## v1.61.1 (Delete a Character) — 2026-10-28
|
||||
|
||||
- 角色卡上加了橘色的 **删除角色** 按钮(Anomalous_TTS 议题 #2):确认以后,角色的整个文件夹(模型、参考音频、设置)移到系统回收站,需要时能从回收站还原;U 盘、网络盘上没有回收站就不删。设置坏了、读不出来的角色也能删。需要 Anomalous_TTS v1.1.1;旧版 TTS 不显示这个按钮,其他照常。
|
||||
- Character cards have an orange Delete character button (Anomalous_TTS issue #2): after a confirm, the character's whole folder (models, reference audio, settings) goes to the Recycle Bin, where it can be restored; on a drive without one nothing is deleted. Broken characters can be deleted too. Needs Anomalous_TTS v1.1.1; with an older one the button is not shown and nothing else changes.
|
||||
|
||||
## v1.61.0 (Quick Search, Canvas Menu, Combos From Pictures, Tidier Model Folders, Multi-Character Voice-over) — 2026-10-25
|
||||
|
||||
### 🔎 Quick search on the canvas: Alt+B (画布快速搜索)
|
||||
- **Press Alt+B over the canvas** (or right-click the empty canvas → AMB → *Quick search…*) and type: combos, models and saved prompts that match show at once, with their pictures. A model is found by its name, file, base model or trigger words; a combo by its name, models or prompt; a prompt by its text.
|
||||
- **Enter puts it where your pointer was**: a combo as its group of nodes and a model in its loader node, both following the pointer until you click; a prompt as a new prompt node. ↑ ↓ pick, **Tab** shows only combos, models or prompts, Esc closes.
|
||||
- The shortcut is a ComfyUI command (*AMB quick search*), so it can be changed in ComfyUI's keybinding settings like any other. Nothing is read until the bar opens.
|
||||
|
||||
### 🖱️ AMB in the canvas's right-click menu (画布右键菜单)
|
||||
- **Right-click a node → AMB** offers what Current node does for it, without opening the browser first: **Open in AMB** (a prompt node opens Prompts on it, any other node Current node), **Change model by picture**, **Insert a LoRA before / after**, **Apply saved values**, **Pictures made with this model**, **Model details**, **Save this node's values** and **Save as combo**. Each entry shows only where it applies: a checkpoint loader has no "before", a prompt node no model entries.
|
||||
- **Right-click the empty canvas → AMB** opens any of AMB's pages.
|
||||
- **Make it yours: Settings → Canvas → Right-click menu**. Switch each entry on or off and move it up or down; a line above each list shows what the menu will offer. Two cards, each with a small drawing of the menu, choose whether AMB's entries sit **together under "AMB"** (the default, ComfyUI's menu gets one line more) or are **listed in the menu**. *Adjust this menu…* at the end of the submenu leads there. **Save as combo** moved into the AMB submenu with the rest.
|
||||
- In the model picker, Esc now closes the picker wherever the focus is, and only the picker: it used to close the browser behind it when the search box had lost the focus.
|
||||
- The floating AMB button no longer sits on the head of a model picker opened from this menu (with the browser closed).
|
||||
- Fixed: when a model folder is a link (a junction to another drive, say), a combo kept from a picture and other lookups of saved model paths got a wrong place for the model, or failed.
|
||||
|
||||
### 🧩 A combo's page, redone (搭配详情重做)
|
||||
- **A head that says what it is**: the picture (the one you kept it from, with a **See the picture** button; else the main model's cover), the name in large type with a **✎ Rename** button beside it, and chips for its base model, main model and LoRAs. **Put on canvas** and More (delete) sit under them.
|
||||
- **Only what is chosen**: the main model and the LoRAs are short rows with their covers; **Change** and **+ Add a LoRA** open the grid of compatible models only when you want it, instead of every model of that base filling the page. ✕ takes a LoRA out; the numbers say their order.
|
||||
- **No Save button**: every change (models, name, prompt) saves itself, as it already did. Fixed: a tag changed in the bilingual view was not saved into the prompt.
|
||||
|
||||
### ★ See only the kept pictures (图库只看收藏)
|
||||
- **★ Kept** beside the gallery's search lists only the pictures you kept as a workflow, combo or prompt (the ★ on their cards). Works with the search and with Pictures / Videos. Pointing at a card's ★ says what it was kept as.
|
||||
|
||||
### 📌 A picture kept as a combo keeps its nodes as they were (图片存搭配,节点原样记住)
|
||||
- **Keep → Its combo** now keeps the nodes that made the picture's model and prompts: the model and CLIP loaders, the whole LoRA chain (Power Lora Loader and other packs' nodes too), CLIP skip and model patches, the positive **and** negative prompt nodes, with every value as the picture had it. The sampler, size and decoding are left out, so Put on canvas gives a group you wire to your own sampler, like a combo saved from the canvas.
|
||||
- It used to keep only the main model, the LoRAs' names and the positive prompt: LoRA strengths came back as 1.0, and the negative prompt and CLIP skip were lost. **A combo only remembers; it never changes a value** (now one of the plugin's rules). Combos kept before stay as they are.
|
||||
- Models are named by where they are on this computer; one this computer lacks is kept by the picture's name and said after keeping. A workflow without a sampler to read from still gives the simple kind.
|
||||
- Fixed: after a picture was kept (as a workflow, combo or prompt) or its parameters compared, the prompt boxes of its workflow could stay on ComfyUI's page and show at its top left, over the tabs. The workflow read off the picture is now taken away once it is read.
|
||||
|
||||
### 🩺 Model Check and ComfyUI-Manager's list (模型检查与 Manager 清单)
|
||||
- A **Download by name** from ComfyUI-Manager's model list now also says where the list's file is (the site and repository), to help you decide.
|
||||
- A list entry that does not say its type is no longer offered for a node whose type is known.
|
||||
- Without ComfyUI Manager, Model Check says it would add one more place to download from when a model's source is not found.
|
||||
- A foundation component (VAE, text encoder, CLIP Vision…) no source was found for gets **Search Hugging Face** (its mirror when that is on): the search page for its file name, for you to pick from.
|
||||
- The rules of Model Check (what counts as the same model, the confidence levels, foundation components, download sources and where ComfyUI-Manager fits) are now written in one place: `docs/guides/model-check-rules.md`.
|
||||
|
||||
### ⭐ Combos kept from a picture are easy to tell apart (图片存的搭配一眼认得出)
|
||||
- **The card shows the picture you kept it from**, with the main model's cover as a small round badge in its corner, instead of the model's cover alone (several combos of one model used to look the same).
|
||||
- **A name that says which one it is**: the first LoRA (or the main model) and the prompt's first two telling tags, like *Ahri · fox ears, kimono*, skipping words nearly every picture has (*1girl*, *solo*, *looking at viewer*…).
|
||||
- **Rename it right away**: the message after keeping shows the picture and the name in a box; type a new one and press Enter. A combo's page has a Rename button too.
|
||||
|
||||
### 📒 Prompt cards look like notebook pages (提示词卡片换成笔记本样式)
|
||||
- In Prompt Studio each card is a page with a bound spine in its colour (green positive, red negative), its title, a Positive / Negative label and its first words as small tags instead of a cut-off line of text. The card open as the reference wears a ribbon; Common and My prompts are index tabs with how many cards they hold. Dark and light themes alike.
|
||||
|
||||
### 🗂️ Tidier model folders (模型文件夹更清爽)
|
||||
- **Settings → Models → Files that come with models**: what a scan found (`.info`), your names and notes (`.anomalous.json`) and Civitai's picture kept aside can go **in a hidden `.amb` folder** inside each model folder instead of beside every model, leaving only the model and its cover there. Covers stay beside the model, where ComfyUI and other plugins look. Choosing a place says how many files will move, then moves them (only moves; switch back any time); a file that cannot move still works where it is.
|
||||
- **No more duplicate cover copies**: scans used to keep a copy of Civitai's picture identical to the cover for every model. Now the cover is noted as Civitai's and a copy is kept only when you put your own cover in its place (so you can still bring Civitai's back). **Check and tidy** in the same place sends the existing duplicates to the Recycle Bin.
|
||||
|
||||
### 🎭 Several characters in one script (多角色配音)
|
||||
- **Every line has its character**: on Voice-over, click a character in the list on the left, then paste: the lines are its. To give lines to another, pick them (a click; Ctrl / ⌘ adds one, Shift a run; Esc lets go) and choose who reads them on the right, or click the name on a line for a menu of characters. Each line shows who reads it in that character's colour, and its emotions are that character's. The *Who speaks* bar at the top is gone: the list does its job.
|
||||
- **Three layers: the whole script, a character, a line (整份剧本 / 角色 / 句子,三层分清)**: the left side picks, the side panel changes. Nothing picked: the **whole script**, what everyone reads with, and each character in it. A character clicked in the list: that character **in this script** (only its lines). Picked lines: **only them**, with who reads them. A path on top, *Whole script › Aris › Line 2*, says which layer you are changing and leads back up (Esc too). Every layer has the same three rows, speed, tone and language, and each row says where its value comes from: **this line** (yellow), **the character** (purple), **the whole script** or **the default** (grey), or the character's **usual** setting; a click on it goes to that layer, ↺ lets this layer follow the one above. The more particular wins: a line over its character over the whole script over the usual settings. The settings that last beyond this script (the tones, a character's own settings) are links at the foot, apart from the rest. The tone can be heard on that line right there; *Numbers* shows how it compares and its numbers. With many characters, *Read by* lists the ones in this script (with their colours) and the rest under *Other characters*, a menu with a search box; a card's name menu gets the same search box.
|
||||
- **Drafts (草稿箱)**: every script is kept as a draft on its own, half a second after a change, in ComfyUI's user folder (so clearing the browser or using another one loses nothing). A bar over the script names it (a click renames it; unnamed, its first line names it) and has **New** (a blank script, this one stays), **Drafts** and **Close script**. Drafts shows a card per script, the last changed first, with who reads it and how many lines: open, rename, copy, delete. Deleted scripts wait in *Recently deleted* for 30 days. A reload opens the script that was open; a file voiced again from the audio gallery becomes a script of its own. Two tabs saving at once keep each other's drafts. The script the earlier 1.61 build kept in the browser moves in by itself.
|
||||
- Editing the script (*Edit text*) keeps each unchanged line's own settings; it used to drop them.
|
||||
- A character with no line in the script yet (clicked in the list) hears a **sample sentence** on ▶, and the panel now says so (*▶ Listen to a sample*); it used to look like a line that was not in the script. On the whole script, ▶ reads the line heard with its own character.
|
||||
- **Plainer words (更好懂)**: the mode is now the **tone** (语气), the presets are **Steady / Natural / Lively** (平稳 / 自然 / 生动), *Follow* is *As the character* (同角色), *Save as default* is *Always read like this* (以后都这样念), a character's *Generation settings* are its *Own settings* (专属设置), and the GPT-SoVITS pretrained files are *base files* (基础文件). The names inside the node (stable / standard / lively) are unchanged, so saved workflows and settings keep working.
|
||||
- **GPT-SoVITS Settings, in tabs (GPT-SoVITS 设置分类)**: a chip beside the title says everything is ready, or what is missing; tabs show one part at a time: **Tones** (which one to use, with a *Simple / Numbers* switch), **Other languages**, **Base files** (a dot when something there needs you, and the window opens there), **Where things are**, **Version**.
|
||||
- So one script can have a calm narrator, a lively character and one line read slower, all in one go. This needs Anomalous_TTS interface 17 (1.1.0): the whole script's values go into the node's own inputs, and only the lines that differ get `[lang:…]`, `[speed:…]` or `[preset:…]`, so the text pushed or copied to a node stays readable; a file voiced again from the audio gallery comes back with them. With an older Anomalous_TTS the whole script is read as before, as its first line's character reads, and the panel says so.
|
||||
- **Picking and editing no longer get in each other's way**: a click on a line picks it; a double-click (or ✎) edits its text in place, Enter keeps it and Esc puts it back.
|
||||
- **The list on the left is the cast there**: narrower, the characters with their colours only; a click there never takes you back to the Characters page.
|
||||
- Generating, ▶ on a line, pushing, dragging and copying all follow who reads each line: the script carries `[name]` where the speaker changes (Anomalous_TTS reads it already), and in Auto mode each character uses its own generation settings. A file voiced again from the audio gallery comes back with its speakers.
|
||||
- The character cards' **Voice-over** button is gone: the switch at the top of the page and the cast do its job.
|
||||
|
||||
## v1.60.2 (Fix: the browser opens whatever the plugin folder is called) — 2026-10-25
|
||||
|
||||
### 💬 From a Bilibili comment (来自 B 站评论)
|
||||
- **The browser opens when the plugin folder has another name (插件文件夹改了名也能打开)**: a plugin unpacked from a ZIP of GitHub sits in `Anomalous_Model_Browser-main`; the browser's look did not load there, so a click on **Model Browser** seemed to do nothing (the browser lay under the canvas, without its look) and that button showed in the top bar although the floating button was chosen. The plugin now finds its files under whatever name its folder has. Updates to the plugin also reach the page at once there again, as with the usual name.
|
||||
|
||||
|
||||
## v1.60.1 (Cloud Servers, Generation Presets, Full Window, Faster Scan Page) — 2026-10-22
|
||||
|
||||
### 🎚️ Reference audio: every clip in a list, main and auxiliary references (参考音频:全部列出,主参考和副参考)
|
||||
- **Reference audio and emotions** (the character card's button, formerly *Edit emotions*) is now a large window. On top, the **voices**: the main voice, each emotion with how many references it has (*1 main, 2 aux*), and **+ New emotion**. Below, **every clip** of the character, one row each: ▶ to hear it, its file and length, its line, and where else it is used (*Main voice · aux*). For the voice picked, a row is its **Main** reference or one of its **Aux** references, or nothing; unused clips just stay in the list. Filters (*All*, *This voice*, *In use*, *No line*) and a search by line or file name; the first 60 rows, then more.
|
||||
- **Auxiliary references (副参考音频)**: a voice keeps one main reference (its tone and way of speaking, 3–10 s, with a line) and can take any number of auxiliary ones, whose timbre is averaged in when it speaks, as GPT-SoVITS does with several references; they need no line. Only v1 / v2 / v2Pro models use them (the controls say so for v3 / v4).
|
||||
- **A clip's line can be fixed right in its row** (改参考台词): the fixed line is kept in the character's settings and used before its .txt / .list files, which stay as they are. A line that is missing shows *No line found: click to type it*.
|
||||
- **🗑 on a row moves that clip to the Recycle Bin** (删除参考音频), with its own .txt / .lab, after saying where it is used; it is taken out of those voices and the settings. Never a permanent delete: where there is no Recycle Bin (a USB stick, a network drive) nothing is deleted and the window says so.
|
||||
- Clips too long or too short to be a main reference are marked (they can still be auxiliary). The main voice can go back to the node's own pick; an emotion can be renamed or deleted (one found from a file name is changed by renaming the file). Closing with unsaved changes asks first.
|
||||
- Auxiliary references, fixed lines, lengths and 🗑 need Anomalous_TTS interface 18 (1.1.0); with an older version the window works as before for main references and says what needs the update.
|
||||
|
||||
### 🔍 Scanning (扫描)
|
||||
- **The scan page opens at once, also with many models**: it shows the last counts right away, says it is updating them, and shows the fresh ones when they come. What each model file's header says is now kept on disk (`user/anomalous/model_heads.json`, by file, read again only when the file changes), so a big library or a network drive is not read again after every restart either; requests that come while the page counts share that one count.
|
||||
- **See the models found on Civitai**: the scan page's "Found on Civitai" count opens a list of them, with each model's Civitai name and base model, a box to find one by name, and a way to open each.
|
||||
- **Before an online scan, the plugin checks whether Civitai can be reached**, on both its addresses. When it cannot, it asks instead of waiting on every model: **Scan from the files** (base models found, no covers or trigger words; a later scan fills them in), **Set a proxy** (opens Settings → Models → Network), or Cancel. A check that reached Civitai is trusted for five minutes.
|
||||
- **Civitai's other address (civitai.red) when civitai.com does not answer**: some networks reach only the second, a cloud server in mainland China among them (tested on AutoDL: civitai.com timed out, civitai.red answered in about a second). Scans, import and Model Check's lookups and **Settings → Network → Test** ask civitai.red then, so models still get their names, trigger words and base models; the test says which address answered. The covers are on Civitai's picture server, which such networks often cannot reach either: after two covers in a row that would not download, a scan fetches no more covers instead of waiting on each.
|
||||
|
||||
### 🧩 Combos (搭配)
|
||||
- **A combo's models are cover cards**, as in the model library: the cover on top, the name below. In a combo saved from picked nodes, the slot (Main model, LoRA…) is written on the cover and a click on the card changes the model; in a default combo, the main models and LoRAs to pick from are cover cards side by side. They used to be rows as wide as the window, with **Change** far from the name (模型卡片不再横向拉满整个窗口).
|
||||
|
||||
### 🎙️ Character voices (角色语音)
|
||||
- **Update Anomalous_TTS from GPT-SoVITS Settings (在 GPT-SoVITS 设置里更新 Anomalous_TTS)**: a new *Anomalous_TTS version* line shows the installed version; **Check for updates** looks up its published stable releases (only then does it go online), **Update to v…** switches to the newest one and offers to restart ComfyUI, and **Back to v…** undoes it. Anomalous_TTS now publishes numbered stable releases (the first is v1.0.0), so an update never lands on unfinished work. Only an Anomalous_TTS installed with git from its GitHub repository is changed (as ComfyUI Manager installs it), only from the computer ComfyUI runs on, and never over files you changed. When an import needs a newer Anomalous_TTS, the message points here.
|
||||
|
||||
- **Generation presets (生成预设)**: GPT-SoVITS Settings has three presets, **Steady** (fewer slips, flatter), **Standard** (the old default) and **Lively** (livelier, slips now and then): pick the default one for every character, and one for each pair of languages, such as a Japanese character speaking Chinese (until now every such line was made steadier without asking; that is the rule you start with). With nothing changed, voices sound exactly as before.
|
||||
- **Presets you can hear and compare (听得到、比得出的预设)**: pick a preset and see where it sits between *steadier* and *livelier* next to your default (a ring for the default, a dot for the one picked), with a sentence such as "a little livelier than your default (Standard)". **▶ Listen to this one** has a character read a line with that preset; every take uses the same seed, so only the preset changes, and the presets already heard stay as buttons to click back and forth. Folded under it are the numbers as bars against your default, the way the Current node card shows a change (old → new, ↑ ↓); each has its plain name and the usual one beside it (`top_k`, `top_p`, `temperature`, `repetition_penalty`), so values from the GPT-SoVITS WebUI are easy to match. Click a number to change it; a changed built-in number shows its built-in value, and **Reset** puts it back. **Make default** sets the preset every character uses. **+ New** makes your own preset from a copy of the one picked, with a name of your own (such as 念旁白 for narration); it can be picked everywhere the built-in ones can, renamed, and deleted (the question says what uses it; those places go back to the general setting).
|
||||
- **Modes on Voice-over (配音时直接选模式)**: under *How it is read*, **Auto** (the presets and the character's own settings decide, line by line) or any preset for the whole script, shown against Auto the same way and heard from the **▶** on each line: it reads that line in the mode picked, and the modes already heard on it stay on the side as buttons to compare. The scale and the numbers fold away there, so the narrow side stays short. A preset's numbers can be changed right there and are saved into that preset. Links lead to the presets and to the character's generation settings. This replaces the folded fine-tuning numbers, which showed the node's defaults rather than what the presets used; **Save as default** now keeps the language and speed only.
|
||||
- **A character's own generation settings (角色特调)**: a new **Generation settings** button on each character card sets the preset and speed for the character as a whole and for each language it speaks, with the numbers themselves when a preset is not enough. Under each row are the numbers its lines will get, blue where they differ from the general presets. The node follows these settings too, so a workflow in ComfyUI sounds like generating here; numbers changed on the node itself still go first. Needs Anomalous_TTS v1.1.0 (update it from the same dialog).
|
||||
|
||||
- **More room on Voice-over (配音页更宽敞)**: each line is one row (number, text, its buttons), so more lines fit; a bar between the lines and the generating side can be dragged to share the width (remembered; double-click for the default, about half each); *Into your own workflow* is folded to one line; who reads and their voices share one row, and the how-to line shows only until there are lines. When the side is wide enough, the modes' scale and numbers are shown open.
|
||||
|
||||
### 🪟 The window (窗口)
|
||||
- **Full window (全屏)**: a new button beside *Dock* makes the plugin fill the whole ComfyUI page, and turns it back; a double-click on the title bar does the same. It is remembered.
|
||||
|
||||
### ☁️ ComfyUI on a cloud server (云服务器上使用)
|
||||
- **How far it goes**: fully supported on overseas cloud servers (RunPod…). On servers in mainland China (AutoDL…) only in part: browsing, offline scans and tidying work, but Civitai is mostly out of reach there, so lookups are unreliable and covers rarely download (海外服务器完整支持;国内服务器只能部分支持). Not fully tested yet: one AutoDL server so far, no overseas one. The cloud guide ends with a section for AI assistants (what is going on, the steps, how to check, what to do when it did not work, what to tell the user about safety), so a user can hand the link to their AI.
|
||||
- On a rented cloud server (AutoDL, RunPod…) the page is always opened from another device, so since v1.58.1 the plugin only let it look: scans, downloads, moves and saves were refused. Now there are two ways: open ComfyUI through an **SSH tunnel** (the address starts with 127.0.0.1; it counts as the computer ComfyUI runs on, nothing to change), or make an empty file named **`allow_remote_changes`** in the plugin folder on the server and restart ComfyUI. With the file, every device that can open the page may change things; the page can neither make nor remove it, and the MCP endpoint stays for this computer only. How, and the risk: [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
|
||||
- The **View only** chip beside the page title can be pressed: it says what is refused and the two ways above, with a **Copy** button for the one line to paste into the server's terminal (the plugin's real path on that server already filled in), so there is no folder to find. With the file there it reads **Remote changes on**, in yellow, and its dialog gives the line that turns it off again.
|
||||
- **Models that are links (模型是快捷方式/链接时)**: on a cloud server such as AutoDL every model in the models folder is a link into the platform's shared model disk. Scans wrote the info beside the link, but the cards read it from the disk behind it: a scanned model showed as not scanned, under the disk's file name (`6b4679…`) instead of its own, and editing, deleting, opening its folder or finding it from a saved workflow could be refused as "outside the models folder". A linked model is now taken as named, with its covers and info beside the link; edits, renames and deletes act on the link, never on the shared disk; moving one in Tidy moves the link instead of copying the model disk's file onto the server. Names leaving the folder (`..`) are refused as before.
|
||||
- **Models linked from another tool keep their info**: a model that is a link with no info beside it shows the info beside the file it links to (A1111's Civitai Helper leaves `.civitai.info` there). Only read there; covers, edits and scans stay beside the link.
|
||||
|
||||
## v1.60.0 (Model Import, Tidy, Parameter Compare, Workflows Rebuilt, Base Model Filter, Settings Tabs) — 2026-10-22
|
||||
|
||||
### 📥 Drop a model in, it goes to the right folder (拖进来自动归位)
|
||||
- **Models page → + Import**, or drop model files anywhere on the models page: each file gets a card saying what it is — Checkpoint, LoRA, VAE, text encoder, diffusion model, ControlNet, Embedding, CLIP Vision, upscale model — with its base model, how sure that is, and one line on what such a model is for. It is read from the file's header only; .ckpt/.pt files, which can carry code, are never opened (you pick their type).
|
||||
- Where it will go is shown and can be changed; by default it follows the download setting (by base model, such as loras/Illustrious). A file found in your Downloads folder or on the Desktop is **moved** there (instant on the same drive; tick "Keep the original" to copy); any other file is sent as a copy.
|
||||
- While you look, the whole file is checked against Civitai: SDXL-family LoRAs get their exact base model (Pony, Illustrious, NoobAI cannot be told apart from the file alone) and the model and version name. **A file you already have** (identical content) is pointed out and left out unless you tick it; a different file of the same name is saved as "name (2)". Nothing is ever replaced. The new files are scanned right after.
|
||||
|
||||
### 🧹 Tidy: models in the wrong folder, files you have twice (整理模型)
|
||||
- **The models page has tabs: All models, Tidy and Sources** (Sources was a chip among the model types). Tidy shows a count when it found something.
|
||||
- **Tidy** reads the "table of contents" inside every model file and shows, as cards with the model's cover and name, two steps. **1. In the wrong folder**: what the file really is and where it belongs, drawn as *Checkpoint → UNet / Anima*, with one plain reason (for example an Anima, Flux or Wan model that is only the main part, which "Load Checkpoint" cannot read — Civitai labels all of these "Checkpoint"). **Move there** takes the model with its cover, info and your notes, keeping its subfolder ("Change place" to pick another); nothing is replaced, and it says which loader node your workflows need instead. **2. Identical files**: the copies side by side, the one in the right folder marked to keep (click another cover to keep that one), and one button sends the extra copies to the Recycle Bin with their covers and info. Scanned models are compared at once; same-size files never scanned when you press **Compare**.
|
||||
- Models that load where they are anyway (a whole checkpoint in the UNet folder) are not listed.
|
||||
- **Not used lately (很久没用的模型和节点包)**, under the two steps, when you press **Look for them**, for models under Tidy and for node packs in **Settings → Node packs** (a line under Tidy leads there), from *not used for 30 days*, *for 90 days* to *never used*. AMB now notes what each run uses and what each workflow you open holds (nodes and models, kept on this computer), and counts the tabs open when you look, so a node pack making sounds or videos, a workflow never saved, or a node only put on the canvas counts too; before that note began, your images and sounds (each records its models and nodes) and your saved workflows, recipes and combos tell. Nothing that came in the last 30 days is judged, and while the note is younger than the group you look at, the page says so.
|
||||
- **Models**, biggest first, with the day they were last used and a warning when a saved workflow, recipe or combo still names one. Tick the ones to drop and send them to the Recycle Bin with their covers and info.
|
||||
- **Node packs** with how many nodes and how long installed (from the day its folder was made), and a warning when another pack's nodes take what only this one makes. Packs that work only through nodes come first; those that also add buttons, menus or panels to the page are listed apart, since their nodes going unused does not say the rest is. A pack used only through its panels or buttons leaves no record: press **I still use it** and it is listed apart from then on (**Unmark** undoes it). **Find it in the Manager** opens ComfyUI Manager's node list already searched for that pack (its name is also copied), where you disable or remove it. Packs without nodes, which only work on the page or the server, are listed apart: no record can tell whether they are used.
|
||||
- Importing, uploading and Tidy's moves only work on the computer ComfyUI runs on; opened from another device (ComfyUI started with `--listen`), they say so instead of writing into your model folders.
|
||||
|
||||
### 🧬 Filter models by base model (按底模筛选)
|
||||
- Under the type chips, one chip per base model the listed models have, with counts: **All · Illustrious 21 · Anima 7 · SDXL 9 · Unknown 2**. Spellings of one family count as one (SDXL 1.0 and SDXL). Models not scanned yet are listed by what their file's header tells. Handy for LoRAs: pick the base model of your checkpoint and only the LoRAs that fit are left. It works together with the search.
|
||||
- The scanner's offline guess of a base model (for models Civitai does not know) now also recognizes LoRAs, Qwen-Image, Wan, Chroma and Hunyuan Video files.
|
||||
|
||||
### ✏️ A new model editor, with a source link (模型编辑页重做,能填来源链接)
|
||||
- **Edit** on a model opens a cleaner editor: the cover large on the left with the choices under it (the current one, Civitai's own cover when you replaced it, your newest pictures, upload, or the whole gallery), and on the right **Name**, **Source link** and **Notes**, each saying what it does. Nothing is written until **Save**; Esc or a click outside cancels. Renaming the file on disk is a tick under the name that says what it means.
|
||||
- **Source link (来源链接)**: where you downloaded a model (LiblibAI, HuggingFace…), its site named as you type. The details get a button that opens it, beside Civitai's; shared workflows and Model Check point to it when someone misses that model. Only web addresses are kept.
|
||||
- **Reset** clears the name and notes you set and brings Civitai's cover back; the source link stays.
|
||||
- Saving no longer marks the name or notes as yours when you did not touch them.
|
||||
|
||||
### 🪪 Model details, rearranged (模型详情页重排)
|
||||
- From a Bilibili comment: the buttons were small and spread over five places, and the file name showed twice. Now the title bar is gone and everything sits beside the cover: a small **← Models / LoRA** to go back (Esc does the same; from Model Check or the assistant it says where it leads), the model's **name**, the size / base model / source line with the Civitai and source links, and one row of buttons: **Add node** (the one coloured button), **Edit**, **Folder** (shows the file in Explorer; pointing at it shows the path) and **Outputs** (pictures made with it).
|
||||
- The file's full path shows once, at the foot of the details with **Copy** and **Delete model**. Delete is no longer a red button next to Add node; it asks first in AMB's own dialog, and the files still go to the Recycle Bin. The ⬇ (jump to the bottom) button is gone.
|
||||
- In a narrow window (the browser docked beside the canvas) the cover column is hidden as before, so a small cover now sits beside the name.
|
||||
- A model's description (Civitai's own text) keeps its headings a little above the text instead of poster size, and a prompt quoted in it wraps instead of making the details scroll sideways.
|
||||
|
||||
### 🗂️ The folder column follows the type; open folders in Explorer (文件夹栏跟着类型走,一键打开文件夹)
|
||||
- The column left of the models used to list every type's folders (raw names like `checkpoints (1)`, `[Root]` rows with 0, nothing marking where you were), repeating the chips above it. Now it shows the folders of the type you picked: **All**, then its subfolders as a tree whose numbers include their subfolders (empty ones dimmed, ▾ folds one), and **In the top folder** for models lying directly in the type's folder. The folder you are looking at is marked.
|
||||
- **📂 Open** next to the type's name opens that type's folder in Explorer, and a 📂 on any folder you point at opens that one, so you can drop files in or tidy up by hand. Like the details' Folder button, it works only on the computer ComfyUI runs on.
|
||||
- In a narrow window (docked beside the canvas) the column opens as a drawer over the page; closing it no longer squeezes the page for a moment as it slides away.
|
||||
|
||||
### 🎨 The Combos page looks like the others (搭配页和其他页面统一)
|
||||
- It no longer sits in a lighter, inset box with a shadow, a second "Combos" title and a red ✕: it fills the page in the same colours as Models and Workflows. The explanation of what a combo is folds behind **?** (shown by itself while you have no combos), and the editor's Back reads **← All combos**.
|
||||
|
||||
### 🧩 Combos with rgthree's and Easy-Use's LoRA nodes (搭配支持常用的第三方 LoRA 节点)
|
||||
- Stacking many LoRAs used to mean a long chain of Load LoRA nodes. **New** on the Combos page now also offers **Default combo, LoRAs in Power Lora Loader** (rgthree: every LoRA in one node, each row with its own switch and strength) and **…in Easy-Use LoRA stack** (up to ten per stack node, put on the model by Easy Apply LoraStack), each only when that node pack is installed. You still just pick the main model and the LoRAs; the combo's editor can switch between them later.
|
||||
- A combo set to a node pack this ComfyUI does not have is put down with ComfyUI's own LoRA loaders, and says so.
|
||||
- **Save as combo** on canvas nodes keeps a Power Lora Loader's LoRAs: before, its rows were lost and it came back empty. Each row's LoRA is now a slot you can change in the editor, its strength and switch shown beside it.
|
||||
- **The LoRAs inside a Power Lora Loader are seen everywhere else too (Power Lora Loader 里的 LoRA 也认得了)**: before, AMB looked only at ordinary drop-downs, so a downloaded workflow using one never said its LoRAs were missing. Now **Model Check** lists each switched-on row's LoRA (missing, found by fingerprint, download, put back in), saving a workflow records their fingerprints, workflow cards and a picture's parameters list them with their strengths, a workflow's **models used** include them, and a LoRA's **Outputs** finds the pictures made with it. A workflow's models used also include Easy-Use LoRA stack rows. Only these widely used nodes are read this way; ComfyUI's own loaders remain the reference.
|
||||
|
||||
### 🚀 First-run setup on Home (新手一键准备)
|
||||
- Until your models have been scanned once, Home opens with **New here? Let's get set up**: four steps and **Set up for me**. It checks that Civitai answers (many need a proxy for it; if not, **Retry** or **Scan offline**), offers the optional Civitai key, scans every model with a progress bar in the card (it renames no files and runs in the background), then starts the 30-second tour. It stops to ask when the network is down. **Later** hides it; Home's **New here?** line has **Set up** to bring it back. It is a card on Home, never a popup.
|
||||
|
||||
### ≈ What was read from the file says so (离线推断标清楚)
|
||||
- A model Civitai does not know has its base model inferred from the file, as before; now it says so. Its card reads **推断 SDXL** (it was ≈), and its details open with a note: why Civitai did not have it, that the base model was read from the file and may be off, and **Add a name and source link**, instead of an English placeholder description.
|
||||
- The scan page says it too: an online scan "looks up 5 on Civitai and infers the base model from the file for any it does not find", and the state chip reads **Not found · inferred from file**.
|
||||
|
||||
### 🏷️ Version labels on model cards (卡片显示版本号)
|
||||
- A scanned model's card shows its Civitai version ("v2.0", "Illustrious", "fp8"…) as a small label above the name, so versions of one model that share a file name can be told apart.
|
||||
|
||||
### ⇄ Compare parameters (参数对比)
|
||||
- Two pictures from the same workflow often differ in only a few values. Press ⇄ on one picture in the Gallery, then on another (or **Compare with the canvas**); the picture details have the same ⇄ for the canvas. The window says whether both use the same nodes and lists only what differs, node by node, A and B each in its own colour: prompts by the tags only A or only B has (marked positive or negative), long names one line per side with the differing part marked, seeds folded away, nodes only one side has listed apart. Two workflows numbered differently still compare the nodes each has one of, such as their only KSampler.
|
||||
- Against the canvas, tick the values to take from the picture and put them on the canvas in one go (seeds stay); one Ctrl+Z or the toast's Undo takes them back.
|
||||
- **What changed since the last picture (和最新出图对比)**: **⇄ Canvas vs. newest picture** at the top of the Gallery and of Activity compares the canvas with the newest picture in the output folder, edits made by hand on the canvas included, and can put the picture's values back.
|
||||
- **Key parameters (关键参数)**: press ☆ before a value's name (tick **Show equal values too** to star one that matches) and it stays at the top of every comparison, for every workflow, kept with your ComfyUI settings.
|
||||
- **Current node shows where saved values came from (当前节点的参数方案:出处、出图、并排)**: each saved set for the selected node is a card with the picture made with those values (or the picture they were kept from; click to see it large) and a line on where it came from, with the day: **a parameter set of workflow X** (with how many pictures it made, and **Open** to show that set in the workflow), **kept with picture Y** (**See it**), or **saved here**. The values it would change are drawn instead of written: numbers as two bars (now and then) with ↑ or ↓, choices as the old one struck through → the new one. What stays (seeds, models, choices this computer lacks) is said with why; **Show all** lists every value, equal ones too (there is no more "4 shown, N more"). **⇄ Compare** opens the comparison of the whole workflow with the canvas, not only this node.
|
||||
- **Where the node's values came from (上次套用的是哪套)**: a line on top of the parameters says which set was last put into this node and when ("上次套用「阿罗娜K4.0 · 02」· 今天 18:02"), and what changed since ("之后变了:cfg 5 → 6.5"), with **Put them back**; that set's card is marked. It is remembered in this browser, per workflow and node, and forgotten when the change is undone. Without it, the line says which saved set the values match, or come closest to ("只差:cfg 6 → 5").
|
||||
- **Side by side (并排)**: with two sets or more, switch to a table: the current values and each set in columns (its picture on top), the values a set would change marked, **Use N** under each. The choice is remembered.
|
||||
- **Parameter sets in view (参数方案更好找)**: a workflow card with saved sets shows **▤ N** (how many), which opens them directly. Each set there shows its key values (those unlike the workflow's own in colour), how many values differ, and ⇄ to compare it with the workflow.
|
||||
|
||||
### 🗂️ A tidier Workflows page (工作流页面重做)
|
||||
- One bar on top: search, **Filter** (kind and tag in one menu), grid or list, share and import, and **Save the canvas**. The drag tip strip and the red ✕ are gone (Esc still closes the page; the empty page explains dragging).
|
||||
- **A workflow opens on its values (打开工作流先看参数方案)**: the details are rebuilt around them. On the left: **默认参数** (the workflow's own), your saved sets (each with its key values and how many values differ), **+ 从画布保存** and **+ 新建参数方案**, then **全部出图** and **版本**. On the right, the chosen set: **应用到当前画布**, **⇄ 对比** (with the default values or the canvas), ⋯ (copy as a new set, rename, delete), its main values, its prompts (long ones folded, the role still chosen from a menu), **the pictures made with these values**, and, folded, the models and every node's values. The bar keeps the name (click to rename), the models' state, Open and ⋯ (edit, export). The old Overview tab is gone: cover, notes and tags sit at the top of 默认参数.
|
||||
- A new set is named after the workflow with a number (阿罗娜K4.0 2); values saved without names show their real names instead of 控件 1, 控件 2. A set equal to the workflow's own values (saving a workflow keeps one) is no longer listed twice.
|
||||
- **Compare pictures inside a workflow too**: every picture in 全部出图 and under a set has ⇄ — pick two, or one and the canvas or the default values. The pictures there now load as thumbnails.
|
||||
- Cards keep the picture, the name, a line of steps, sampler, LoRAs and size, and the model. The models' state is a dot on the cover, with words only when some are missing; 功能模块 marks a partial workflow. **▶ Open** and **⋯** (edit, export, delete) are two small round buttons in the cover's corner, shown when the pointer is on a card, so pressing a card to look inside no longer opens it on the canvas by mistake.
|
||||
- **Focus values (重点参数)**: mark what matters most in a workflow. Under 全部节点参数 every value has a ☆; marked values show at the top of 默认参数 and of every set as large tiles with the node they belong to, a note you can add (「0.6–0.8 最好」), and, on a set, the default value when it differs. Set rows list them first, and in ⇄ 对比 they lead the key values with their note. A star turns at once with a little bounce, the value fades in at the top, and marking or noting one saves only that (no new version of the workflow). They belong to that workflow (the ☆ in the comparison window still stars a value for every workflow), are kept when the workflow is updated from the canvas, and travel with it in an exported workflow package (share codes carry only the canvas workflow).
|
||||
- **Tips where they help (参数方案的引导)**: a workflow without sets says what they are for, with **+ 从画布保存** right there; on a set, a line points at **⇄ 对比** until you have used it (or pressed 知道了).
|
||||
- **Folds remember you (折叠记住你的选择)**: 用到的模型 and 全部节点参数 open as you last left them. 用到的模型 says on its title whether the models are all here or how many are missing, and opens by itself when some are.
|
||||
- **Docked, the details fit (侧边栏模式不再挤)**: in a narrow panel the left column becomes one row of pills above the values (默认参数, your sets, new, 全部出图, 版本).
|
||||
- **Saving a workflow, redone (保存工作流的窗口重做)**: the cover is large on the left, chosen from the canvas, the current cover or your recent pictures (as thumbnails); name, tags and notes on the right; Cancel and Save always in view. The name starts as the canvas's workflow name when ComfyUI has saved it under one. Esc cancels, Enter in the name saves.
|
||||
|
||||
### 🔇 Models you never want scanned (不需要扫描的模型)
|
||||
- A model whose header looks like no drawing model (a voice model such as F5-TTS kept in checkpoints, say) is no longer just "not scanned" forever. The scan page asks about them: **都不扫**, **都照常扫**, or **逐个看**. Set to not be scanned, a model shows **不扫描** instead of 未扫描, a scan of everything skips it (picked by hand it is still scanned), and the scan page lists these models to change it back. A model's details have the same button. Nothing is decided for you: scanning still keeps a fingerprint that repairs workflows.
|
||||
- **Not on Civitai is no fault (Civitai 上没有不再像出错)**: the count of models Civitai did not find warns only when some file is damaged or a lookup is still to do; each such model says its fingerprint is kept for repairing workflows.
|
||||
|
||||
### 🎯 The scan page, rearranged: pick models beside the button, settings in place, components apart (扫描页重排)
|
||||
- **Quieter the further down.** On top one card answers "what now": two rows of your models' covers (an empty dashed frame for each model not scanned yet, gently pulsing while a scan runs), what is left in one line ("3 models still to look up"), the scan button, **Pick models to scan…**, and **Online / Offline** right beside them. Then the models' state as a coloured bar with a chip per state; then, only when there is something, what needs a look (damaged files, voice-like models, models set to never be scanned, other formats), one row each with its icon; then the settings as tiles with an icon and a switch each; the last scan is one line at the foot. "How scanning works" is folded under the title.
|
||||
- **Pick models to scan… sits beside the scan button.** The picks are named under it (each with a ✕, with their count and total size) and the button scans just them; **Clear, scan everything again** goes back. There is no "all / only ones I pick" switch any more. When some picks are already found, it says so: a scan only reads their record again unless Fetch again is on.
|
||||
- **Under the button, what this scan will do**, in one sentence; **Details** unfolds the numbers: how many will be looked up on Civitai (not scanned yet, and those last scanned without a connection; how many of them are components), how many are already found and keep their record, how many Civitai does not have and are not looked up again, how many are set to never be scanned. Turning on offline or Fetch again changes the lines at once.
|
||||
- **The scan settings are on the page** instead of a page of their own: Civitai names on cards, fixing the workflow afterwards and the Civitai key as tiles; renaming files and fetching everything again are folded under **More**, marked as changing files, and when one is on the scan card says so in yellow.
|
||||
- **Components on their own (基础组件)**: VAE, text encoders, CLIP, CLIP Vision and upscalers get a chip of their own, "7 components · not found is fine", whose list (found, not found, not scanned yet) says that many of them are not on Civitai at all and that this does not matter (the fingerprint is still kept for Model Check). The counts above, and the Not found and Not scanned yet lists, leave them out; their own list shows all of them.
|
||||
- **The model picker is new**: every model a scan reads, as a list (name, folder, state, base model, size) or as covers. Chips by state, the same words as the scan page — **Not scanned yet, Not found, Damaged, Found, Never scan** — with counts; a search; the folders on the left with how many are picked in each, two folders of one type told apart by where they are. **Pick these N** picks everything shown (so "the 12 not scanned yet in LoRA" is two clicks), shift-click picks a run, **Picked only** shows the picks. The footer says how many are picked and how big they are.
|
||||
- Only files a scan can read (.safetensors) are listed; the old picker also offered .ckpt and other files a scan never opens.
|
||||
- **Quick to open with many models, or models on a network drive** (a NAS, a cloud server's model disk): the page reads the headers of models not scanned yet 16 at a time and remembers them until the file changes, so it opens again at once; while it counts, it says **Counting your models…** instead of staying blank. On a cloud server with 1041 unscanned models it had taken 79 seconds each time.
|
||||
|
||||
### ⚙️ Settings in tabs (设置分组)
|
||||
- The settings page is split into tabs: **Look, Models, Workflows, Node packs, Prompts, Backup, Help**. "Change where downloads go" opens Models directly.
|
||||
- **Where downloads (and now imports) go is a choice**: by base model (the new default), in Downloads then by base model, all in a Downloads folder, straight in the type's folder, or your own folder typed by hand; an example path shows the result. Without a saved setting, downloads now go by base model instead of into a Downloads folder.
|
||||
|
||||
### 🔖 Take single tags from a saved prompt (从存过的提示词里挑词)
|
||||
- **In Prompt Studio a clicked card opens as a reference** between the cards and the prompt you are editing, as its tags (with their meanings when a language is picked). Click one tag and only that tag goes into the prompt; tags the prompt has already are ticked. **Add all** puts the whole card in, as a click on a card used to; the preview's Add and dragging a card work as before. The ✕ closes the reference; beside the canvas the drawer widens by the column while it is open.
|
||||
- **It does not squeeze a narrow window**: where three columns do not fit (a side-panel browser window, a narrow drawer), the reference takes the card list's place and **‹ Cards** brings the list back, so the reference and the prompt you edit each get half the width.
|
||||
|
||||
### Fixes
|
||||
- **The browser no longer opens blank after Put on canvas (搭配放到画布后再打开不再空白)**: putting a combo on the canvas hid the combo page before closing the browser, so the next time it opened the title said Combos and nothing was below. It now opens again on the combo list, or on the combo you put down.
|
||||
- **Edit emotions: every clip in the list, and the line follows the clip (编辑情绪:下拉列出全部音频,换音频台词跟着换)**: a box that already held a file listed only the files whose names contain it (the browser filters by the box's text), so a character's other clips seemed missing. Pressing the box now lists all of them, with the current file shown greyed; leaving without a pick keeps it. Picking another clip no longer keeps the old clip's line beside it: the line the node already reads for that clip is filled in, otherwise the line the node finds for the clip (its .txt / .lab or the annotation list) is filled in, with Anomalous_TTS from interface 14 on (older versions leave the box blank and the node reads the line when it speaks); going back to the first clip brings its line back. A blank line box shows, greyed, the line the node uses for its clip.
|
||||
- **Search a character's clips by line (按台词找参考音频)**: Edit emotions has a search box above the clips: type part of a line or a file name and the matches list with their lines, to hear (▶), **Use as main voice** or **Add as emotion** (a new emotion row with that clip and its line, ready for its name). Searching lines needs Anomalous_TTS interface 14; with an older version it searches file names.
|
||||
|
||||
## v1.59.1 Beta (GPT-SoVITS v3 / v4 voices) — 2026-10-17
|
||||
|
||||
### 🎙️ Character voices: v3 and v4 models (角色语音支持 v3 / v4 模型)
|
||||
- **Anomalous_TTS now runs GPT-SoVITS v3 and v4 models** (its first stable release, [v1.0.0](https://github.com/DemonGatanjieu/Anomalous_TTS/releases/tag/v1.0.0); update it in ComfyUI Manager), and this version of the plugin goes with it: v3 / v4 files are imported instead of refused, and a file whose version cannot be read is refused with a clearer reason. With an Anomalous_TTS too old for v3 / v4, the import says to update it.
|
||||
- **Each character card shows its GPT-SoVITS model version** (v2, v2ProPlus, v4…) after the name (角色卡片显示模型版本).
|
||||
- **v3 and v4 need their own official base model and vocoder** (v4 about 0.8 GB, v3 about 1.2 GB). **GPT-SoVITS Settings** lists them, with their own labels, only when one of your characters uses that version, and the import card says what is still missing for the model you dropped in. They download on first use too; a GPT-SoVITS package already on the computer is used as it is.
|
||||
|
||||
## v1.59.0 Beta (Model Downloads, Backup, Share Codes, Node Combos, MCP, Light Theme) — 2026-10-17
|
||||
|
||||
### ⬇️ Download missing models from Model Check (模型检查一键下载缺失模型)
|
||||
- **Download** next to a missing model, and **Download all** for every one, when its source is certain: Civitai found the very file by the workflow's fingerprint, or the workflow carries a link to the file (a shared workflow's model links, or the download links in ComfyUI's own templates on Hugging Face). A file name alone is never enough, and a link to a different file than the workflow's is not offered.
|
||||
- **You see where it goes before it starts**: the dialog shows, per file, its size, source and base model, the full path it will be saved to, and the free disk space. The models folder (when you have several, e.g. through extra_model_paths) and the subfolder can be changed right there; the folder you pick is remembered per type.
|
||||
- **Your folder habits (按你的文件夹习惯放)**: Settings → Model downloads sets the default. *Where the workflow says* (default) puts the file in the subfolder the workflow names, so nothing in the workflow changes; a model without one goes into the folder set below (`Downloads`). *A set folder* always uses that folder, and the node is moved to the new place (Ctrl+Z undoes it). Write `{base}` in the folder to sort by base model (SDXL, Flux, Illustrious…); a folder of that name you already have is used, in whatever spelling.
|
||||
- **Safe by default**: it downloads into a temporary file and checks the fingerprint before giving it its name, so a broken or wrong download never shows up in your models; an existing file is never replaced; an interrupted download continues where it stopped. `.ckpt` / `.pt` files get a warning (they can run code), and a source without a fingerprint says it cannot be checked. Your Civitai API key (needed for some models; Settings → Model downloads, or the scan page) is only sent to Civitai.
|
||||
- Progress shows in the row with Cancel; when a file is done, the nodes using it get it, a short message says so, the new files are scanned for their information and cover, and the activity log records the download (source, size, whether the fingerprint matched).
|
||||
- **Base models, VAEs, text encoders… from ComfyUI-Manager's list (Manager 模型清单)**: models that are not on Civitai (Flux's VAE, T5 and CLIP text encoders, upscalers, ControlNets…) are now looked up by file name in the model list of the ComfyUI-Manager installed here, which has a few hundred of them, mostly on Hugging Face. When the workflow carries the model's fingerprint, only the listed file with that fingerprint is offered, so it is certainly the same file; without one, a single listed file of that name is offered as **Download by name**, with a warning, and never in Download all.
|
||||
- **Hugging Face first with the mirror (开镜像时优先抱脸)**: when a model is on Civitai and the very same file (same fingerprint) is on Hugging Face too, through the workflow's link or ComfyUI-Manager's list, it now downloads from Hugging Face while the China mirror is on: usually much faster there, and no Civitai login. Civitai's name, base model and page are still shown.
|
||||
- **Covers and prompts from Hugging Face (抱脸模型的封面和示例提示词)**: a model downloaded from Hugging Face gets its model card's first example image as its cover, and its trigger words, example prompts and base model in its notes, with the repository as its link. Nothing you or a scan already set is replaced. Hugging Face cannot be searched by fingerprint, so this is for files downloaded from it.
|
||||
- **Text encoders, CLIP vision and upscalers shown by default (文本编码器等默认显示)**: these folders are now shown and scanned by default, also where an earlier version saved them as hidden (once; you can hide them again in Settings → Model folders). Scanned models get fingerprints, so workflows you share carry them for T5, CLIP and the like too and Model Check can find the very file for whoever opens them.
|
||||
- **One site down does not stop the others (一个网站连不上不影响其他)**: when Civitai does not answer (common in mainland China), the rest of the lookup no longer gives up: it stops asking Civitai and still asks Hugging Face (through the mirror) and the others.
|
||||
- **AI apps can download too (AI 也能下载)**: three new MCP tools: `download_missing_models` (what Model Check's Download all does, for the open workflow), `download_model` (one model from a Civitai version link, a Hugging Face or GitHub file link, or a Civitai hash, into the folder of its type) and `download_status`. Files go where Settings → Model downloads says, are checked the same way, and are never written over an existing file.
|
||||
- **Hugging Face through the China mirror (Hugging Face 国内镜像)**: Settings → Model downloads can send Hugging Face downloads through hf-mirror.com, which is on by default for the Chinese interface. When a download from Hugging Face itself fails, the row suggests the mirror.
|
||||
|
||||
### 🌐 A proxy for the plugin, and why Civitai cannot be reached (代理设置与连不上的原因)
|
||||
- **Settings → Network → Proxy address**: the plugin's own requests (scans, model downloads, translation, update checks) go through the proxy written here, e.g. `127.0.0.1:7890`. Empty uses the system proxy as before. For a VPN app in PAC mode or one that only proxies the browser: the browser reached Civitai, the scan did not. Only this plugin uses it; ComfyUI and other node packs are untouched.
|
||||
- **Test connection** right below says whether Civitai answers now, and if not, why.
|
||||
- **A scan that cannot reach Civitai says why**: no answer (often no proxy), refused (wrong proxy port, VPN off), the address not found, a failed secure connection, or an error from Civitai, with what to try.
|
||||
|
||||
### 💾 Backup and restore (备份与恢复)
|
||||
- **Settings → Backup → Export** packs your Workflow Recipes, combos, materials and saved prompts, parameter sets, the plugin settings and the names, notes and covers you gave your models into one .zip, optionally with ComfyUI's own saved workflows. Keep it on a cloud drive or a USB stick. The Civitai API key is never included.
|
||||
- **Import** first lists what is in the backup next to what this computer has (new, the same, different), and the models it finds again by file hash, even after they were renamed or moved. By default it only adds what is missing; "take the backup's" replaces differing files and puts this computer's in the Recycle Bin. A model's name and notes are filled in where empty, and your own cover takes the place of the Civitai one. Nothing is deleted.
|
||||
- Models' scan results (.info: names, trigger words, base models from Civitai) are in the backup too, so they survive Civitai removing a model; Civitai's covers can be added (they can be hundreds of MB). Both are put back only where this computer has none.
|
||||
- **Download what the new computer lacks**: the backup also keeps a model list (each model's place, size, fingerprint and link). Importing says how many of those models this computer lacks; **Find downloads…** looks them up like Model Check (Civitai by hash, the saved link, Hugging Face), shows sizes and sources, and downloads the ticked ones one by one into the folder and file name they had, each checked against its fingerprint and scanned after.
|
||||
|
||||
### 🔗 Short share codes, one place to import (短分享码与统一导入)
|
||||
- **Share codes are 5–10 times shorter (AMB2)**: only what a workflow needs is kept — node types, values, links, positions, titles, groups and the models' fingerprints — and ComfyUI fills in the rest when it opens. A Chinese-character spelling ("汉字码") is the shortest; a letter spelling ("字母码") posts anywhere, for chats or comment sections whose word filters stop the characters. Before a code is given, it is opened once and compared with the canvas; a workflow it cannot hold exactly (subgraphs, for one) is shared whole. Nodes from node packs keep their links on a computer that has not installed the pack yet. AMB0 and AMB1 codes still open.
|
||||
- **Share and import in one place**: the Workflows page's ⇅ makes the code as soon as it opens (with the fingerprint and download-link notes), and takes anything in: a pasted share code or workflow JSON, or a dropped or chosen workflow file, an image with a workflow, or a backup .zip.
|
||||
|
||||
### 📦 Recipe packages are back (配方包重新开放)
|
||||
- A recipe card's export button (and Export in the recipe's More menu) makes a **recipe package**: one .zip with the recipe's workflow, name, notes, tags, parameters, cover and model thumbnails (its versions only when ticked), to give to someone. Dropped into the ⇅ of their Workflows page, it becomes a new recipe card, optionally opened on the canvas; a recipe of the same name is never replaced (the new one is kept as "Name (2)"). Packages are checked for size, paths and checksums before anything is written, and a failed import leaves nothing half done. Send it as a file in chat apps.
|
||||
|
||||
### 🧱 Combos from your own nodes (按自己的节点结构做搭配)
|
||||
- A combo is no longer only "a main model, LoRAs and a prompt". Pick nodes on the canvas (a Flux UNet with its two text encoders and VAE, a video model, a node pack's loader…), choose **New → From the nodes picked on the canvas**, and the combo keeps those nodes and how they are wired. Its editor then lists the **slots**: each model drop-down (a node with several has several) to change with a picker that shows that folder's models with covers, filtered by base model, and each prompt box to write in. **Put on canvas** lays the nodes out wired as saved, with your choices in them.
|
||||
- Only certain things are kept: nodes that pick model files and nodes that hold text, and the links between them. Other picked nodes (samplers, latents…) are left out and their ports stay open for you to wire, as before. Every other value of the kept nodes (strengths, CLIP type…) is stored by name and put back untouched. When the dialog saves, you see what is kept and what is not, and can rename slots or untick the ones that should stay as they are.
|
||||
- A node combo's page is grouped into Models, LoRAs and Prompts, in the order they flow (loaders first, LoRAs from the model outwards, the positive prompt before the negative); "LoRA 1" is the one next to the model. Each model shows its cover (looked up once for models chosen on the canvas), a two-line name, and its node's saved values (strength, CLIP strength, type) as small read-only tags. A whole row opens the picker; prompt boxes grow with their text. The top bar of the combo editor no longer floats 20 px down with the page showing above it.
|
||||
- Each slot names the node it is on, and its node pack when that is not ComfyUI's own. A prompt has a small "译" button: a read-only panel beside it shows the translation (English for Chinese, Chinese for English); Prompt Studio stays the place to edit prompts.
|
||||
- Nodes holding several models (Load CLIP Triple, LoRA stacks of rgthree, Comfyroll and Easy-Use, Easy-Use's full loader): each model is its own slot, and each saved value goes to the row with its number (strength_02 under the second LoRA); more than four values fold behind "+N". Empty rows of a stack (None) start unticked when saving, so a ten-row stack does not make ten empty slots. rgthree's Power Lora Loader draws its own rows instead of drop-downs and is not kept.
|
||||
- **Right-click → Save as combo**: the canvas's node menu saves the picked nodes (or the node you right-clicked, when it is not among them) the same way, without opening the browser first. It shows only when there is a model or text node to keep.
|
||||
- Every other value of a saved node stays as it was saved: the combo always puts down exactly what you saved, so a Flux combo cannot fall back to a node's default CLIP type or strength. For other values, save another combo.
|
||||
- On another computer, missing nodes are named with their node pack before anything is placed; a box or link a node pack has since renamed is reported after placing, never guessed. **New → Same structure as …** starts another combo from a structure you already saved.
|
||||
- **Save as combo keeps every picked node**, not only the model and text nodes: an upscale model's loader with the node that uses it and the Save Image after it, a ControlNet's nodes, any small group of nodes that work together. The models and prompts in it can still be changed in the combo; the other nodes are listed under **Nodes put back as saved** with their values, and go back on the canvas exactly as saved (change them on the canvas and save the combo again).
|
||||
- A link coming in from a node that was not picked cannot be kept; the save dialog, the combo and its card (**1 input to wire**) say which inputs to connect after putting it down. Subgraphs cannot be kept yet. A combo with no models shows its nodes' names on the card. Combos saved before work as they did.
|
||||
|
||||
### 🤖 AI apps can use your library (MCP,让 AI 用你的库)
|
||||
- While ComfyUI runs, AI apps that speak MCP (Claude Code, Claude Desktop, Cursor, Cherry Studio, VS Code…) can connect to `http://127.0.0.1:8188/anomalous/mcp` and ask about your library: models with their Civitai information, trigger words and your notes, which models are unmatched and why, output images and the settings that made them, combos, workflow recipes, saved prompts, generated speech and the activity log. A model's cover or an image's thumbnail can be attached for apps that look at pictures.
|
||||
- It can also act on the workflow open in ComfyUI: rewrite or extend the prompts, swap a node's model, add a LoRA (it goes after the model loader and the LoRAs already there; one that also trains the text encoder takes the CLIP line too, from the checkpoint or, in a UNet workflow, from its CLIP loader, and everything those fed, such as both prompts, moves behind it; one that does not, like most Flux and Anima LoRAs, patches the model only), place one of your combos, check and put back missing models, and run the workflow; and start scans, or have an Anomalous TTS voice read a line. Every canvas change is one Ctrl+Z step, shows a note on the page and is in the activity log marked "AI". Nothing deletes or renames files; AI apps ask before calling a tool that changes something.
|
||||
- Only this computer can connect, and requests from web pages, other hosts or through a proxy are refused. It costs no memory while nobody is connected. Setup for each app: `docs/guides/mcp.md`.
|
||||
|
||||
### ☀️ Light theme (浅色主题)
|
||||
- **Settings → Appearance → Theme**: **Follow ComfyUI** (the default), **Dark**, **Light** or **Abyssal Scarlet**. Following ComfyUI switches with ComfyUI's color palette at once, light with a light palette and dark with a dark one. The same choice is in ComfyUI's settings under Anomalous Model Browser (it replaces the Abyssal Scarlet on/off switch; Abyssal Scarlet stays chosen if it was on).
|
||||
- Every page, the Prompt Studio drawer and the dialogs follow the theme. The image viewer stays dark, like ComfyUI's own, and so do the labels and buttons over pictures (a model card's name, a recipe's cover labels, the gallery's View parameters button), so their text reads on any picture. Grey hint and secondary text is darker in the light theme so it reads clearly. The dark themes look as before.
|
||||
- The "all models are here" banner on a recipe now reads green instead of pink, and the voice page's **Import character** button has dark text on its light blue in the dark themes.
|
||||
|
||||
### 🎯 Opens where your node is; number keys for the pages (按节点打开、数字键切页)
|
||||
- **Opening follows the selected node**: open the browser with a text node selected on the canvas and Prompt Studio opens on that node; with a KSampler, a model loader or any other node, Current node opens. With nothing selected it reopens the page you used last, as before. It can be turned off in Settings → Opening and window.
|
||||
- **Prompts is a page like the others (提示词变成普通页面)**: the rail's Prompts entry (and 7) now shows Prompt Studio inside the browser window instead of folding the window away. **Beside the canvas** on the page moves it next to the canvas as before, to see the canvas while you edit (its side and width are remembered); **Back to the window** returns it.
|
||||
- **Prompt Studio's own shortcut is gone** (Ctrl+Shift+L, and its row in ComfyUI's settings and the Extensions menu): it opens from the rail, with 7 while the browser is open, or by opening the browser with a text node selected. The browser's shortcut (Ctrl+Shift+M) stays.
|
||||
- **1–9 go to the pages on the left** while the browser is open (Home, Activity, Models, Gallery, Workflows, Combos, Prompts, Voices, Audio library). Numbers typed into a box stay in the box, and the rail's tooltips show each number. W/S were not used because W is ComfyUI's own Workflows key.
|
||||
|
||||
### 🔎 Model search in the header (模型搜索放到顶栏)
|
||||
- The models page's search sits in the header now, instead of inside the folder list you had to open first. It matches the name, file name, folder, trigger words, base model and notes, every word you type, in the type you are looking at; Esc clears it. It also searches all the models of the type, where the old box only filtered the cards on screen.
|
||||
|
||||
### 🧩 Inserting a LoRA wires it properly (插入 LoRA 连线更聪明)
|
||||
- **Current node → Insert LoRA before / after** now works on the usual workflows it refused before: a checkpoint whose CLIP feeds both prompts (both now hang behind the LoRA), and UNet / Flux / Anima workflows with their own CLIP loader. Whether the CLIP line goes through the LoRA is decided by the LoRA file itself: one that also trains the text encoder (most SD 1.5 / SDXL / Illustrious LoRAs) takes it, from the checkpoint or the CLIP loader; one that does not (most Flux and Anima LoRAs) is inserted as a model-only LoRA loader. Inserting before a sampler moves only that sampler's model, and the CLIP of the prompts wired into it.
|
||||
|
||||
### 🌐 Your own DeepL key for translation (DeepL 翻译密钥)
|
||||
- **Settings → Translation → DeepL key**: prompts are translated with Google's free endpoint (MyMemory when Google cannot be reached); with your own DeepL API key, DeepL comes first. Saving the key translates one sentence to show it works, or says why not (a key DeepL refuses, a used-up month). The Free plan gives 500,000 characters a month. The key stays on this computer and is never put in a backup.
|
||||
- A DeepL key saved by hand in `config.json` before did not work any more: DeepL stopped taking the key as a request parameter in 2025. It now goes in the Authorization header.
|
||||
|
||||
### 🎬 Videos in the gallery (图库显示视频)
|
||||
- **The gallery shows your video outputs** (MP4, WebM, MOV, MKV) next to the pictures: each card shows the video's first frame with a ▶ badge naming its format and plays it muted while the pointer is on it (or always, as Settings → Video covers says for model covers); clicking it plays the video with sound in the viewer, filling the window, and a double-click goes full screen. Closing the viewer (×, a click beside it or Esc) stops it. Videos are deleted (to the Recycle Bin) like pictures, and a video dragged onto the canvas becomes a Load Video node; View parameters, the star and the right-click menu stay with pictures, which carry the parameters. A first contribution from outside: thanks to [@namnamjeopjeop](https://github.com/namnamjeopjeop) (cocorang) and [@wkdtjs](https://github.com/wkdtjs) (PR #21).
|
||||
- **All / Images / Videos (全部 / 图片 / 视频)** beside the gallery's search, shown once your output folder has a video. **Settings → Gallery** hides formats you never want to see (GIF, say); click a format again to show it. Both are remembered in this browser. The recipe cover picker and AI apps (MCP) still see pictures only.
|
||||
|
||||
### 🧪 Tests in the repository (测试公开)
|
||||
- The plugin's tests are now part of the repository: `tests/` (Python tests for the backend, `.mjs` tests for the frontend rules), `node tools/run_tests.mjs` runs them all with the structure check, and [docs/guides/testing.md](docs/guides/testing.md) says how to run and write them. They need no network or GPU and only touch temporary folders. Developer notes no longer point at tests that were removed earlier.
|
||||
|
||||
### ⭐ A Star for the project (点个 Star)
|
||||
- Home's "Found a problem, or have an idea?" band ends with **⭐ Star on GitHub**, and Settings → Help and feedback has **Star it on GitHub**: both open the project page, where the ☆ Star button is. Nothing pops up and nothing is fetched; it is there for whoever wants it.
|
||||
|
||||
### ⚠️ Damaged model files are named as such (模型文件损坏会直接说出来)
|
||||
- A model whose file header cannot be read (a download cut short, or another tool that rewrote the header and failed) used to look like a model Civitai simply does not know, inferred from the file. A scan now checks the header of every model it cannot match and names these **文件损坏**: a red badge on the card, a line in the details, a warning on the scan page, and first place in its unmatched list with advice to download the model again (then **Look up again**). Only the header is read. Models scanned before keep their old reason until they are looked up again.
|
||||
|
||||
### Fixes
|
||||
- **Ctrl+Z after a scan (扫描后的 Ctrl+Z)**: after any scan, the first Ctrl+Z used to seem to do nothing: the workflow's saved fingerprints had changed, which ComfyUI counted as a step. A change of fingerprints alone no longer takes a step.
|
||||
- **Esc in a model's Outputs window (作品窗口里按 Esc)**: Esc closes the window of pictures made with a model; it used to close the model's details behind it and leave the window open.
|
||||
|
||||
## v1.58.3 Beta (Fix: canvas speed with other extensions) — 2026-10-15
|
||||
|
||||
### 🐢 The canvas stays smooth with other extensions (画布不再被其他插件拖慢)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Claude 工作入口
|
||||
|
||||
本仓库的开发与维护规范以 AGENTS.md 为唯一正文,第 0 节是每次提交都必须做到的要求;这里只做入口,不另写规则。
|
||||
|
||||
@AGENTS.md
|
||||
@@ -1,11 +0,0 @@
|
||||
# Gemini 工作入口
|
||||
|
||||
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构;第 0 节是每次提交都必须做到的要求。
|
||||
|
||||
随后阅读 [ARCHITECTURE.md](ARCHITECTURE.md),按其中的阅读地图加载与当前任务有关的专题。
|
||||
|
||||
不要只凭当前任务描述直接追加实现。先确认现有责任模块、状态来源、关闭清理和样式规则;完成后按规范验证并报告结果。
|
||||
|
||||
如果当前工具没有加载本文件,任务发起者应明确要求 Gemini 阅读 `GEMINI.md` 和 `AGENTS.md`。文件的存在不代表工具已实际读取,更不代表检查已自动执行。
|
||||
|
||||
前端设计任务按 AGENTS.md 第 4 节和 [前端架构](docs/architecture/frontend.md) 进行,不使用历史视觉建议作为当前任务单。
|
||||
@@ -3,15 +3,16 @@
|
||||
# 🚀 Anomalous Model Browser
|
||||
|
||||
**A model browser and creative workspace for ComfyUI**
|
||||
*模型库 · 出图图库 · 工作流配方 · 搭配 · 提示词 · 角色配音 · 模型检查 · 操作记录*
|
||||
*模型库 · 出图图库 · 工作流 · 搭配 · 提示词 · 角色配音 · 模型检查 · 操作记录*
|
||||
|
||||
<br/>
|
||||
|
||||
[](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
[](https://youtu.be/hAvsj7uiaCw)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.bilibili.com/video/BV1boHx61Enc/)
|
||||
[](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
|
||||
[](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -19,7 +20,7 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<img src="docs/images/models.webp" width="900" alt="Models with their Civitai covers, base model and real names">
|
||||
<img src="docs/images/en-models.webp" width="900" alt="Models with their Civitai covers, base model and real names">
|
||||
|
||||
</div>
|
||||
|
||||
@@ -29,87 +30,101 @@
|
||||
|
||||
> **Anomalous Model Browser** takes care of the chores around ComfyUI in one window: browse your models with their Civitai covers and trigger words, look back through your outputs, keep workflows you have tuned, keep model combos and prompts, repair workflows whose models are missing, and, with the companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS), give your characters a voice. An activity log shows everything the plugin changed.
|
||||
|
||||
### 🎬 Video Walkthrough & Demos
|
||||
* 📺 **YouTube**: [Watch Quick Walkthrough on YouTube](https://youtu.be/hAvsj7uiaCw)
|
||||
* 📺 **Bilibili**: [Watch Video Demo on Bilibili (在哔哩哔哩观看)](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
### 🎬 Videos and discussion
|
||||
* 📺 **Bilibili** (Chinese): [Video demo](https://www.bilibili.com/video/BV1boHx61Enc/) · [Character voices demo](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
|
||||
* 💬 **Reddit**: [The post on r/comfyui](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
|
||||
|
||||
### 👀 See it in action
|
||||
### 💡 What it helps with
|
||||
|
||||
*The pictures show the Chinese interface; English is one switch away in Settings.*
|
||||
**1. "Which LoRA was that again?"** A file called `download (3).safetensors` tells you nothing. Press **Scan** once: every model is looked up on Civitai by its file hash and gets its real name, cover, trigger words and base model. From then on you pick models by their covers.
|
||||
|
||||
**1. Scan once, then pick models by their covers.** Press **Scan** and every model is looked up on Civitai by its file hash, so even a file called `下载 (3).safetensors` gets its real name, cover, trigger words and base model.
|
||||
<p align="center"><img src="docs/images/en-scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
|
||||
**2. Someone else's workflow opens with red nodes.** Model Check says which models are missing. When the workflow carries model fingerprints (the plugin writes them into the workflows and images of everyone who has it, unless they turn it off), the ones you already have under another name or in another folder go back in with one press, and the rest can be downloaded from Civitai, Hugging Face or ComfyUI-Manager's model list, each download checked against its fingerprint (a Manager entry that only shares the file name is marked so, for you to decide). A workflow without fingerprints can only be matched by file name: a file of the same name elsewhere on your computer is offered for you to confirm.
|
||||
|
||||
**2. Open someone else's workflow without hunting for models.** When models are missing, a bar over the canvas says so. One press puts back the ones you already have under another name or in another folder; the red outlines go away.
|
||||
<p align="center"><img src="docs/images/en-model-check.webp" width="760" alt="Model Check puts back three missing models with one press"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="Model Check puts back three missing models with one press"></p>
|
||||
**3. "How did I make this picture?"** Search your outputs by prompt words, model or LoRA, then drag the picture onto the canvas: its workflow comes back.
|
||||
|
||||
**3. Find an old image and get its workflow back.** Search the gallery by prompt words, model or LoRA, then drag the image onto the canvas.
|
||||
<p align="center"><img src="docs/images/en-gallery.webp" width="760" alt="Gallery search, then drag an image onto the canvas"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="Gallery search, then drag an image onto the canvas"></p>
|
||||
**4. The same model, LoRAs and prompt, again and again.** Pick those nodes on the canvas, right-click → **AMB** → **Save as combo**, and they are kept as a card beside the canvas. Next time press **Put on canvas** (or drag the card) and the wired group comes back where you click; nothing already on the canvas changes. Models and prompts in a combo can be changed later.
|
||||
|
||||
**4. Keep a model + LoRA + prompt combo and lay it down in one move.** Drag a combo card onto empty canvas: a wired group of nodes appears, and nothing already on the canvas changes.
|
||||
<p align="center"><img src="docs/images/en-combo.webp" width="760" alt="Picked nodes saved as a combo, then put back on the canvas as wired nodes"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/combo.webp" width="760" alt="A combo card dragged onto the canvas becomes wired nodes"></p>
|
||||
**5. Long prompts are hard to edit.** Select a prompt node and Prompt Studio shows its text as tags: click to weight, drag to reorder, type to add (Chinese and other languages become English).
|
||||
|
||||
**5. Edit prompts as tags.** Select a prompt node and Prompt Studio opens its text as tags: click to weight, drag to reorder, type to add (Chinese and other languages become English).
|
||||
<p align="center"><img src="docs/images/en-prompt-studio.webp" width="760" alt="Prompt Studio editing a prompt node as tags"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="Prompt Studio editing a prompt node as tags"></p>
|
||||
**6. Characters that talk.** With the companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS): GPT-SoVITS characters with a reference clip per emotion, and a script page to voice them.
|
||||
|
||||
**6. See what the plugin changed, and undo it.** Every change the plugin makes is listed in Activity, with **Undo** and **Find on canvas**.
|
||||
<p align="center"><img src="docs/images/en-voices.webp" width="760" alt="Character voices page"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/activity.webp" width="560" alt="Activity log with an undo button"></p>
|
||||
**Changed your mind?** Everything the plugin changes is listed in **Activity**, with **Undo** and **Find on canvas**.
|
||||
|
||||
**7. Give characters a voice** (with [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)): GPT-SoVITS characters with a reference clip per emotion, and a script page to voice them.
|
||||
<p align="center"><img src="docs/images/en-activity.webp" width="620" alt="Activity log with an undo button"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/voices.webp" width="760" alt="Character voices page"></p>
|
||||
**Light on your computer**: the plugin itself loads no models, so it takes no graphics memory from image generation; the browser shows small thumbnails instead of full images, and its pictures are released a minute and a half after you close it. The voice models of Anomalous_TTS give their graphics memory back whenever ComfyUI needs it for image generation, and all of it on **Unload models**.
|
||||
|
||||
### 🌟 What it does
|
||||
### 📦 Install
|
||||
|
||||
1. In **ComfyUI Manager**, search `Anomalous Model Browser`, install it and restart ComfyUI. Or clone it into `custom_nodes`:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. Press **Shift + B** to open it. On **Home**, **Scan model folders** fills in covers and trigger words, and **Tour the interface** points at each part of the window.
|
||||
3. Optional: for character voices, also install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) (search `Anomalous TTS` in ComfyUI Manager).
|
||||
|
||||
Scanning needs to reach Civitai. Without it, scan offline: base models are read from the files, and a later online scan fills in the rest (see **Network and backups** below).
|
||||
|
||||
<details>
|
||||
<summary><b>📖 Every page and tool, in detail</b></summary>
|
||||
|
||||
Every page has an icon on the rail at the left of the window.
|
||||
|
||||
| Page | What you do there |
|
||||
| :--- | :--- |
|
||||
| **🏠 Home** | Pick what you want to do: a card per task, first steps (scan your model folders, a guided tour of the interface) and your latest activity. |
|
||||
| **🕘 Activity** | Everything the plugin changed, by day: on the canvas (which node settings changed from what to what, nodes added or removed, workflows opened) and in files (models, covers, recipes, materials, notes, images, audio). **Find on canvas** jumps to the node; a canvas change can be undone with **Undo** until something changes it again, the rest is recorded only. Your own edits are not listed. |
|
||||
| **🕘 Activity** | Everything the plugin changed, by day: on the canvas (which node settings changed from what to what, nodes added or removed, workflows opened) and in files (models, covers, workflows, materials, notes, images, audio). **Find on canvas** jumps to the node; a canvas change can be undone with **Undo** until something changes it again, the rest is recorded only. Your own edits are not listed. |
|
||||
| **📦 Models** | Your models with covers, trigger words and base model. Chips at the top switch between types (Checkpoint, LoRA, VAE…) and list a whole type, subfolders included; the folder list narrows it to one folder. **+** adds a loader node to the canvas; edit a model's name, notes and cover, or scan just that model. |
|
||||
| **🖼️ Gallery** | Your ComfyUI `output` folder. Search by prompt, model, LoRA, seed, file name or model hash; open an image to see the parameters it was made with, or drag it onto the canvas to get its workflow back. **☆** keeps a good one as a whole workflow, a combo (model, LoRAs, prompt) or just its prompts. |
|
||||
| **🪡 Workflows** | Workflow recipes: save a whole workflow or a part of one together with its models, cover, notes and parameters. Each card shows whether the models are on this computer; drag it onto the canvas to load it. Versions can be compared and restored. |
|
||||
| **🪡 Workflows** | Saved workflows: save a whole workflow or a part of one together with its models, cover, notes and parameters. Each card shows whether the models are on this computer; drag it onto the canvas to load it. Versions can be compared and restored. A card's export button makes a **recipe package** (.zip) to give to someone; dropped into their ⇅, it becomes a workflow card. |
|
||||
| **🧩 Combos** | A main model, a few LoRAs and a prompt you like, kept together. **Put on canvas** (or dragging the card onto the canvas) lays them out as a wired group of nodes; nothing already on the canvas changes. |
|
||||
| **✍️ Prompts** | Opens Prompt Studio beside the canvas. Select a prompt node and its prompt boxes appear as tags, edited in place: click a tag to make it stronger or weaker, double-click to edit, drag to reorder, type to add (Chinese, Japanese and other languages are translated to English), and show each tag's meaning in Chinese, Japanese, Korean and other languages. Click a card (a few common ones, plus your saved prompts) to add it, or drag it straight onto a prompt box on the canvas (outlined green for positive, red for negative) or onto empty canvas for a new prompt node. With no prompt node selected you write a draft. |
|
||||
| **✍️ Prompts** | Prompt Studio, a page of the browser; **Beside the canvas** moves it next to the canvas to see both. Select a prompt node and its prompt boxes appear as tags, edited in place: click a tag to make it stronger or weaker, double-click to edit, drag to reorder, type to add (Chinese, Japanese and other languages are translated to English), and show each tag's meaning in Chinese, Japanese, Korean and other languages. Click a card (a few common ones, plus your saved prompts) to add it, or drag it straight onto a prompt box on the canvas (outlined green for positive, red for negative) or onto empty canvas for a new prompt node. With no prompt node selected you write a draft. |
|
||||
| **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) installed: import GPT-SoVITS characters, pick a reference clip per emotion, fix pronunciations, write a script under Voice-over on the same page and generate it right there; generated audio is listed in the audio gallery. Without it, the page explains how to install it and nothing else depends on it. |
|
||||
|
||||
Tools on the rail:
|
||||
|
||||
* **Scan** reads your model folders and fetches covers, trigger words and base models from Civitai (by file hash, no extra Python packages).
|
||||
* **Model Check** (formerly Model Doctor) checks each workflow you open: when models are missing, a bar over the canvas says so and puts back, with one press, the ones you have under another name or folder (recognised by hash and file size); its page lists the rest with what you can do.
|
||||
* **Model Check** (formerly Model Doctor) checks each workflow you open: when models are missing, a bar over the canvas says so and puts back, with one press, the ones you have under another name or folder, recognised by the fingerprints (hash and file size) the plugin records in workflows and images (**Settings → Record model fingerprints in workflows**, on by default); without them, a file of the same name is offered for you to confirm. Its page lists the rest with what you can do.
|
||||
* **Current node** (formerly Node Assistant) shows the model of the node you select, lets you swap it from the covers or insert a LoRA, and lists every set of values saved for that kind of node with exactly what applying it would change; saved values can be deleted there.
|
||||
* **Each tool on its page**: workflow share codes (import / export) on Workflows, Model Sources (where each model can be downloaded) on Models and in Model Check, translation on the prompt boxes in Current node.
|
||||
* **Each tool on its page**: share and import on Workflows (⇅: a short share code of the canvas workflow, in Chinese characters or letters; paste a code, or drop a workflow file, an image or a backup .zip), Model Sources (where each model can be downloaded) on Models and in Model Check, translation on the prompt boxes in Current node.
|
||||
* **Settings** (at the bottom): language, font size, thumbnails, video covers, which model folders to show, and feedback. Home and Settings have **Report a problem**, which opens a GitHub issue with your versions and GPU already filled in.
|
||||
|
||||
**Light on your computer**: the plugin itself loads no models, so it takes no graphics memory from image generation; the browser shows small thumbnails instead of full images, and its pictures are released a minute and a half after you close it. The voice models of Anomalous_TTS give their graphics memory back whenever ComfyUI needs it for image generation, and all of it on **Unload models**.
|
||||
**AI apps (MCP)**: Claude, Cursor, Cherry Studio and other apps that support MCP can use your library through `http://127.0.0.1:8188/anomalous/mcp`: answer questions about your models, outputs and saved prompts, and change the open workflow for you (prompts, models, LoRAs, combos, missing models), each change one Ctrl+Z step. Local only; setup in [docs/guides/mcp.md](docs/guides/mcp.md).
|
||||
|
||||
### 📦 Quick Installation
|
||||
</details>
|
||||
|
||||
1. Open your terminal in the ComfyUI `custom_nodes` directory:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. Restart ComfyUI. *(Alternatively, install via **ComfyUI Manager** by searching for `Anomalous Model Browser`)*
|
||||
3. For character voices, also install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) the same way. It is optional.
|
||||
4. **Updating**: click the **!** button at the top right of the browser; it shows the current version, and **Check for updates / switch version** opens the version panel. Update to the newest release, go back to an earlier one or return to the latest, then restart ComfyUI. The plugin never checks for updates on its own.
|
||||
<details>
|
||||
<summary><b>⚙️ Opening, shortcuts and updating</b></summary>
|
||||
|
||||
Open the browser with **Shift + B** (again to close), the floating button on the canvas, or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** changes the shortcut, the language and how the browser is opened (floating button, a button next to Run, or the Extensions menu only).
|
||||
|
||||
The first time you open it, start on **Home**: **Scan model folders** fills in covers and trigger words, and **Tour the interface** points at each part of the window.
|
||||
**Updating**: click the **!** button at the top right of the browser; it shows the current version, and **Check for updates / switch version** opens the version panel. Update to the newest release, go back to an earlier one or return to the latest, then restart ComfyUI. The plugin never checks for updates on its own.
|
||||
|
||||
> [!NOTE]
|
||||
> **Network:** Scan looks models up on Civitai and prompt translation uses Google Translate, so both need to reach those sites (from mainland China usually through a proxy). Without them, pick **Offline scan**: base models are read from the files, without covers or trigger words, and a later online scan fills in the rest. Browsing models, outputs, workflows and combos needs no network.
|
||||
</details>
|
||||
|
||||
> [!WARNING]
|
||||
> **Keep a copy of your own data:** Workflow Recipes, combos, saved prompts and node values, and parameter presets live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder. A copy of them before updating the plugin is a good habit.
|
||||
<details>
|
||||
<summary><b>🌐 Network and backups</b></summary>
|
||||
|
||||
**On a cloud server (AutoDL, RunPod…):** fully supported on overseas servers; on servers in mainland China only in part (Civitai is mostly out of reach there: lookups are unreliable, covers rarely download). Not fully tested yet (one AutoDL server so far). The guide has a section for AI assistants to walk you through it. Opened from another computer, the plugin is view only by default. Open ComfyUI through an SSH tunnel, or see [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
|
||||
|
||||
**Network:** Scan looks models up on Civitai and prompt translation uses Google Translate, so both need to reach those sites (from mainland China usually through a proxy; when civitai.com cannot be reached the plugin tries Civitai's other address, civitai.red, which some networks reach, a cloud server in mainland China among them: names and trigger words come, covers usually not). Without them, pick **Offline** on the scan page (an online scan does the same by itself when it cannot connect): base models are read from the files, without covers or trigger words, and a later online scan fills in the rest. Downloading missing models needs the network too; for Hugging Face, **Settings → Model downloads** can use the hf-mirror.com mirror (Civitai has none). Browsing models, outputs, workflows and combos needs no network.
|
||||
|
||||
**Keep a copy of your own data:** **Settings → Backup → Export** packs workflows, combos, saved prompts and node values, parameter presets, and the names, notes and covers you gave your models into one .zip; keep it on a cloud drive or a USB stick, and **Import** puts it back, also on another computer. (They live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder.)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -124,94 +139,115 @@ The first time you open it, start on **Home**: **Scan model folders** fills in c
|
||||
>
|
||||
> 插件改动过什么,都能在“操作记录”里查到。
|
||||
|
||||
### 🎬 视频演示与教程
|
||||
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
|
||||
### 🎬 视频与讨论
|
||||
* 📺 **哔哩哔哩 (Bilibili)**:[功能演示](https://www.bilibili.com/video/BV1boHx61Enc/) · [角色配音演示](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
|
||||
* 💬 **Reddit**:[r/comfyui 上的介绍帖](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
|
||||
|
||||
### 👀 看图上手
|
||||
### 💡 它能帮你解决什么
|
||||
|
||||
**1. 扫描一次,以后看封面挑模型。** 点 **扫描**,每个模型都按文件哈希去 C 站认一遍。就算文件叫 `下载 (3).safetensors`,也能显示出它真正的名字、封面、触发词和底模。
|
||||
**1. 模型太多,认不出谁是谁。** 文件叫 `下载 (3).safetensors`,根本看不出是什么。点一次 **扫描**,每个模型都按文件哈希去 C 站认一遍,显示出它真正的名字、封面、触发词和底模。以后看封面挑模型就行。
|
||||
|
||||
<p align="center"><img src="docs/images/scan.webp" width="520" alt="扫描结果:每个文件都在 C 站认出来了"></p>
|
||||
|
||||
**2. 打开别人的工作流,不用自己找模型。** 缺模型时画布上方会提示。你电脑里其实有、只是改了名或换了文件夹的,点一下就换上,红框随之消失。
|
||||
**2. 打开别人的工作流,一片红框。** 模型检查会告诉你缺哪些模型。如果工作流里带着模型指纹(装了本插件的人出图、保存工作流时会自动记下,除非他在设置里关掉),你电脑里其实有、只是改了名或换了文件夹的,点一下就换上;真没有的可以从 C 站、Hugging Face 或 ComfyUI-Manager 的模型清单下载,下载完按指纹核对过才用(Manager 清单里只是同名、没法核对的,会标出来让你决定)。没有指纹的工作流只能按文件名找:别处有同名文件,会列出来让你确认。
|
||||
|
||||
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="模型检查一键换上 3 个缺失的模型"></p>
|
||||
|
||||
**3. 找到以前的图,拿回当时的工作流。** 在图库里按提示词、模型或 LoRA 搜,再把图拖到画布上。
|
||||
**3. 这张图当时是怎么出的?** 在图库里按提示词、模型或 LoRA 搜,再把图拖到画布上,当时的工作流就回来了。
|
||||
|
||||
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="图库搜索后把图拖到画布"></p>
|
||||
|
||||
**4. 存下“模型 + LoRA + 提示词”的搭配,一拖就用。** 把搭配卡片拖到画布空白处,就放出一组连好线的节点,画布上原有的东西不动。
|
||||
**4. 每次都要重新配一遍模型、LoRA 和提示词。** 在画布上选中这几个节点,右键 → **AMB** → **存为搭配**,它们就成了画布旁的一张卡片。下次点 **放到画布**(或者直接拖卡片),点哪里,那组连好线的节点就出现在哪里,画布上原有的东西不动。搭配里的模型和提示词以后还能换。
|
||||
|
||||
<p align="center"><img src="docs/images/combo.webp" width="760" alt="搭配卡片拖到画布变成连好线的节点"></p>
|
||||
<p align="center"><img src="docs/images/combo.webp" width="760" alt="选中的节点存为搭配,再放回画布变成连好线的节点"></p>
|
||||
|
||||
**5. 像搭积木一样改提示词。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文)。
|
||||
**5. 提示词太长,改起来麻烦。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文,比如输入“蓝宝石”加进去的是 sapphire)。
|
||||
|
||||
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="提示词工坊按词块编辑提示词节点"></p>
|
||||
|
||||
**6. 插件改过什么都查得到,还能撤销。** 插件做的每一处改动都列在“操作记录”里,带 **撤销** 和 **在画布上找到**。
|
||||
|
||||
<p align="center"><img src="docs/images/activity.webp" width="560" alt="操作记录和撤销按钮"></p>
|
||||
|
||||
**7. 给角色配音**(需要 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)):导入 GPT-SoVITS 角色,每种情绪一段参考音频,在配音页写台词直接生成。
|
||||
**6. 想让角色开口说话。** 配合配套节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS):导入 GPT-SoVITS 角色,每种情绪一段参考音频,在配音页写台词直接生成。
|
||||
|
||||
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面"></p>
|
||||
|
||||
### 🌟 能做什么
|
||||
**改错了也不怕。** 插件做的每一处改动都列在“操作记录”里,带 **撤销** 和 **在画布上找到**。
|
||||
|
||||
窗口左侧的图标栏,每个页面一个图标。
|
||||
|
||||
| 页面 | 在这里做什么 |
|
||||
| :--- | :--- |
|
||||
| **🏠 主页** | 想做什么就点哪张卡片。还有上手第一步(扫描模型文件夹、界面导览)和最近的操作记录。 |
|
||||
| **🕘 记录** | 按天列出插件做过的每一处改动。<br>画布上:哪个节点的设置从什么改成了什么、加了或删了哪些节点、打开了哪个工作流。<br>文件上:模型、封面、配方、素材、笔记、图片、音频。<br>**在画布上找到** 可以直接跳到那个节点;画布上的改动在没被再改过之前可以点 **撤销**,其余只记录。你自己在画布上的修改不会记进来。 |
|
||||
| **📦 模型** | 模型带封面、触发词和底模信息。<br>顶部标签切换类型(Checkpoint、LoRA、VAE…),连同子文件夹一起列出;左侧文件夹列表可以只看某个文件夹。<br>**+** 一键在画布上创建加载节点;也可以改名、写备注、换封面,或只扫描这一个模型。 |
|
||||
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。<br>可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。<br>点开图片能看到生成参数;拖到画布上可以还原当时的工作流。<br>觉得好就点 **☆**:存成整个工作流、搭配(模型 + LoRA + 提示词),或者只存提示词。 |
|
||||
| **🪡 工作流** | 工作流配方:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。<br>卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。<br>可以比较、恢复历史版本。 |
|
||||
| **🧩 搭配** | 把常用的主模型、几个 LoRA 和一段提示词存成一个搭配。<br>按 **放到画布**(或把卡片拖到画布上),就放出一组连好线的节点;画布上原有的东西不动。 |
|
||||
| **✍️ 提示词** | 在画布旁边打开提示词工坊。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。<br>点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 |
|
||||
| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:<br>导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。<br>生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 |
|
||||
|
||||
图标栏上的工具:
|
||||
|
||||
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
|
||||
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件(按哈希和文件大小认)一键换上,其余的在检查页里自己挑或去 Civitai 找。
|
||||
* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用;存下的参数也在这里删除。
|
||||
* **工具回到各自的页面**:工作流分享码(导入导出)在“工作流”页,模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
|
||||
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
|
||||
<p align="center"><img src="docs/images/activity.webp" width="560" alt="操作记录和撤销按钮"></p>
|
||||
|
||||
**不占你的资源**:
|
||||
- 插件本身不加载任何模型,不和生图抢显存。
|
||||
- 浏览时显示的是小缩略图,不加载原图;关闭窗口一分半后,图片也会释放掉。
|
||||
- Anomalous_TTS 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 时全部释放。
|
||||
|
||||
### 📦 快速安装
|
||||
### 📦 安装
|
||||
|
||||
1. 在 ComfyUI 的 `custom_nodes` 目录下打开终端执行:
|
||||
1. 在 **ComfyUI Manager** 里搜索 `Anomalous Model Browser`,安装后重启 ComfyUI。也可以在 `custom_nodes` 目录下手动安装:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 安装*)
|
||||
3. 想用角色配音的话,用同样的方法再装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)。不装也不影响其他功能。
|
||||
4. **更新插件**:
|
||||
- 点窗口右上角的 **!**,可以看到当前版本;
|
||||
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
|
||||
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
|
||||
2. 按 **Shift + B** 打开。在 **主页** 点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
|
||||
3. 想用角色配音的话,再装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)(在 ComfyUI Manager 搜索 `Anomalous TTS`)。不装也不影响其他功能。
|
||||
|
||||
扫描要连 C 站。连不上也能用:选离线扫描,只从模型文件推断底模,以后能连上时再扫一次会自动补上(见下面的“网络和备份”)。
|
||||
|
||||
<details>
|
||||
<summary><b>📖 每个页面和工具的详细说明</b></summary>
|
||||
|
||||
窗口左侧的图标栏,每个页面一个图标。
|
||||
|
||||
| 页面 | 在这里做什么 |
|
||||
| :--- | :--- |
|
||||
| **🏠 主页** | 想做什么就点哪张卡片。还有上手第一步(扫描模型文件夹、界面导览)和最近的操作记录。 |
|
||||
| **🕘 记录** | 按天列出插件做过的每一处改动。<br>画布上:哪个节点的设置从什么改成了什么、加了或删了哪些节点、打开了哪个工作流。<br>文件上:模型、封面、工作流、素材、笔记、图片、音频。<br>**在画布上找到** 可以直接跳到那个节点;画布上的改动在没被再改过之前可以点 **撤销**,其余只记录。你自己在画布上的修改不会记进来。 |
|
||||
| **📦 模型** | 模型带封面、触发词和底模信息。<br>顶部标签切换类型(Checkpoint、LoRA、VAE…),连同子文件夹一起列出;左侧文件夹列表可以只看某个文件夹。<br>**+** 一键在画布上创建加载节点;也可以改名、写备注、换封面,或只扫描这一个模型。 |
|
||||
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。<br>可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。<br>点开图片能看到生成参数;拖到画布上可以还原当时的工作流。<br>觉得好就点 **☆**:存成整个工作流、搭配(模型 + LoRA + 提示词),或者只存提示词。 |
|
||||
| **🪡 工作流** | 存下的工作流:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。<br>卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。<br>可以比较、恢复历史版本。<br>卡片上的导出按钮能打成**配方包**(压缩包)发给别人,对方拖进 ⇅ 就多一张工作流卡片。 |
|
||||
| **🧩 搭配** | 把常用的主模型、几个 LoRA 和一段提示词存成一个搭配。<br>按 **放到画布**(或把卡片拖到画布上),就放出一组连好线的节点;画布上原有的东西不动。 |
|
||||
| **✍️ 提示词** | 提示词工坊,浏览器里的一个页面;点“停到画布旁”可以移到画布旁边,边看画布边改。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。<br>点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 |
|
||||
| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:<br>导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。<br>生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 |
|
||||
|
||||
图标栏上的工具:
|
||||
|
||||
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
|
||||
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件一键换上,靠的是插件记在工作流和图片里的模型指纹(哈希和文件大小;**设置 → 在工作流里记下模型指纹**,默认打开);没有指纹时,同名文件会列出来让你确认。其余的在检查页里自己挑或去 Civitai 找。
|
||||
* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用;存下的参数也在这里删除。
|
||||
* **工具回到各自的页面**:分享与导入在“工作流”页(⇅:把画布上的工作流变成一段短短的分享码,有汉字码和字母码;也能粘贴分享码,或拖入工作流文件、图片、备份压缩包),模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
|
||||
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
|
||||
|
||||
**接入 AI 软件(MCP)**:Claude、Cursor、Cherry Studio 等支持 MCP 的 AI 软件,可以通过 `http://127.0.0.1:8188/anomalous/mcp` 查你的模型、出图和存下的提示词,回答相关问题;也能帮你改当前工作流(提示词、模型、LoRA、搭配、缺失的模型),每次改动都能用 Ctrl+Z 撤销。只限本机。设置方法见 [docs/guides/mcp.md](docs/guides/mcp.md)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>⚙️ 打开方式、快捷键和更新</b></summary>
|
||||
|
||||
打开方式有三种:按 **Shift + B**(再按一次关闭)、点击画布上的悬浮按钮,或者从 ComfyUI 菜单 **扩展 → Anomalous Model Browser** 打开。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 里,可以修改快捷键、插件语言和打开方式(悬浮按钮、运行按钮旁的按钮,或只用扩展菜单)。
|
||||
|
||||
第一次打开时会停在 **主页**:点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
|
||||
**更新插件**:
|
||||
- 点窗口右上角的 **!**,可以看到当前版本;
|
||||
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
|
||||
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
|
||||
|
||||
> [!NOTE]
|
||||
> **关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描时选 **离线扫描**,只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。浏览模型、图库、工作流和搭配都不用联网。
|
||||
</details>
|
||||
|
||||
> [!WARNING]
|
||||
> **备份自己的数据:** 工作流配方、搭配、存下的提示词和节点参数、参数预设存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。更新插件前顺手备份一份,更安心。
|
||||
<details>
|
||||
<summary><b>🌐 网络和备份</b></summary>
|
||||
|
||||
**在云服务器上(AutoDL 等):** 海外的云服务器完整支持;国内的云服务器只能部分支持(多半连不上 Civitai,查模型不稳定,封面基本下不来)。目前还没有完整测过(只实测过一台 AutoDL 服务器),用的时候请留意。说明里有一节写给 AI 看的步骤,把链接丢给 AI 就能让它带你做。从别的电脑打开时插件默认只能浏览。用 SSH 隧道打开就能照常用,或者见 [docs/guides/cloud-server.md](docs/guides/cloud-server.md)。
|
||||
|
||||
**关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描页选 **离线**(联网扫描连不上时也会自动改成这样),只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。下载缺失的模型也要联网:Hugging Face 的模型可以在 **设置 → 模型下载** 里打开“Hugging Face 用国内镜像”,不用代理;C 站的没有镜像。(civitai.com 连不上时插件会自动试 Civitai 的另一个地址 civitai.red,有的网络能连上,比如国内的云服务器,这时能认出模型名字和触发词,封面多半还是下不来。)浏览模型、图库、工作流和搭配都不用联网。
|
||||
|
||||
**备份自己的数据:** **设置 → 备份 → 导出**,把工作流、搭配、存下的提示词和节点参数、参数预设,以及你给模型改的名字、备注和封面打成一个压缩包,放到云端硬盘或 U 盘里;换电脑或重装后用 **导入** 恢复。(它们存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters`。)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 🧪 Tests (测试)
|
||||
|
||||
The plugin's tests are in `tests/`; run them all with `node tools/run_tests.mjs` from the plugin folder. See [docs/guides/testing.md](docs/guides/testing.md).
|
||||
插件的测试在 `tests/` 里,在插件文件夹运行 `node tools/run_tests.mjs` 一次跑完。改代码或提 PR 之前跑一遍,说明见 [docs/guides/testing.md](docs/guides/testing.md)。
|
||||
|
||||
### 📝 License & Branding (开源与品牌声明)
|
||||
|
||||
* **Code License (代码授权)**: The source code is released under the [MIT License](LICENSE). 本项目源代码基于 MIT 许可证开源,可自由使用、修改与分发。
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
from .api import setup_routes
|
||||
from .api.usage_ledger import note_prompt
|
||||
from server import PromptServer
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
@@ -7,6 +8,8 @@ WEB_DIRECTORY = "./web"
|
||||
# 注册 API 路由
|
||||
if hasattr(PromptServer, "instance") and PromptServer.instance is not None:
|
||||
setup_routes(PromptServer.instance.app)
|
||||
# Each queued run's nodes and models, for the Tidy tab's "Not used lately" (api/usage_ledger.py).
|
||||
PromptServer.instance.add_on_prompt_handler(note_prompt)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
+32
-4
@@ -1,16 +1,20 @@
|
||||
from . import (
|
||||
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
|
||||
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary,
|
||||
local_guard, translation_routes, version_manager,
|
||||
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, quick_index, node_material, recipe_packages, recipes, scan_choice, scan_report, scan_summary,
|
||||
local_guard, lora_info, mcp_server, model_download, model_import, model_placement, translation_routes, version_manager, tts_version, backup,
|
||||
network_check, model_heads, model_usage, usage_ledger, sidecar_layout, ai_tasks_routes,
|
||||
)
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
from .notebooks import *
|
||||
from .parameters import *
|
||||
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
EXTENSION_STATIC_PREFIX = '/extensions/Anomalous_Model_Browser/'
|
||||
# ComfyUI serves the web folder under the plugin folder's own name, whatever it is called.
|
||||
EXTENSION_STATIC_PREFIX = f'/extensions/{os.path.basename(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))}/'
|
||||
|
||||
|
||||
@web.middleware
|
||||
@@ -27,18 +31,22 @@ async def no_cache_extension_middleware(request, handler):
|
||||
return response
|
||||
|
||||
def setup_routes(app):
|
||||
model_heads.keep_on_disk()
|
||||
if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares:
|
||||
app.middlewares.append(no_cache_extension_middleware)
|
||||
# Other devices on the network may look; only this computer may change things (local_guard.py).
|
||||
if hasattr(app, 'middlewares') and local_guard.local_only_middleware not in app.middlewares:
|
||||
app.middlewares.append(local_guard.local_only_middleware)
|
||||
app.router.add_get('/anomalous/access', local_guard.api_access)
|
||||
try:
|
||||
folder_types.upgrade_folder_defaults()
|
||||
except Exception as error:
|
||||
print(f"[Anomalous Browser] Could not update the shown model folders: {error}")
|
||||
app.router.add_get('/anomalous/folders', model_catalog.api_get_folders)
|
||||
app.router.add_get('/anomalous/all_folder_types', folder_types.api_get_all_folder_types)
|
||||
app.router.add_get('/anomalous/models', model_catalog.api_get_models)
|
||||
app.router.add_get('/anomalous/type_models', model_type_listing.api_get_type_models)
|
||||
app.router.add_get('/anomalous/all_scan_models', model_catalog.api_get_all_scan_models)
|
||||
app.router.add_get('/anomalous/batch_select', model_catalog.api_batch_select)
|
||||
app.router.add_get('/anomalous/image', media_routes.api_serve_image)
|
||||
app.router.add_get('/anomalous/card_cache', media_routes.api_card_cache)
|
||||
app.router.add_post('/anomalous/card_cache/clear', media_routes.api_clear_card_cache)
|
||||
@@ -57,10 +65,12 @@ def setup_routes(app):
|
||||
# Parameter Routes
|
||||
app.router.add_get('/anomalous/parameters', api_get_parameters)
|
||||
app.router.add_get('/anomalous/parameters/by_node_type', api_get_parameters_by_type)
|
||||
app.router.add_get('/anomalous/parameters/signatures', api_get_parameter_set_signatures)
|
||||
app.router.add_post('/anomalous/save_parameter', api_save_parameter)
|
||||
app.router.add_post('/anomalous/rename_parameter', api_rename_parameter)
|
||||
app.router.add_post('/anomalous/delete_parameter', api_delete_parameter)
|
||||
app.router.add_get('/anomalous/parameter_gallery', api_get_parameter_gallery)
|
||||
app.router.add_get('/anomalous/parameter_pictures', api_get_parameter_pictures)
|
||||
|
||||
# Recipe Routes
|
||||
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
|
||||
@@ -71,6 +81,7 @@ def setup_routes(app):
|
||||
app.router.add_get('/anomalous/recipe_gallery_compare', recipes.api_get_recipe_gallery_compare)
|
||||
app.router.add_post('/anomalous/save_recipe', recipes.api_save_recipe)
|
||||
app.router.add_post('/anomalous/update_recipe', recipes.api_update_recipe)
|
||||
app.router.add_post('/anomalous/recipe_focus', recipes.api_set_recipe_focus)
|
||||
app.router.add_post('/anomalous/set_recipe_gallery_cover', recipes.api_set_recipe_gallery_cover)
|
||||
app.router.add_post('/anomalous/delete_recipe', recipes.api_delete_recipe)
|
||||
app.router.add_get('/anomalous/recipe_history', recipes.api_get_recipe_history)
|
||||
@@ -100,6 +111,7 @@ def setup_routes(app):
|
||||
app.router.add_post('/anomalous/delete_gallery_image', gallery_routes.api_delete_gallery_image)
|
||||
app.router.add_get('/anomalous/output_thumbnail', gallery_routes.api_output_thumbnail)
|
||||
app.router.add_get('/anomalous/kept_images', kept_images.api_kept_images)
|
||||
app.router.add_get('/anomalous/quick_index', quick_index.api_quick_index)
|
||||
app.router.add_get('/anomalous/resolve_hash', model_resolution.api_resolve_hash)
|
||||
app.router.add_post('/anomalous/resolve_hash_batch', model_resolution.api_resolve_hash_batch)
|
||||
app.router.add_get('/anomalous/all_hashes', model_resolution.api_get_all_hashes)
|
||||
@@ -109,11 +121,16 @@ def setup_routes(app):
|
||||
app.router.add_post('/anomalous/update_metadata', model_metadata.api_update_metadata)
|
||||
app.router.add_post('/anomalous/set_custom_cover', model_media.api_set_custom_cover)
|
||||
app.router.add_post('/anomalous/upload_custom_cover', model_media.api_upload_custom_cover)
|
||||
app.router.add_get('/anomalous/model_covers', model_media.api_model_covers)
|
||||
app.router.add_get('/anomalous/model_images', media_routes.api_get_model_images)
|
||||
app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews)
|
||||
app.router.add_get('/anomalous/scan_summary', scan_summary.api_scan_summary)
|
||||
app.router.add_get('/anomalous/scan_matched', scan_summary.api_scan_matched)
|
||||
app.router.add_get('/anomalous/scan_pickable', scan_summary.api_scan_pickable)
|
||||
app.router.add_get('/anomalous/last_scan', scan_report.api_last_scan)
|
||||
app.router.add_get('/anomalous/scan_model', scan_report.api_scan_model)
|
||||
app.router.add_post('/anomalous/scan_choice', scan_choice.api_set_scan_choice)
|
||||
app.router.add_get('/anomalous/lora_info', lora_info.api_lora_info)
|
||||
|
||||
# Audio & Voice Studio Routes
|
||||
app.router.add_get('/anomalous/audio_stream', audio_catalog.api_serve_audio)
|
||||
@@ -122,4 +139,15 @@ def setup_routes(app):
|
||||
app.router.add_get('/anomalous/audio_previews', audio_catalog.api_get_audio_previews)
|
||||
app.router.add_post('/anomalous/save_audio_preview', audio_catalog.api_save_audio_preview)
|
||||
version_manager.register_routes(app)
|
||||
tts_version.register_routes(app)
|
||||
activity_log.register_routes(app)
|
||||
mcp_server.register_routes(app)
|
||||
ai_tasks_routes.register_routes(app)
|
||||
model_download.register_routes(app)
|
||||
network_check.register_routes(app)
|
||||
model_usage.register_routes(app)
|
||||
usage_ledger.register_routes(app)
|
||||
sidecar_layout.register_routes(app)
|
||||
model_import.register_routes(app)
|
||||
model_placement.register_routes(app)
|
||||
backup.register_routes(app)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""What each thing an AI app can do through Anomalous's MCP endpoint is, in one place.
|
||||
|
||||
Every tool (mcp_tools.py reads, mcp_actions.py acts, mcp_task_tools.py the AI's own) has an
|
||||
entry here: its kind (the AI workbench's colour, words and counts; None = not a step of its
|
||||
own), the stage of the work it belongs to (the flow over a task: models, combo, prompt,
|
||||
canvas, run, pictures, voice, library) and its gate (what it touches or costs, for the user's
|
||||
rules on what needs their OK: canvas, gpu, download, network, files; None = nothing to guard).
|
||||
tests/test_ai_tasks.py keeps this catalog and the tools in step.
|
||||
|
||||
The user's own changes in Anomalous (activity log actions) get stages too, so the ones made
|
||||
while an AI works sit in the same flow as its steps (ai_tasks.note_activity).
|
||||
"""
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
Entry = namedtuple("Entry", "kind stage gate")
|
||||
|
||||
STAGES = ("model", "combo", "prompt", "canvas", "run", "picture", "voice", "library")
|
||||
GATES = ("canvas", "gpu", "download", "network", "files")
|
||||
|
||||
# Arguments with which a call also changes the canvas: it then asks as the stricter of its own gate and "canvas".
|
||||
ALSO_CANVAS = {"run_workflow": ("workflow", "prompt", "negative", "seed", "width", "height", "image", "image_node", "values")}
|
||||
|
||||
TOOLS = {
|
||||
# reading
|
||||
"library_overview": Entry("look", "library", None),
|
||||
"search_models": Entry("look", "model", None),
|
||||
"get_model": Entry("look", "model", None),
|
||||
"scan_report": Entry("look", "library", None),
|
||||
"search_images": Entry("look", "picture", None),
|
||||
"get_image_info": Entry("look", "picture", None),
|
||||
"zoom_image": Entry("look", "picture", None),
|
||||
"list_combos": Entry("look", "combo", None),
|
||||
"list_workflows": Entry("look", "canvas", None),
|
||||
"list_saved_prompts": Entry("look", "prompt", None),
|
||||
"search_audio": Entry("look", "voice", None),
|
||||
"recent_activity": Entry("look", "library", None),
|
||||
"describe_canvas": Entry("look", "canvas", None),
|
||||
"get_situation": Entry("look", None, None), # the task, the canvas in brief and the next step (mcp_situation.py)
|
||||
"check_workflow_models": Entry("look", "model", None),
|
||||
"download_status": Entry("look", "model", None),
|
||||
"list_voices": Entry("look", "voice", None),
|
||||
# acting
|
||||
"set_prompt": Entry("canvas", "prompt", "canvas"),
|
||||
"set_model": Entry("canvas", "model", "canvas"),
|
||||
"add_lora": Entry("canvas", "model", "canvas"),
|
||||
"place_combo": Entry("canvas", "combo", "canvas"),
|
||||
"fix_workflow_models": Entry("canvas", "model", "canvas"),
|
||||
"find_nodes": Entry("look", "canvas", None),
|
||||
"add_node": Entry("canvas", "canvas", "canvas"),
|
||||
"add_nodes": Entry("canvas", "canvas", "canvas"),
|
||||
"connect_nodes": Entry("canvas", "canvas", "canvas"),
|
||||
"set_values": Entry("canvas", "canvas", "canvas"),
|
||||
"remove_nodes": Entry("canvas", "canvas", "canvas"),
|
||||
"new_workflow": Entry("canvas", "canvas", "canvas"),
|
||||
"open_workflow": Entry("canvas", "canvas", "canvas"),
|
||||
"run_workflow": Entry("run", "run", "gpu"),
|
||||
"download_missing_models": Entry("download", "model", "download"),
|
||||
"download_model": Entry("download", "model", "download"),
|
||||
"move_model": Entry("act", "model", "files"),
|
||||
"scan_models": Entry("scan", "library", "network"),
|
||||
"speak": Entry("speech", "voice", "gpu"),
|
||||
"open_in_anomalous": Entry("show", "model", None),
|
||||
# the AI's own: notes and panels are steps; the rest only steer the task
|
||||
"report": Entry("note", None, None),
|
||||
"log_decision": Entry("decision", None, None),
|
||||
"log_outside": Entry("outside", None, None), # what it did with other tools (ai_outside.py, which also notes what changed)
|
||||
"show_panel": Entry("panel", None, None),
|
||||
"ask_user": Entry("panel", None, None),
|
||||
"propose_repaint_area": Entry("panel", "picture", None),
|
||||
"show_run_map": Entry("panel", None, None),
|
||||
"begin_task": Entry(None, None, None),
|
||||
"track_run": Entry(None, "run", None), # attaches an existing submission; creates its own run step
|
||||
"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),
|
||||
"app_answer": Entry(None, None, None),
|
||||
}
|
||||
|
||||
# Activity log actions (activity_log.py ROUTE_ACTIONS, and "canvas") the user makes in Anomalous
|
||||
# that belong in the task an AI is working on, with their stage. Downloads, imports, moves, scans
|
||||
# and backups are left out: an AI's own calls can cause those too.
|
||||
USER_ACTIONS = {
|
||||
"canvas": "canvas",
|
||||
"model_edit": "library", "model_cover": "library", "model_delete": "library",
|
||||
"recipe_save": "canvas", "recipe_edit": "canvas", "recipe_delete": "canvas", "recipe_restore": "canvas",
|
||||
"material_save": "prompt", "material_edit": "prompt", "material_delete": "prompt",
|
||||
"preset_save": "run", "preset_rename": "run", "preset_delete": "run",
|
||||
"image_delete": "picture", "audio_delete": "voice", "audio_save": "voice", "voice_settings": "voice",
|
||||
"note_save": "library", "note_delete": "library",
|
||||
}
|
||||
|
||||
|
||||
def entry(tool, read_only=True):
|
||||
"""The catalog entry of `tool`; one not listed is a read, or an act when it is not read-only."""
|
||||
found = TOOLS.get(tool)
|
||||
if found:
|
||||
return found
|
||||
return Entry("look" if read_only else "act", None, None)
|
||||
|
||||
|
||||
def is_step(tool):
|
||||
"""Whether a call of `tool` is a step of its own in a task."""
|
||||
return entry(tool).kind is not None
|
||||
+33
-7
@@ -24,6 +24,7 @@ from .path_utils import atomic_write_json
|
||||
MAX_ENTRIES = 500
|
||||
MAX_LOG_BYTES = 2 * 1024 * 1024 # long canvas entries could otherwise outgrow the JSON writer's limit
|
||||
MAX_VALUE_CHARS = 400
|
||||
MAX_CHANGE_CHARS = 2000 # a before/after value: room for a whole prompt, so its receipt can compare tags
|
||||
DIFF_CONTEXT_CHARS = 60 # kept before the first difference of a long before/after pair
|
||||
MAX_CHANGES = 60
|
||||
|
||||
@@ -66,6 +67,7 @@ ACTIVITY_DETAIL = web.RequestKey("activity_detail", dict) if hasattr(web, "Reque
|
||||
|
||||
_lock = threading.RLock()
|
||||
_entries = None
|
||||
_listeners = [] # called with each new entry once it is saved (ai_tasks.note_activity)
|
||||
|
||||
|
||||
def log_path():
|
||||
@@ -104,19 +106,20 @@ def _text(value):
|
||||
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _short(value):
|
||||
def _short(value, limit=MAX_VALUE_CHARS):
|
||||
text = _text(value)
|
||||
return text if len(text) <= MAX_VALUE_CHARS else text[:MAX_VALUE_CHARS] + '…'
|
||||
return text if len(text) <= limit else text[:limit] + '…'
|
||||
|
||||
|
||||
def _short_values(item, keys):
|
||||
"""`keys` of `item` shortened. A long before/after pair is cut around its first difference
|
||||
instead of at the start, so a change late in a long prompt still shows."""
|
||||
out = {key: _short(item[key]) for key in keys if key in item and item[key] is not None}
|
||||
out = {key: _short(item[key], MAX_CHANGE_CHARS if key in ('before', 'after') else MAX_VALUE_CHARS)
|
||||
for key in keys if key in item and item[key] is not None}
|
||||
if 'before' not in out or 'after' not in out:
|
||||
return out
|
||||
old, new = _text(item['before']), _text(item['after'])
|
||||
if len(old) <= MAX_VALUE_CHARS and len(new) <= MAX_VALUE_CHARS:
|
||||
if len(old) <= MAX_CHANGE_CHARS and len(new) <= MAX_CHANGE_CHARS:
|
||||
return out
|
||||
same = 0
|
||||
while same < min(len(old), len(new)) and old[same] == new[same]:
|
||||
@@ -124,7 +127,7 @@ def _short_values(item, keys):
|
||||
start = max(0, same - DIFF_CONTEXT_CHARS)
|
||||
|
||||
def cut(text):
|
||||
return ('…' if start else '') + text[start:start + MAX_VALUE_CHARS] + ('…' if start + MAX_VALUE_CHARS < len(text) else '')
|
||||
return ('…' if start else '') + text[start:start + MAX_CHANGE_CHARS] + ('…' if start + MAX_CHANGE_CHARS < len(text) else '')
|
||||
out['before'], out['after'] = cut(old), cut(new)
|
||||
return out
|
||||
|
||||
@@ -145,9 +148,29 @@ def add_entry(source, action, target='', detail=None):
|
||||
entries.insert(0, entry)
|
||||
_trim(entries)
|
||||
atomic_write_json(log_path(), entries)
|
||||
for listener in _listeners:
|
||||
try:
|
||||
listener(entry)
|
||||
except Exception: # noqa: BLE001 - a listener must never fail the action recorded
|
||||
logging.getLogger(__name__).warning('Anomalous activity log: a listener failed', exc_info=True)
|
||||
return entry
|
||||
|
||||
|
||||
def add_listener(listener):
|
||||
if listener not in _listeners:
|
||||
_listeners.append(listener)
|
||||
|
||||
|
||||
def tag(entry_id, task_id):
|
||||
"""Names the AI task an entry belongs to (made by the AI, or by the user while it worked)."""
|
||||
with _lock:
|
||||
entries = _load()
|
||||
found = next((entry for entry in entries if entry.get('id') == entry_id), None)
|
||||
if found and found.get('task') != task_id:
|
||||
found['task'] = task_id
|
||||
atomic_write_json(log_path(), entries)
|
||||
|
||||
|
||||
def list_entries(limit=100, source=None):
|
||||
with _lock:
|
||||
entries = _load()
|
||||
@@ -232,7 +255,7 @@ def _clean_changes(changes):
|
||||
if not isinstance(change, dict):
|
||||
continue
|
||||
item = _short_values(change, ('kind', 'node', 'type', 'widget', 'before', 'after'))
|
||||
if item.get('kind') in ('changed', 'added', 'removed', 'opened'):
|
||||
if item.get('kind') in ('changed', 'wired', 'added', 'removed', 'opened'):
|
||||
cleaned.append(item)
|
||||
return cleaned
|
||||
|
||||
@@ -248,7 +271,8 @@ async def api_get_activity(request):
|
||||
|
||||
|
||||
async def api_post_activity(request):
|
||||
"""Canvas changes from the browser: {changes: [...], total, workflow}."""
|
||||
"""Canvas changes from the browser: {changes: [...], total, workflow, via?}; via "mcp" marks
|
||||
changes an AI app made through the MCP endpoint."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
@@ -260,6 +284,8 @@ async def api_post_activity(request):
|
||||
return web.json_response({'error': 'No recognizable changes'}, status=400)
|
||||
total = body.get('total') if isinstance(body.get('total'), int) else len(changes)
|
||||
detail = {'changes': changes, 'total': max(total, len(changes))}
|
||||
if body.get('via') == 'mcp':
|
||||
detail['via'] = 'mcp'
|
||||
entry = await asyncio.get_running_loop().run_in_executor(
|
||||
None, add_entry, 'canvas', 'canvas', body.get('workflow') if isinstance(body.get('workflow'), str) else '', detail)
|
||||
return web.json_response({'entry': entry})
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""How long an AI waits for the user to answer its question (ask_user, a question on a panel).
|
||||
|
||||
One tool call waits at most ai_panels.MAX_WAIT (55 s, under the minute AI apps give a call), so
|
||||
the answer to a question still open says whether to wait on with get_answers: until the time the
|
||||
user set in the Gates window (ai_gates.answer_wait, from when it asked), and while the user is
|
||||
typing an answer (the page posts POST /anomalous/ai_tasks/{id}/typing as they type) until a while
|
||||
after their last key. Then the AI ends its turn and the user brings it back with a line to paste
|
||||
(web/modules/ai_call_lines.js); or, when the user set so (ai_gates.no_answer "recommended"), each
|
||||
open choice the AI marked a recommended option for takes it, marked as such for the user and the
|
||||
AI (recommended_on_timeout), and the AI goes on; not once the user began writing (typed since the
|
||||
question came): they stepped away mid-answer, and it waits for them. Typing is kept in memory only.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from . import ai_gates, ai_panels, ai_tasks
|
||||
|
||||
AFTER_TYPING = 60 # the user typed this recently: their answer is on its way
|
||||
TOOK = ("The user did not answer in time and set that your recommended option goes then: Anomalous took it for "
|
||||
"them (recommended_on_timeout). Go on with it; a question without a recommended option stays open for the user.")
|
||||
STOP = "You waited as long as the user set: end your turn now. They answer in Anomalous and tell you in your chat to go on."
|
||||
WROTE = ("The user began writing an answer, then stepped away: end your turn now. Nothing is chosen for them; they "
|
||||
"finish it in Anomalous and tell you in your chat to go on.")
|
||||
_typed = {} # task id -> time of the user's last key
|
||||
|
||||
|
||||
def take_recommended(task_id, step):
|
||||
"""Answers each open choice of panel step `step` that has a recommended option with it. Returns how many."""
|
||||
taken = 0
|
||||
for block in step["panel"].get("blocks") or []:
|
||||
picked = [option["id"] for option in block.get("options") or [] if option.get("recommended")]
|
||||
if block.get("type") != "choices" or not picked or block.get("id") in (step.get("answers") or {}):
|
||||
continue
|
||||
try:
|
||||
ai_panels.answer(task_id, step["id"], block["id"], choice=picked if block.get("multiple") else picked[:1],
|
||||
extra={"recommended_on_timeout": True})
|
||||
taken += 1
|
||||
except ai_panels.PanelError:
|
||||
pass
|
||||
return taken
|
||||
|
||||
|
||||
def typing(task_id, now=None):
|
||||
"""The user is typing in task `task_id` (an answer or a message). False for no such task."""
|
||||
with ai_tasks._lock:
|
||||
if not ai_tasks.get_task(task_id):
|
||||
return False
|
||||
_typed[task_id] = time.time() if now is None else now
|
||||
return True
|
||||
|
||||
|
||||
def wait_on(panel_id, now=None, allow_recommended=True):
|
||||
"""For a question of panel `panel_id` still open: {keep_waiting, seconds_left?, typing?, note}, or {}."""
|
||||
with ai_tasks._lock:
|
||||
task, step = ai_panels._find(panel_id)
|
||||
return _wait_on(task, step, now, allow_recommended)
|
||||
|
||||
|
||||
def _wait_on(task, step, now, allow_recommended):
|
||||
"""Check typing and commit a timeout choice under the same lock as user answers."""
|
||||
if not step:
|
||||
return {}
|
||||
now = time.time() if now is None else now
|
||||
typed = _typed.get(task["id"], 0)
|
||||
left = max(step["at"] + ai_gates.answer_wait(), typed + AFTER_TYPING) - now
|
||||
if left <= 0:
|
||||
wrote = typed >= step["at"] # a half-written answer is theirs: no option is taken over it
|
||||
if allow_recommended and not wrote and ai_gates.no_answer() == "recommended" and take_recommended(task["id"], step):
|
||||
return {"keep_waiting": False, "took_recommended": True, "note": TOOK}
|
||||
return {"keep_waiting": False, "note": WROTE if wrote else STOP}
|
||||
typing_now = now - typed < AFTER_TYPING
|
||||
return {"keep_waiting": True, "seconds_left": round(left), **({"typing": True} if typing_now else {}),
|
||||
"note": ("The user is typing their answer" if typing_now else "The user has not answered yet")
|
||||
+ ": call get_answers(panel, wait_seconds=55) now, and again while keep_waiting is true. Do not "
|
||||
"end your turn or ask the same in your own chat."}
|
||||
|
||||
|
||||
def for_answer(answer, panel_id=None, now=None):
|
||||
"""get_answers' `answer`, with how to wait on when nothing came back but questions still open:
|
||||
for panel `panel_id`, or without one for the newest open question while the user's time runs."""
|
||||
waiting = answer.get("still_waiting") or []
|
||||
if not waiting or answer.get("answers") or answer.get("messages"):
|
||||
return answer
|
||||
if panel_id is None:
|
||||
found = [wait_on(panel, now) for panel in dict.fromkeys(item["panel"] for item in waiting)]
|
||||
live = [item for item in found if item.get("keep_waiting")]
|
||||
status = max(live, key=lambda item: item["seconds_left"]) if live else next((item for item in found if item), {})
|
||||
took = any(item.get("took_recommended") for item in found)
|
||||
return {**answer, **status, **({"took_recommended": True} if took else {})}
|
||||
return {**answer, **wait_on(panel_id, now)}
|
||||
|
||||
|
||||
def collect(client, panel_id=None, wait_seconds=0, task_id=None):
|
||||
"""Wait unlocked, then deliver a timeout choice or a racing user answer in this same call."""
|
||||
answer = ai_panels.get_answers(client, panel_id, wait_seconds, task_id=task_id)
|
||||
if answer.get("answers") or answer.get("messages") or not answer.get("still_waiting"):
|
||||
return answer
|
||||
with ai_tasks._lock:
|
||||
# Input may have arrived after the long wait released its lock. Read it before choosing.
|
||||
answer = ai_panels.get_answers(client, panel_id, 0, task_id=task_id)
|
||||
if answer.get("answers") or answer.get("messages") or not answer.get("still_waiting"):
|
||||
return answer
|
||||
status = for_answer(answer, panel_id)
|
||||
fresh = ai_panels.get_answers(client, panel_id, 0, task_id=task_id)
|
||||
if fresh.get("answers") or fresh.get("messages"):
|
||||
if any(item.get("recommended_on_timeout") for item in fresh.get("answers", [])):
|
||||
fresh["note"] = TOOK
|
||||
return fresh
|
||||
return {**status, **fresh} if fresh.get("still_waiting") else fresh
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Who is calling Anomalous's MCP endpoint (mcp_server.py), for the AI workbench's record
|
||||
(ai_tasks.py): a short name for the AI app, the session it runs when it keeps several, and a task
|
||||
it names. Each comes from the request's headers or metadata and is cleaned to a short plain word."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_CLIENT_NAMES = (
|
||||
("claude-code", "Claude Code"), ("claude", "Claude"), ("cursor", "Cursor"), ("cherry", "Cherry Studio"),
|
||||
("windsurf", "Windsurf"), ("codex", "Codex"), ("gemini", "Gemini"), ("vscode", "VS Code"), ("cline", "Cline"),
|
||||
)
|
||||
|
||||
|
||||
def client_of(headers, params):
|
||||
"""A short name for the AI app making a call: the client info it sends, else its User-Agent."""
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
info = meta.get("io.modelcontextprotocol/clientInfo")
|
||||
raw = info.get("name") if isinstance(info, dict) and isinstance(info.get("name"), str) else ""
|
||||
if not raw:
|
||||
raw = (headers.get("User-Agent") or "").split(" ")[0].split("/")[0]
|
||||
lowered = raw.lower()
|
||||
for key, name in _CLIENT_NAMES:
|
||||
if key in lowered:
|
||||
return name
|
||||
clean = re.sub(r"[^\w .-]", "", raw).strip()[:40]
|
||||
return clean or "AI"
|
||||
|
||||
|
||||
def session_of(headers, params):
|
||||
"""An optional session key when one app runs multiple MCP sessions at once."""
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
raw = headers.get("Mcp-Session-Id") or headers.get("X-Anomalous-Session") or meta.get("sessionId") or meta.get("conversationId") or ""
|
||||
return re.sub(r"[^\w .:-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else ""
|
||||
|
||||
|
||||
def task_of(headers, params, arguments=None):
|
||||
"""An explicit task id from headers, request metadata or tool arguments."""
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
arg_task = arguments.get("task") if isinstance(arguments, dict) else None
|
||||
raw = arg_task or headers.get("X-Anomalous-Task") or meta.get("anomalous/task") or meta.get("task") or ""
|
||||
return re.sub(r"[^\w-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else ""
|
||||
@@ -0,0 +1,101 @@
|
||||
"""A task's key decisions, kept in Anomalous to look back on ("决策"). Talk may happen anywhere,
|
||||
in the AI's own chat too; what was settled is kept here: the AI records it (log_decision: what,
|
||||
why, what was left out, who decided, the steps it is about), and what the user decided in
|
||||
Anomalous itself is read from the task's own steps (a panel answer, a gate's allow or no, the
|
||||
materials handed over, the canvas mode, a stop). decisions() lists both, oldest first; gap() says
|
||||
how much the AI did since its last recorded decision, for a quiet reminder."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import ai_tasks
|
||||
|
||||
BY = ("user", "ai", "together")
|
||||
MAX_TEXT = 600
|
||||
MAX_REJECTED = 6
|
||||
MAX_RELATED = 8
|
||||
QUESTIONS = ("choices", "input", "picks")
|
||||
|
||||
|
||||
def log(client, title, decision, reason="", rejected=None, by="user", related=None, now=None, session="", task_id=None):
|
||||
"""A decision the AI records. `related`: ids of this task's steps it is about. Returns the step."""
|
||||
title, decision = str(title or "").strip(), str(decision or "").strip()
|
||||
if not title or not decision:
|
||||
raise ValueError("title (what was decided about) and decision (what was decided) are needed")
|
||||
if by not in BY:
|
||||
raise ValueError(f"by is one of {', '.join(BY)}")
|
||||
rejected = [ai_tasks._short(str(item).strip(), 200) for item in (rejected or []) if str(item).strip()][:MAX_REJECTED]
|
||||
now = time.time() if now is None else now
|
||||
record = {"title": ai_tasks._short(title, 200), "decision": ai_tasks._short(decision, MAX_TEXT),
|
||||
"reason": ai_tasks._short(str(reason or "").strip(), MAX_TEXT), "rejected": rejected, "by": by, "related": []}
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "log_decision", "kind": "decision", "ok": True,
|
||||
"state": "done", "summary": "log_decision", "text": record["decision"], "decision": record, "results": []}
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks.current_task(client, now, session=session, task_id=task_id)
|
||||
known = {item["id"] for item in task["steps"]}
|
||||
record["related"] = [str(item) for item in (related or []) if str(item) in known][:MAX_RELATED]
|
||||
ai_tasks._add_step(task, step)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task["id"])
|
||||
return step
|
||||
|
||||
|
||||
def decisions(task):
|
||||
"""Every decision of `task`, oldest first: {type: logged | answer | gate | toolbox | canvas | stop, step, at, …}."""
|
||||
out = []
|
||||
for step in (task or {}).get("steps") or []:
|
||||
tool = step.get("tool")
|
||||
if step.get("kind") == "decision":
|
||||
out.append({"type": "logged", "step": step["id"], "at": step["at"], **step["decision"]})
|
||||
elif step.get("kind") == "panel":
|
||||
blocks = {block.get("id"): block for block in step["panel"].get("blocks") or []}
|
||||
for block_id, given in (step.get("answers") or {}).items():
|
||||
block = blocks.get(block_id) or {}
|
||||
out.append({"type": "answer", "step": step["id"], "at": given.get("at", step["at"]),
|
||||
"question": block.get("question") or step["panel"].get("title") or "",
|
||||
"answer": "、".join(given.get("labels") or []) if given.get("labels") and given.get("choice") != ["other"] else given.get("text", ""),
|
||||
"skipped": bool(given.get("skipped")), "timed_out": bool(given.get("recommended_on_timeout"))})
|
||||
elif (step.get("gate") or {}).get("at"):
|
||||
asked = step["gate"]
|
||||
out.append({"type": "gate", "step": step["id"], "at": asked["at"], "tool": tool,
|
||||
"allowed": asked.get("decision") == "allow", "text": asked.get("text", "")})
|
||||
elif tool == "user_toolbox":
|
||||
box = step.get("toolbox") or {}
|
||||
count = sum(len(value) for value in box.values() if isinstance(value, list))
|
||||
out.append({"type": "toolbox", "step": step["id"], "at": step["at"], "mode": box.get("mode", "prefer"), "count": count})
|
||||
elif tool == "user_canvas":
|
||||
out.append({"type": "canvas", "step": step["id"], "at": step["at"], "mode": step.get("canvas")})
|
||||
elif tool == "user_stop":
|
||||
out.append({"type": "stop", "step": step["id"], "at": step["at"]})
|
||||
return sorted(out, key=lambda item: item["at"])
|
||||
|
||||
|
||||
def gap(task):
|
||||
"""Runs and canvas changes of the AI since its last recorded decision ({runs, canvas}), or None when there are none."""
|
||||
runs = canvas = 0
|
||||
for step in reversed((task or {}).get("steps") or []):
|
||||
if step.get("kind") == "decision":
|
||||
break
|
||||
runs += step.get("kind") == "run"
|
||||
canvas += step.get("kind") == "canvas"
|
||||
return {"runs": runs, "canvas": canvas} if runs or canvas else None
|
||||
|
||||
|
||||
def lines(task, limit=12):
|
||||
"""The latest decisions in a line each, for an AI picking the task up again."""
|
||||
words = []
|
||||
for item in decisions(task)[-limit:]:
|
||||
if item["type"] == "logged":
|
||||
words.append(f"{item['title']}: {item['decision']}" + (f" (because {item['reason']})" if item["reason"] else ""))
|
||||
elif item["type"] == "answer":
|
||||
words.append(f"Nobody answered \"{item['question']}\" in time; your recommended option was taken: {item['answer']}" if item["timed_out"] else
|
||||
f"The user answered \"{item['question']}\": " + ("left it to you" if item["skipped"] else item["answer"]))
|
||||
elif item["type"] == "gate":
|
||||
words.append(f"The user {'allowed' if item['allowed'] else 'refused'} {item['tool']}" + (f": {item['text']}" if item["text"] else ""))
|
||||
elif item["type"] == "toolbox":
|
||||
words.append(f"The user handed over {item['count']} materials ({item['mode']})")
|
||||
elif item["type"] == "canvas":
|
||||
words.append("The user set the canvas mode to " + ("the task's own tab" if item["mode"] == "new" else "their open canvas"))
|
||||
else:
|
||||
words.append("The user stopped a run")
|
||||
return words
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
"""Gates: what the user lets AI apps do through Anomalous's MCP endpoint without asking first.
|
||||
|
||||
Each acting tool has a gate in action_catalog.py (canvas, gpu, download, network, files) and each gate
|
||||
a policy the user sets on the AI workbench: "auto" (go ahead), "ask" (wait for the user's OK)
|
||||
or "never". mcp_server.py asks check() before running a tool. With "ask" the tool's step waits
|
||||
in state "asking", showing what it would do, for up to WAIT_SECONDS: the user's Allow lets the
|
||||
call go on, No answers the AI with the user's reason. When the user has not answered by then,
|
||||
the AI is told to wait (get_answers) and call the same tool again; the user's later answer
|
||||
reaches it as a message (ai_panels._collect), and an allowed call made again with the same
|
||||
arguments goes ahead without asking twice. Policies are kept in the ComfyUI user folder
|
||||
(anomalous/ai_gates.json); changing one is a write (local_guard.py keeps it to this computer).
|
||||
The same window sets (anomalous/ai_tools.json) how an AI waits for a run (run_wait): "call", it ends its turn and the user tells it when the run is done (the AI
|
||||
workbench gives them a line to paste), or "wait", it waits with run_status; how long in all an
|
||||
AI waits for the user's answer to its question (answer_wait, ai_answer_wait.py), and what happens
|
||||
when that time runs out (no_answer): "stop", the AI ends its turn and waits for the user, or
|
||||
"recommended", a choice the AI marked a recommended option for takes it (never a gate's question).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import action_catalog, ai_tasks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
POLICIES = ("auto", "ask", "never")
|
||||
RUN_WAITS = ("call", "wait")
|
||||
ANSWER_WAITS = (60, 180, 600) # seconds; the first call waits 30-55 s, get_answers the rest
|
||||
NO_ANSWERS = ("stop", "recommended")
|
||||
DEFAULTS = {"canvas": "auto", "gpu": "auto", "download": "ask", "network": "auto", "files": "ask"}
|
||||
WAIT_SECONDS = 50 # below the minute many AI apps give a tool call
|
||||
ALLOWED_SECONDS = 30 * 60 # an Allow the AI did not wait for holds this long for the same call
|
||||
MAX_ARGS = 8
|
||||
|
||||
_decided = threading.Condition(ai_tasks._lock)
|
||||
|
||||
|
||||
def _path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_gates.json")
|
||||
|
||||
|
||||
def policies():
|
||||
try:
|
||||
with open(_path(), encoding="utf-8") as source:
|
||||
stored = json.load(source)
|
||||
except (OSError, ValueError):
|
||||
stored = {}
|
||||
stored = stored if isinstance(stored, dict) else {}
|
||||
return {gate: stored.get(gate) if stored.get(gate) in POLICIES else default for gate, default in DEFAULTS.items()}
|
||||
|
||||
|
||||
def set_policy(gate, policy):
|
||||
if gate not in DEFAULTS or policy not in POLICIES:
|
||||
raise ValueError("Unknown gate or policy")
|
||||
with _decided:
|
||||
current = policies()
|
||||
current[gate] = policy
|
||||
atomic_write_json(_path(), current)
|
||||
return current
|
||||
|
||||
|
||||
def _tools_path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_tools.json")
|
||||
|
||||
|
||||
def _tool_settings():
|
||||
try:
|
||||
with open(_tools_path(), encoding="utf-8") as source:
|
||||
stored = json.load(source)
|
||||
except (OSError, ValueError):
|
||||
stored = {}
|
||||
return stored if isinstance(stored, dict) else {}
|
||||
|
||||
|
||||
def _set_tool_setting(key, value):
|
||||
with _decided:
|
||||
atomic_write_json(_tools_path(), {**_tool_settings(), key: value})
|
||||
return value
|
||||
|
||||
|
||||
def run_wait():
|
||||
"""How an AI waits for its run: "call" (the user tells it when it is done) or "wait" (run_status)."""
|
||||
chosen = _tool_settings().get("runs")
|
||||
return chosen if chosen in RUN_WAITS else "call"
|
||||
|
||||
|
||||
def set_run_wait(mode):
|
||||
if mode not in RUN_WAITS:
|
||||
raise ValueError("runs is call or wait")
|
||||
return _set_tool_setting("runs", mode)
|
||||
|
||||
|
||||
def answer_wait():
|
||||
"""How long in all an AI waits for the user's answer, in seconds (one of ANSWER_WAITS)."""
|
||||
chosen = _tool_settings().get("answer_wait")
|
||||
return chosen if chosen in ANSWER_WAITS else 180
|
||||
|
||||
|
||||
def set_answer_wait(seconds):
|
||||
if seconds not in ANSWER_WAITS or isinstance(seconds, bool):
|
||||
raise ValueError("answer_wait is 60, 180 or 600")
|
||||
return _set_tool_setting("answer_wait", seconds)
|
||||
|
||||
|
||||
def no_answer():
|
||||
"""When the user's time runs out unanswered: "stop" (the AI waits for them) or "recommended"."""
|
||||
chosen = _tool_settings().get("no_answer")
|
||||
return chosen if chosen in NO_ANSWERS else "stop"
|
||||
|
||||
|
||||
def set_no_answer(mode):
|
||||
if mode not in NO_ANSWERS:
|
||||
raise ValueError("no_answer is stop or recommended")
|
||||
return _set_tool_setting("no_answer", mode)
|
||||
|
||||
|
||||
def describe(tool, arguments):
|
||||
"""What a call would do, as [{label, value}] for the card the user answers."""
|
||||
out = []
|
||||
for key, value in list((arguments or {}).items())[:MAX_ARGS]:
|
||||
if key == "task":
|
||||
continue
|
||||
if key == "model_id": # its name, and the folder it is in
|
||||
value = " · ".join(filter(None, (ai_tasks._model_result(value).get("name") or value, str(value).split(":")[0])))
|
||||
out.append({"label": key, "value": ai_tasks._short(value, 400)})
|
||||
return out
|
||||
|
||||
|
||||
def _signature(tool, arguments):
|
||||
return json.dumps([tool, {k: v for k, v in (arguments or {}).items() if k != "task"}], sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def _blocked(text):
|
||||
return {"content": [{"type": "text", "text": text}], "isError": True}
|
||||
|
||||
|
||||
def _step(step_ref):
|
||||
task = ai_tasks._load().get(step_ref[0]) if step_ref else None
|
||||
step = next((item for item in (task or {}).get("steps", []) if item.get("id") == step_ref[1]), None) if task else None
|
||||
return task, step
|
||||
|
||||
|
||||
def check(tool, arguments, step_ref, now=None):
|
||||
"""None when the call may go ahead; else the result to give the AI instead of running it."""
|
||||
gate = action_catalog.entry(tool).gate
|
||||
if gate and set(arguments or {}) & set(action_catalog.ALSO_CANVAS.get(tool, ())):
|
||||
strict = {"auto": 0, "ask": 1, "never": 2}
|
||||
gate = max((gate, "canvas"), key=lambda name: strict.get(policies().get(name, "auto"), 0))
|
||||
policy = policies().get(gate, "auto") if gate else "auto"
|
||||
if policy == "auto":
|
||||
return None
|
||||
if policy == "never":
|
||||
return _blocked(f"The user does not let AI apps do this ({gate}) through Anomalous; tell them what you "
|
||||
"wanted to do instead, or ask them to change it in the AI workbench (Gates).")
|
||||
now = time.time() if now is None else now
|
||||
signature = _signature(tool, arguments)
|
||||
with _decided:
|
||||
task, step = _step(step_ref)
|
||||
if not step:
|
||||
return _blocked("Approval could not be recorded; nothing was done. Retry after reopening the AI workbench.")
|
||||
# An Allow given after the AI stopped waiting: the same call again goes ahead.
|
||||
for earlier in task["steps"]:
|
||||
asked = earlier.get("gate") or {}
|
||||
if (asked.get("timed_out") and asked.get("signature") == signature
|
||||
and asked.get("decision") == "allow" and not asked.get("used")
|
||||
and now - asked.get("at", 0) < ALLOWED_SECONDS):
|
||||
asked["used"] = True
|
||||
earlier["state"] = "done"
|
||||
step["gate"] = {"gate": gate, "signature": signature, "decision": "allow", "used": True, "earlier": earlier["id"]}
|
||||
ai_tasks._save(task)
|
||||
return None
|
||||
step["state"] = "asking"
|
||||
step["gate"] = {"gate": gate, "signature": signature, "lines": describe(tool, arguments), "asked": now}
|
||||
ai_tasks._save(task)
|
||||
task_id = task["id"]
|
||||
ai_tasks._notify(task_id, {"gate": gate, "asks": True})
|
||||
with _decided:
|
||||
_decided.wait_for(lambda: "decision" in step["gate"], WAIT_SECONDS)
|
||||
asked = step["gate"]
|
||||
if asked.get("decision") == "allow":
|
||||
asked["used"] = True
|
||||
step["state"] = "running"
|
||||
ai_tasks._save(task)
|
||||
return None
|
||||
if asked.get("decision") == "deny":
|
||||
return _blocked("你没有允许 / The user said no to this in Anomalous." + (f" They said: {asked['text']}" if asked.get("text") else ""))
|
||||
asked["timed_out"] = True
|
||||
ai_tasks._save(task)
|
||||
return {"content": [{"type": "text", "text": "Waiting for the user's OK in Anomalous's AI workbench: nothing was done yet. "
|
||||
"Call get_answers (wait_seconds up to 55) to hear their answer; once they allow "
|
||||
"it, call this tool again with the same arguments."}],
|
||||
"structuredContent": {"waiting_for_user": True, "gate": gate}, "isError": False}
|
||||
|
||||
|
||||
def decide(task_id, step_id, decision, text="", remember=False, now=None):
|
||||
"""The user's answer to a step that asks: "allow" or "deny" (with words for the AI)."""
|
||||
if decision not in ("allow", "deny"):
|
||||
raise ValueError("decision is allow or deny")
|
||||
with _decided:
|
||||
task, step = _step((task_id, step_id))
|
||||
asked = (step or {}).get("gate")
|
||||
if not asked or step.get("state") != "asking":
|
||||
raise ValueError("Nothing waits for an answer there")
|
||||
asked.update(decision=decision, at=time.time() if now is None else now)
|
||||
if text:
|
||||
asked["text"] = ai_tasks._short(str(text).strip(), 1000)
|
||||
if asked.get("timed_out"):
|
||||
# The AI stopped waiting: it hears of the answer as a message, and an Allow waits for its call.
|
||||
asked["delivered"] = False
|
||||
step["state"] = "allowed" if decision == "allow" else "error"
|
||||
else:
|
||||
step["state"] = "running" if decision == "allow" else "error"
|
||||
if decision == "deny":
|
||||
step.update(ok=False, error="你没有允许 / You said no")
|
||||
ai_tasks._save(task)
|
||||
_decided.notify_all()
|
||||
if remember and decision == "allow":
|
||||
set_policy(asked["gate"], "auto")
|
||||
ai_tasks._notify(task_id)
|
||||
from . import ai_panels
|
||||
ai_panels.wake()
|
||||
return step
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
"""Marks the user draws on a picture to tell the AI where to change it (the AI workbench's marker,
|
||||
web/modules/ui_ai_marker.js): boxes, circles, arrows and free lines, each with a note. The page
|
||||
sends the picture with the marks drawn on it (a PNG) and the marks themselves; this keeps the
|
||||
PNG in the ComfyUI user folder (anomalous/ai_marks/<task>/<step>.png, never the output folder, so
|
||||
the gallery stays the user's pictures) and adds a "user_marks" step to the task. The AI gets it
|
||||
like a message (ai_panels._collect, kind "marks": the notes with where each mark is, in
|
||||
fractions of the picture) and, attached to the same answer, the marked picture as a thumbnail
|
||||
(image_items), so an AI that can see pictures sees the marks. Only an output picture can be
|
||||
marked; the PNG is checked (signature, size, dimensions) before it is kept.
|
||||
The user also says how to change the picture (MODES), and the AI reads that choice first, as the
|
||||
user's: leave it to the AI, change only the marked areas, or make the whole picture anew. For
|
||||
"only the marked areas" the page sends a mask (the marker's "repaint" brush, and the boxes,
|
||||
circles and lines; white where to repaint), and the picture is also written to ComfyUI's input
|
||||
folder as anomalous_inpaint_<step>.png with that area as its alpha, so LoadImage gives the
|
||||
picture and the area as its MASK for an inpaint workflow; the AI gets the file name with the marks.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import ai_tasks
|
||||
from .mcp_tools import _image_content
|
||||
|
||||
MAX_PNG_BYTES = 8 * 1024 * 1024
|
||||
MAX_EDGE = 4096
|
||||
MAX_MARKS = 20
|
||||
MAX_NOTE = 300
|
||||
SHAPES = ("box", "circle", "arrow", "pen")
|
||||
MODES = {
|
||||
"auto": "How to change it is up to you: say in report which way you took (repaint only the marked areas, "
|
||||
"or make the picture anew) and why. To repaint only the marked areas, propose_repaint_area(marks=this "
|
||||
"id) agrees on the area with the user and makes the picture to repaint.",
|
||||
"local": "The user chose: change ONLY the marked areas and keep everything else exactly as it is. Repaint "
|
||||
"them from inpaint_image (inpaint_note says how); do not make the whole picture anew. If the area "
|
||||
"looks off, propose_repaint_area(marks=this id) agrees on a better one first.",
|
||||
"whole": "The user chose: make the whole picture anew with these changes (its own model and prompt, "
|
||||
"changed as the notes say; a new seed or img2img). Do not repaint only a part of it.",
|
||||
}
|
||||
FILE_NAME = re.compile(r"[0-9a-f]{10}\.png")
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
class MarksError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def path_of(task_id, name):
|
||||
"""The kept PNG `name` of task `task_id`, or None (only names this module makes)."""
|
||||
if not FILE_NAME.fullmatch(str(name)):
|
||||
return None
|
||||
path = os.path.join(ai_tasks.marks_dir(task_id), name)
|
||||
return path if os.path.isfile(path) else None
|
||||
|
||||
|
||||
def _png(data_url):
|
||||
text = str(data_url or "")
|
||||
if not text.startswith("data:image/png;base64,"):
|
||||
raise MarksError("Expected the marked picture as a PNG data URL")
|
||||
try:
|
||||
data = base64.b64decode(text.split(",", 1)[1], validate=True)
|
||||
except (ValueError, IndexError):
|
||||
raise MarksError("The marked picture is not valid base64")
|
||||
if len(data) > MAX_PNG_BYTES or not data.startswith(PNG_SIGNATURE):
|
||||
raise MarksError("The marked picture is not a PNG of a usable size")
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(data)) as image:
|
||||
if image.format != "PNG" or max(image.size) > MAX_EDGE:
|
||||
raise MarksError("The marked picture is too large")
|
||||
image.verify()
|
||||
except MarksError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - whatever PIL says, it is not a picture to keep
|
||||
raise MarksError("The marked picture cannot be read")
|
||||
return data
|
||||
|
||||
|
||||
def _fraction(value):
|
||||
try:
|
||||
return min(1.0, max(0.0, round(float(value), 4)))
|
||||
except (TypeError, ValueError):
|
||||
raise MarksError("Mark positions are fractions of the picture")
|
||||
|
||||
|
||||
def _clean(marks, masked=False):
|
||||
if not isinstance(marks, list) or (not marks and not masked) or len(marks) > MAX_MARKS:
|
||||
raise MarksError(f"Between 1 and {MAX_MARKS} marks")
|
||||
out = []
|
||||
for mark in marks:
|
||||
if not isinstance(mark, dict) or mark.get("shape") not in SHAPES:
|
||||
raise MarksError(f"Each mark is one of {', '.join(SHAPES)}")
|
||||
x, y, w, h = (_fraction(mark.get(key, 0)) for key in ("x", "y", "w", "h"))
|
||||
out.append({"shape": mark["shape"], "x": x, "y": y, "w": min(w, 1 - x), "h": min(h, 1 - y),
|
||||
"note": ai_tasks._short(str(mark.get("note") or "").strip(), MAX_NOTE)})
|
||||
return out
|
||||
|
||||
|
||||
def _words(marks, text, mode="auto"):
|
||||
"""The message the AI reads: how to change the picture, the user's words, then each mark with where it is."""
|
||||
lines = [MODES[mode]] + ([text] if text else [])
|
||||
for number, mark in enumerate(marks, 1):
|
||||
where = (f"x {round(mark['x'] * 100)}–{round((mark['x'] + mark['w']) * 100)}%, "
|
||||
f"y {round(mark['y'] * 100)}–{round((mark['y'] + mark['h']) * 100)}% of the picture")
|
||||
lines.append(f"Mark {number} ({mark['shape']}, {where}): {mark['note'] or '(no note)'}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def picture_path(picture):
|
||||
"""Where an output picture {subfolder, filename} (as _output_media gives it) is on disk."""
|
||||
return os.path.join(folder_paths.get_output_directory(), picture["subfolder"], picture["filename"])
|
||||
|
||||
|
||||
def write_inpaint(picture, area_of, name_id):
|
||||
"""The picture with its repaint area as alpha in ComfyUI's input folder (a new file, never one
|
||||
that is there), so LoadImage gives the area as MASK: its name. `area_of(size)` makes the area
|
||||
(an "L" image of that size, white where to repaint, soft edges kept)."""
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
try:
|
||||
with Image.open(picture_path(picture)) as original:
|
||||
area = area_of(original.size)
|
||||
if not area.getbbox():
|
||||
raise MarksError("The repaint area is empty")
|
||||
out = original.convert("RGB")
|
||||
out.putalpha(ImageOps.invert(area)) # ComfyUI's LoadImage: MASK = 1 - alpha
|
||||
except MarksError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - a picture PIL cannot read cannot be repainted either
|
||||
raise MarksError("The picture cannot be read for repainting")
|
||||
name = f"anomalous_inpaint_{name_id}.png"
|
||||
with open(os.path.join(folder_paths.get_input_directory(), name), "xb") as target: # never replaces a file
|
||||
out.save(target, "PNG")
|
||||
return name
|
||||
|
||||
|
||||
def _inpaint_file(picture, mask, step_id):
|
||||
"""write_inpaint with the area the user drew (`mask`, PNG bytes, white where to repaint)."""
|
||||
from PIL import Image
|
||||
|
||||
def drawn_area(size):
|
||||
with Image.open(io.BytesIO(mask)) as drawn:
|
||||
return drawn.convert("L").resize(size, Image.NEAREST).point(lambda value: 255 if value > 127 else 0)
|
||||
|
||||
return write_inpaint(picture, drawn_area, step_id)
|
||||
|
||||
|
||||
def save(task_id, image, png, marks, text="", now=None, mask=None, mode=None):
|
||||
"""Keeps the marked picture and adds the user's marks to the task, with how to change it (a
|
||||
key of MODES; without one, "local" when there is a mask, as the page sent before there was a
|
||||
choice). For "local", `mask` (a PNG data URL) also makes the picture ready to repaint
|
||||
(_inpaint_file); the other ways keep no mask. Returns the step."""
|
||||
mode = mode or ("local" if mask else "auto")
|
||||
if mode not in MODES:
|
||||
raise MarksError(f"How to change it is one of {', '.join(MODES)}")
|
||||
if mode != "local":
|
||||
mask = None
|
||||
elif not mask:
|
||||
raise MarksError("Changing only the marked areas needs a box, circle, line or painted area")
|
||||
try:
|
||||
picture = ai_tasks._output_media(image)
|
||||
except (ValueError, OSError):
|
||||
picture = None
|
||||
if not picture:
|
||||
raise MarksError("Only a picture in the output folder can be marked")
|
||||
data = _png(png)
|
||||
mask = _png(mask) if mask else None
|
||||
marks = _clean(marks, masked=mask is not None)
|
||||
text = ai_tasks._short(str(text or "").strip(), 2000)
|
||||
step_id = uuid.uuid4().hex[:10]
|
||||
now = time.time() if now is None else now
|
||||
if not ai_tasks.get_task(task_id):
|
||||
raise MarksError("No such task")
|
||||
inpaint = _inpaint_file(picture, mask, step_id) if mask else None # outside the lock: a picture takes a moment
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if not task:
|
||||
raise MarksError("No such task")
|
||||
os.makedirs(ai_tasks.marks_dir(task_id), exist_ok=True)
|
||||
with open(os.path.join(ai_tasks.marks_dir(task_id), f"{step_id}.png"), "xb") as target: # a new name: never replaces
|
||||
target.write(data)
|
||||
step = {"id": step_id, "at": now, "ms": 0, "tool": "user_marks", "kind": "user", "ok": True, "state": "done",
|
||||
"summary": "user_marks", "text": _words(marks, text, mode), "note": text, "image": picture, "marks": marks, "mode": mode,
|
||||
"marked": f"{step_id}.png", "delivered": False, "results": [], **({"inpaint": inpaint} if inpaint else {})}
|
||||
task["steps"].append(step)
|
||||
del task["steps"][:-ai_tasks.MAX_STEPS]
|
||||
task["updated"] = max(task.get("updated", 0), now)
|
||||
task["ended"] = None # like a message (ai_panels.leave_message): the task goes on
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return step
|
||||
|
||||
|
||||
def image_items(messages):
|
||||
"""MCP image items of the marked pictures among `messages` and of the pictures the user handed
|
||||
over (ai_pictures.py), their thumbnails, taking their private "_marked" / "_pictures" paths off
|
||||
the messages."""
|
||||
items = []
|
||||
for message in messages:
|
||||
for path in [message.pop("_marked", None), *message.pop("_pictures", [])]:
|
||||
item = _image_content(path) if path else None
|
||||
if item:
|
||||
items.append(item)
|
||||
return items
|
||||
@@ -0,0 +1,88 @@
|
||||
"""What the user points at in a message to the AI ("@" in the AI workbench's message box,
|
||||
web/modules/ui_ai_mention.js): models, combos, workflows, prompts, node types, custom node packs
|
||||
and output pictures, by the names the AI's tools use. Checked when the message is left (clean)
|
||||
and given to the AI with it (message_of): each thing as get_toolbox gives a pick (lookup), the
|
||||
pictures attached with what made them, as ai_pictures.py hands them over, so the AI needs no
|
||||
guessing which "that LoRA" is."""
|
||||
|
||||
from . import ai_pictures
|
||||
from .mcp_tools import ToolError, _resolve_image
|
||||
|
||||
KINDS = ("models", "combos", "workflows", "prompts", "nodes", "packs", "pictures")
|
||||
MAX_REFS = 12
|
||||
MAX_NAME = 300
|
||||
|
||||
|
||||
class RefsError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def clean(refs):
|
||||
"""[{kind, id, label}] from the page: known kinds, text names, each thing once, at most MAX_REFS."""
|
||||
if refs in (None, []):
|
||||
return []
|
||||
if not isinstance(refs, list) or len(refs) > MAX_REFS:
|
||||
raise RefsError(f"refs: a list of up to {MAX_REFS} things")
|
||||
out = []
|
||||
for ref in refs:
|
||||
if not isinstance(ref, dict) or ref.get("kind") not in KINDS:
|
||||
raise RefsError(f"refs: each is {{kind, id}} with kind one of {', '.join(KINDS)}")
|
||||
name = ref.get("id")
|
||||
if not isinstance(name, str) or not name.strip() or len(name) > MAX_NAME:
|
||||
raise RefsError(f"refs: an id is text of up to {MAX_NAME} characters")
|
||||
label = str(ref.get("label") or name).strip()[:MAX_NAME]
|
||||
item = {"kind": ref["kind"], "id": name.strip(), "label": label}
|
||||
if not any(have["kind"] == item["kind"] and have["id"] == item["id"] for have in out):
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def lookup(kind, name):
|
||||
"""A combo, workflow, prompt or model as the AI's tools give it, or None when it is gone."""
|
||||
from . import mcp_tools
|
||||
|
||||
if kind in ("combos", "workflows"):
|
||||
lister = mcp_tools.list_combos if kind == "combos" else mcp_tools.list_workflows
|
||||
return next(iter(lister(name, 1, exact_name=True)[kind]), None)
|
||||
if kind == "prompts":
|
||||
from .ai_toolbox_catalog import everything
|
||||
|
||||
return next((entry for entry in everything()["prompts"] if entry["id"] == name), None)
|
||||
if kind == "models":
|
||||
try:
|
||||
found = mcp_tools.get_model(name)
|
||||
except ToolError:
|
||||
return None
|
||||
return found[0] if isinstance(found, tuple) else found # without its cover picture
|
||||
return None
|
||||
|
||||
|
||||
def message_of(step):
|
||||
"""The refs of a message step for the AI ("refs", each in full or marked missing) and the
|
||||
pictures' files in "_pictures" for ai_marks.image_items to attach."""
|
||||
items, files = [], []
|
||||
for ref in step.get("refs") or []:
|
||||
item = {"kind": ref["kind"], "id": ref["id"], "said_as": f"@{ref['label']}"}
|
||||
if ref["kind"] == "pictures":
|
||||
try:
|
||||
rel, path = _resolve_image(ref["id"])[:2]
|
||||
except ToolError:
|
||||
item["missing"] = True
|
||||
else:
|
||||
item.update(image=rel, made_with=ai_pictures.brief(path))
|
||||
files.append(path)
|
||||
elif ref["kind"] == "nodes":
|
||||
item["note"] = "a node type: find_nodes(types=[id]) gives it in full, add_node adds it"
|
||||
elif ref["kind"] == "packs":
|
||||
item["note"] = "a custom node pack (folder): its node types are those find_nodes lists with this source"
|
||||
else:
|
||||
found = lookup(ref["kind"], ref["id"])
|
||||
if found is None:
|
||||
item["missing"] = True
|
||||
else:
|
||||
item["details"] = found
|
||||
items.append(item)
|
||||
out = {"refs": items, "refs_note": "What the user's @ names in their text point at, and the pictures they attached, exactly."}
|
||||
if files:
|
||||
out["_pictures"] = files
|
||||
return out
|
||||
@@ -0,0 +1,197 @@
|
||||
"""What happened outside Anomalous while an AI works on a task, as steps of kind "outside" in the
|
||||
AI workbench. Anomalous sees only the calls made to it: an AI app may also run commands and
|
||||
scripts of its own, and one that edits pictures that way loses their workflow and the user's
|
||||
earlier result. Two ways to see it:
|
||||
|
||||
check() looks, when the AI calls Anomalous (never in the background), at what changed since its
|
||||
previous call: pictures in ComfyUI's output folder no ComfyUI run saved, and pictures in the
|
||||
output or input folder rewritten in place (changed a while after they were made). Only the top of
|
||||
each folder, by the files' own times (made, changed), at most MAX_ENTRIES entries. While ComfyUI
|
||||
runs a prompt, new pictures wait until it ends (its history names them then). What it finds is
|
||||
a step and goes to the AI with its next answer (tell), which says it may well be the user's own
|
||||
doing. Without a creation time (Linux), output pictures are "new or changed" and the input
|
||||
folder is not looked at (an upload looks the same).
|
||||
|
||||
log() is the AI's own word: what it ran or changed outside Anomalous, why, and which files.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import ai_tasks
|
||||
from .mcp_tools import tool_text
|
||||
|
||||
PICTURES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff", ".mp4", ".webm", ".mov"}
|
||||
MAX_ENTRIES = 20000 # of a folder's top looked at in one check
|
||||
MAX_FOUND = 12 # files named in one step
|
||||
REWRITE_SECONDS = 60 # changed this long after it was made: rewritten, not just written
|
||||
HISTORY_ITEMS = 300 # ComfyUI's latest runs whose saved pictures are no news
|
||||
MAX_WATCHED = 16 # tasks whose last check is kept
|
||||
MAX_TEXT, MAX_FILES, MAX_FILE = 300, 20, 200
|
||||
|
||||
_lock = threading.Lock()
|
||||
_watched = OrderedDict() # task id -> {"since": time of the last full check, "told": {(folder, name, changed)}}
|
||||
|
||||
|
||||
def _queue():
|
||||
from server import PromptServer
|
||||
return PromptServer.instance.prompt_queue
|
||||
|
||||
|
||||
def _busy():
|
||||
"""Whether ComfyUI runs a prompt now (its pictures are in no history yet)."""
|
||||
try:
|
||||
running, _pending = _queue().get_current_queue_volatile()
|
||||
except Exception: # noqa: BLE001 - no server (tests) or an older queue: nothing runs that we know of
|
||||
return False
|
||||
return bool(running)
|
||||
|
||||
|
||||
def _saved_by_runs():
|
||||
"""Names of the pictures ComfyUI's latest runs saved at the top of the output folder."""
|
||||
try:
|
||||
history = _queue().get_history(max_items=HISTORY_ITEMS, map_function=lambda record: record.get("outputs") or {})
|
||||
except Exception: # noqa: BLE001 - no server (tests)
|
||||
return set()
|
||||
names = set()
|
||||
for outputs in history.values():
|
||||
for output in outputs.values() if isinstance(outputs, dict) else ():
|
||||
for items in output.values() if isinstance(output, dict) else ():
|
||||
for item in items if isinstance(items, list) else ():
|
||||
if isinstance(item, dict) and item.get("type") == "output" and not item.get("subfolder") and item.get("filename"):
|
||||
names.add(item["filename"])
|
||||
return names
|
||||
|
||||
|
||||
def _times(entry):
|
||||
"""(made, changed) of a folder entry; made is None where the system keeps no creation time."""
|
||||
stat = entry.stat()
|
||||
return getattr(stat, "st_birthtime", None), stat.st_mtime
|
||||
|
||||
|
||||
def _changes(folder, root, since):
|
||||
"""[(name, change, when)] of the pictures at the top of `root` changed after `since`:
|
||||
"rewritten", "new" or (no creation time) "changed"."""
|
||||
found = []
|
||||
try:
|
||||
with os.scandir(root) as entries:
|
||||
for at, entry in enumerate(entries):
|
||||
if at >= MAX_ENTRIES:
|
||||
break
|
||||
if os.path.splitext(entry.name)[1].lower() not in PICTURES or not entry.is_file(follow_symlinks=False):
|
||||
continue
|
||||
made, changed = _times(entry)
|
||||
if changed <= since and (made is None or made <= since):
|
||||
continue
|
||||
if made is None:
|
||||
found.append((entry.name, "changed", changed))
|
||||
elif changed - made > REWRITE_SECONDS:
|
||||
found.append((entry.name, "rewritten", changed))
|
||||
elif made > since:
|
||||
found.append((entry.name, "new", changed))
|
||||
except OSError:
|
||||
return []
|
||||
return [item for item in found if folder == "output" or item[1] == "rewritten"]
|
||||
|
||||
|
||||
def check(task_id, now=None):
|
||||
"""What changed outside Anomalous since the last check of `task_id`, as a step of the task
|
||||
(also returned), or None. The first check of a task only starts the watch."""
|
||||
if not task_id:
|
||||
return None
|
||||
now = time.time() if now is None else now
|
||||
with _lock:
|
||||
watch = _watched.get(task_id)
|
||||
if watch is None:
|
||||
_watched[task_id] = {"since": now, "told": set()}
|
||||
while len(_watched) > MAX_WATCHED:
|
||||
_watched.popitem(last=False)
|
||||
return None
|
||||
_watched.move_to_end(task_id)
|
||||
busy = _busy()
|
||||
saved = set() if busy else _saved_by_runs()
|
||||
files = []
|
||||
for folder, root in (("output", folder_paths.get_output_directory()), ("input", folder_paths.get_input_directory())):
|
||||
for name, change, when in _changes(folder, root, watch["since"]):
|
||||
if busy and change != "rewritten":
|
||||
continue # maybe the running prompt's: judged once it ends
|
||||
if change != "rewritten" and name in saved:
|
||||
continue
|
||||
if (folder, name, when) not in watch["told"]:
|
||||
watch["told"].add((folder, name, when))
|
||||
files.append({"folder": folder, "name": name, "change": change})
|
||||
if not busy:
|
||||
# A file's change time can be a second or two after the clock read (Windows): kept as told
|
||||
# until it is older than the next check's start.
|
||||
watch.update(since=now, told={key for key in watch["told"] if key[2] > now})
|
||||
if not files:
|
||||
return None
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "outside_files", "kind": "outside", "ok": True,
|
||||
"state": "done", "summary": "outside_files", "files": files[:MAX_FOUND], "more": max(0, len(files) - MAX_FOUND),
|
||||
"results": []}
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
ai_tasks._add_step(task, step)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return step
|
||||
|
||||
|
||||
def tell(result, step):
|
||||
"""Puts what check() found into the AI's tool result."""
|
||||
if not step or not isinstance(result, dict):
|
||||
return result
|
||||
files = [f"{item['folder']}/{item['name']} ({item['change']})" for item in step["files"]]
|
||||
note = ("Anomalous saw pictures change outside its tools since your previous call; it shows them to the user. "
|
||||
"It may be the user's own doing. If it was yours: never edit, crop, paint or overwrite pictures with code "
|
||||
"of your own (that loses their workflow and the user's earlier result); change a picture by running a "
|
||||
"ComfyUI workflow, look at one with zoom_image, and say what you ran outside Anomalous with log_outside.")
|
||||
if result.get("isError"):
|
||||
result["content"] = list(result.get("content") or []) + [{"type": "text", "text": f"{note} Files: {', '.join(files)}"}]
|
||||
return result
|
||||
data = result.get("structuredContent")
|
||||
data = dict(data) if isinstance(data, dict) else {}
|
||||
data["outside_changes"], data["outside_note"] = files, note
|
||||
result["structuredContent"] = data
|
||||
content = list(result.get("content") or [])
|
||||
text = {"type": "text", "text": tool_text(data)}
|
||||
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
|
||||
content[0] = text
|
||||
else:
|
||||
content.insert(0, text)
|
||||
result["content"] = content
|
||||
return result
|
||||
|
||||
|
||||
def _line(value, limit, what):
|
||||
value = str(value or "").strip()
|
||||
if len(value) > limit:
|
||||
raise ValueError(f"{what} is longer than {limit} characters.")
|
||||
return value
|
||||
|
||||
|
||||
def log(client, what, why="", files=None, now=None, session="", task_id=None):
|
||||
"""The AI's word on what it did outside Anomalous, as a step of its task. Returns the step."""
|
||||
what = _line(what, MAX_TEXT, "what")
|
||||
if not what:
|
||||
raise ValueError("what is empty: the command, script or change you made outside Anomalous.")
|
||||
if files is not None and not isinstance(files, list):
|
||||
raise ValueError("files is a list of the files you read, wrote or changed.")
|
||||
named = [_line(item, MAX_FILE, "a file") for item in (files or [])[:MAX_FILES] if str(item or "").strip()]
|
||||
now = time.time() if now is None else now
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "log_outside", "kind": "outside", "ok": True,
|
||||
"state": "done", "summary": f"log_outside: {what[:80]}", "what": what, "why": _line(why, MAX_TEXT, "why"),
|
||||
"files": named, "results": []}
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks.current_task(client, now, session=session, task_id=task_id)
|
||||
ai_tasks._add_step(task, step)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task["id"])
|
||||
return step
|
||||
@@ -0,0 +1,598 @@
|
||||
"""Panels the AI draws in its task (show_panel), from Anomalous's own blocks, and the answers
|
||||
the user gives on them.
|
||||
|
||||
A panel is a step of the AI's current task (ai_tasks.py) holding a title and a list of blocks:
|
||||
heading, text, images (from ComfyUI's output folder, with marks: circles or boxes and a label
|
||||
at places given as fractions of the picture), slider (two pictures over each other, a line to
|
||||
drag between them), picks (pictures with a reason and "take this one"), models (the user's, by
|
||||
model_id), diagram (boxes and arrows, laid out by the page; a box can name a canvas node),
|
||||
table, list, params, choices (buttons; one or several picks) and input (a line of text),
|
||||
progress, divider; and area (the user's repaint area beside the AI's, ai_repaint.py). Nothing the AI sends is run or rendered as markup, and no drawing of its own
|
||||
(SVG) is taken: every block is checked here to be one of these shapes, with plain strings and
|
||||
numbers of bounded size, pictures only from the output folder and models only from the user's
|
||||
folders. The page draws them (ui_ai_panel.js).
|
||||
|
||||
The user's answers come back through POST /anomalous/ai_tasks/{task}/answer and wait in the
|
||||
step until the AI asks for them (get_answers, which may wait a while for one) or makes its next
|
||||
successful MCP tool call (attach_pending). The user can also write to the AI in a task
|
||||
(POST /anomalous/ai_tasks/{task}/message): the message is a step of its own and reaches the AI
|
||||
the same way.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import ai_marks, ai_tasks
|
||||
from .mcp_tools import tool_text
|
||||
|
||||
MAX_BLOCKS = 30
|
||||
MAX_ITEMS = 12
|
||||
MAX_ROWS = 30
|
||||
MAX_COLUMNS = 8
|
||||
MAX_WAIT = 55
|
||||
TONES = ("normal", "muted", "tip", "warning")
|
||||
LAYOUTS = ("row", "grid", "compare")
|
||||
INTERACTIVE = ("choices", "input", "picks")
|
||||
INPAINT_NOTE = (
|
||||
"inpaint_image is the picture with the area to repaint: LoadImage(image=inpaint_image) gives the picture and, as MASK, that area. "
|
||||
"inpaint_image is already in ComfyUI's input folder, ready: do not check it with a command; "
|
||||
"get_image_info(image=inpaint_image, folder='input', include_image=true) shows it and the area, zoom_image a part "
|
||||
"of it larger. Repaint with a workflow; do not paint the picture with code of your own. "
|
||||
"Use the picture's own model and prompt (get_image_info on image) and build it with add_node and its wiring "
|
||||
"(in the picture's workflow, LoadImage -> VAEEncode(pixels, vae) -> SetLatentNoiseMask(samples, mask) in place "
|
||||
"of the empty latent), e.g. new_workflow: CheckpointLoaderSimple, "
|
||||
"LoadImage, CLIPTextEncode x2, VAEEncode(pixels, vae) -> SetLatentNoiseMask(samples, mask) -> KSampler "
|
||||
"(denoise 0.5-0.8 keeps the look; 1.0 with VAEEncodeForInpaint(grow_mask_by 8) replaces it) -> VAEDecode -> "
|
||||
"SaveImage; GrowMask softens the edge. Say in report what you set and why, ask_user the strength if unsure, "
|
||||
"and show the old and new picture with a slider panel."
|
||||
)
|
||||
COLORS = ("red", "green", "blue", "yellow", "purple")
|
||||
MAX_MARKS = 8
|
||||
MAX_NODES = 24
|
||||
MAX_EDGES = 40
|
||||
|
||||
_answered = threading.Condition(ai_tasks._lock)
|
||||
|
||||
|
||||
class PanelError(ValueError):
|
||||
"""What is wrong with a panel, for the AI to fix."""
|
||||
|
||||
|
||||
def _text(value, limit, where, required=False):
|
||||
if value is None and not required:
|
||||
return ""
|
||||
if not isinstance(value, (str, int, float)) or isinstance(value, bool):
|
||||
raise PanelError(f"{where} must be text")
|
||||
text = str(value).strip()
|
||||
if required and not text:
|
||||
raise PanelError(f"{where} is empty")
|
||||
return text[:limit]
|
||||
|
||||
|
||||
def _list(value, limit, where):
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise PanelError(f"{where} must be a list")
|
||||
if len(value) > limit:
|
||||
raise PanelError(f"{where} has more than {limit} items")
|
||||
return value
|
||||
|
||||
|
||||
def _fraction(value, where):
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool) or not 0 <= value <= 1:
|
||||
raise PanelError(f"{where} must be a number from 0 to 1 (a fraction of the picture)")
|
||||
return round(float(value), 4)
|
||||
|
||||
|
||||
def _marks(value, where):
|
||||
marks = []
|
||||
for i, mark in enumerate(_list(value, MAX_MARKS, where)):
|
||||
here = f"{where}[{i}]"
|
||||
if not isinstance(mark, dict):
|
||||
raise PanelError(f"{here} must be {{x, y, w, h | r, label, color}}")
|
||||
out = {"x": _fraction(mark.get("x"), f"{here}.x"), "y": _fraction(mark.get("y"), f"{here}.y"),
|
||||
"label": _text(mark.get("label"), 60, f"{here}.label"),
|
||||
"color": mark.get("color") if mark.get("color") in COLORS else "red"}
|
||||
if mark.get("w") is not None or mark.get("h") is not None:
|
||||
out.update(w=_fraction(mark.get("w"), f"{here}.w"), h=_fraction(mark.get("h"), f"{here}.h"))
|
||||
else:
|
||||
out["r"] = _fraction(mark.get("r", 0.06), f"{here}.r")
|
||||
marks.append(out)
|
||||
return marks
|
||||
|
||||
|
||||
def _image(item, where):
|
||||
image = item.get("image") if isinstance(item, dict) else item
|
||||
found = ai_tasks._output_media(str(image or ""))
|
||||
if not found:
|
||||
raise PanelError(f"{where}: {image!r} is not a picture in ComfyUI's output folder (subfolder/name.png, as search_images gives it)")
|
||||
out = {**found, "caption": _text(item.get("caption") if isinstance(item, dict) else "", 120, f"{where}.caption")}
|
||||
if isinstance(item, dict) and item.get("marks"):
|
||||
out["marks"] = _marks(item["marks"], f"{where}.marks")
|
||||
return out
|
||||
|
||||
|
||||
def _diagram(block, where):
|
||||
nodes, ids = [], set()
|
||||
for i, node in enumerate(_list(block.get("nodes"), MAX_NODES, f"{where}.nodes")):
|
||||
here = f"{where}.nodes[{i}]"
|
||||
if not isinstance(node, dict):
|
||||
raise PanelError(f"{here} must be {{id, label, detail, node_id}}")
|
||||
node_id = _text(node.get("id") or f"n{i + 1}", 40, f"{here}.id")
|
||||
if node_id in ids:
|
||||
raise PanelError(f"{here}.id {node_id!r} twice")
|
||||
ids.add(node_id)
|
||||
out = {"id": node_id, "label": _text(node.get("label"), 40, f"{here}.label", True),
|
||||
"detail": _text(node.get("detail"), 80, f"{here}.detail"),
|
||||
"color": node.get("color") if node.get("color") in COLORS else ""}
|
||||
if node.get("node_id") not in (None, ""):
|
||||
out["node_id"] = _text(node.get("node_id"), 20, f"{here}.node_id")
|
||||
nodes.append(out)
|
||||
if not nodes:
|
||||
raise PanelError(f"{where}.nodes is empty")
|
||||
edges = []
|
||||
for i, edge in enumerate(_list(block.get("edges"), MAX_EDGES, f"{where}.edges")):
|
||||
here = f"{where}.edges[{i}]"
|
||||
if not isinstance(edge, dict) or edge.get("from") not in ids or edge.get("to") not in ids:
|
||||
raise PanelError(f"{here} must be {{from, to, label}} naming two node ids")
|
||||
edges.append({"from": edge["from"], "to": edge["to"], "label": _text(edge.get("label"), 30, f"{here}.label")})
|
||||
return {"nodes": nodes, "edges": edges, "direction": "down" if block.get("direction") == "down" else "right"}
|
||||
|
||||
|
||||
def _model(item, where):
|
||||
from .mcp_tools import ToolError, _resolve_model
|
||||
from .model_media import _preview_url_for_model
|
||||
|
||||
model_id = item.get("model_id") if isinstance(item, dict) else item
|
||||
try:
|
||||
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
|
||||
except ToolError as error:
|
||||
raise PanelError(f"{where}: {error}")
|
||||
try:
|
||||
cover = _preview_url_for_model(folder_type, path_idx, base_dir, file_path) or ""
|
||||
except Exception: # noqa: BLE001 - a card without its cover is still a card
|
||||
cover = ""
|
||||
return {"model_id": str(model_id), "folder": folder_type, "path_idx": path_idx,
|
||||
"rel": os.path.relpath(file_path, base_dir).replace(os.sep, "/"), "name": os.path.basename(file_path),
|
||||
"cover": cover, "note": _text(item.get("note") if isinstance(item, dict) else "", 160, f"{where}.note")}
|
||||
|
||||
|
||||
def clean_block(block, at, taken):
|
||||
"""One block as the page may draw it, or PanelError."""
|
||||
where = f"blocks[{at}]"
|
||||
if not isinstance(block, dict) or not isinstance(block.get("type"), str):
|
||||
raise PanelError(f"{where} must be an object with a type")
|
||||
kind = block["type"]
|
||||
out = {"type": kind}
|
||||
if kind == "heading":
|
||||
out["text"] = _text(block.get("text"), 120, f"{where}.text", True)
|
||||
elif kind == "text":
|
||||
out["text"] = _text(block.get("text"), 4000, f"{where}.text", True)
|
||||
out["tone"] = block.get("tone") if block.get("tone") in TONES else "normal"
|
||||
elif kind == "images":
|
||||
items = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
|
||||
if not items:
|
||||
raise PanelError(f"{where}.items is empty")
|
||||
out["items"] = [_image(item, f"{where}.items[{i}]") for i, item in enumerate(items)]
|
||||
out["layout"] = block.get("layout") if block.get("layout") in LAYOUTS else "row"
|
||||
elif kind == "slider":
|
||||
out.update(before=_image(block.get("before"), f"{where}.before"), after=_image(block.get("after"), f"{where}.after"),
|
||||
before_label=_text(block.get("before_label"), 40, f"{where}.before_label"),
|
||||
after_label=_text(block.get("after_label"), 40, f"{where}.after_label"))
|
||||
elif kind == "picks":
|
||||
items, ids = [], set()
|
||||
raw = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
|
||||
if len(raw) < 2:
|
||||
raise PanelError(f"{where}.items needs 2 to {MAX_ITEMS} pictures")
|
||||
for i, item in enumerate(raw):
|
||||
here = f"{where}.items[{i}]"
|
||||
if not isinstance(item, dict):
|
||||
raise PanelError(f"{here} must be {{id, image, label, reason, recommended}}")
|
||||
item_id = _text(item.get("id") or f"p{i + 1}", 40, f"{here}.id")
|
||||
if item_id in ids:
|
||||
raise PanelError(f"{here}.id {item_id!r} twice")
|
||||
ids.add(item_id)
|
||||
picture = _image(item, here)
|
||||
items.append({**picture, "id": item_id, "label": _text(item.get("label"), 60, f"{here}.label"),
|
||||
"reason": _text(item.get("reason"), 200, f"{here}.reason"), "recommended": item.get("recommended") is True})
|
||||
out.update(question=_text(block.get("question"), 200, f"{where}.question"), items=items,
|
||||
multiple=block.get("multiple") is True)
|
||||
elif kind == "run_map": # a run's real shape (ai_run_map.py), drawn by the page from the step; only show_run_map makes it
|
||||
out["step"] = _text(block.get("step"), 20, f"{where}.step", True)
|
||||
elif kind == "area": # the user's repaint area and the AI's, pictures in the task's marks folder; only ai_repaint makes it
|
||||
if not all(ai_marks.FILE_NAME.fullmatch(str(block.get(side))) for side in ("yours", "ai")):
|
||||
raise PanelError(f"{where}: an area block shows pictures Anomalous made")
|
||||
out.update({side: block[side] for side in ("yours", "ai")}, grow=int(block.get("grow") or 0), feather=int(block.get("feather") or 0))
|
||||
elif kind == "diagram":
|
||||
out.update(_diagram(block, where))
|
||||
elif kind == "models":
|
||||
items = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
|
||||
if not items:
|
||||
raise PanelError(f"{where}.items is empty")
|
||||
out["items"] = [_model(item, f"{where}.items[{i}]") for i, item in enumerate(items)]
|
||||
elif kind == "table":
|
||||
columns = [_text(c, 60, f"{where}.columns") for c in _list(block.get("columns"), MAX_COLUMNS, f"{where}.columns")]
|
||||
rows = []
|
||||
for r, row in enumerate(_list(block.get("rows"), MAX_ROWS, f"{where}.rows")):
|
||||
cells = _list(row, MAX_COLUMNS, f"{where}.rows[{r}]")
|
||||
rows.append([_text(cell, 200, f"{where}.rows[{r}]") for cell in cells])
|
||||
if not rows:
|
||||
raise PanelError(f"{where}.rows is empty")
|
||||
out.update(columns=columns, rows=rows)
|
||||
elif kind == "list":
|
||||
items = [_text(item, 300, f"{where}.items") for item in _list(block.get("items"), MAX_ROWS, f"{where}.items")]
|
||||
if not items:
|
||||
raise PanelError(f"{where}.items is empty")
|
||||
out.update(items=items, ordered=block.get("ordered") is True)
|
||||
elif kind == "params":
|
||||
items = []
|
||||
for i, item in enumerate(_list(block.get("items"), MAX_ROWS, f"{where}.items")):
|
||||
if not isinstance(item, dict):
|
||||
raise PanelError(f"{where}.items[{i}] must be {{label, value}}")
|
||||
items.append({"label": _text(item.get("label"), 60, f"{where}.items[{i}].label", True),
|
||||
"value": _text(item.get("value"), 300, f"{where}.items[{i}].value")})
|
||||
if not items:
|
||||
raise PanelError(f"{where}.items is empty")
|
||||
out["items"] = items
|
||||
elif kind == "choices":
|
||||
options, ids = [], set()
|
||||
raw = _list(block.get("options"), 8, f"{where}.options")
|
||||
if len(raw) < 2:
|
||||
raise PanelError(f"{where}.options needs 2 to 8 options")
|
||||
for i, option in enumerate(raw):
|
||||
if not isinstance(option, dict):
|
||||
option = {"label": option}
|
||||
label = _text(option.get("label"), 80, f"{where}.options[{i}].label", True)
|
||||
option_id = _text(option.get("id") or f"o{i + 1}", 40, f"{where}.options[{i}].id")
|
||||
if option_id in ids:
|
||||
raise PanelError(f"{where}.options: id {option_id!r} twice")
|
||||
ids.add(option_id)
|
||||
options.append({"id": option_id, "label": label, "detail": _text(option.get("detail"), 200, f"{where}.options[{i}].detail"),
|
||||
**({"recommended": True} if option.get("recommended") is True else {}),
|
||||
**({"label_key": option["label_key"]} if re.fullmatch(r"ai[A-Z]\w{1,40}", str(option.get("label_key"))) else {})})
|
||||
# other: a line for an answer none of the options is
|
||||
out.update(question=_text(block.get("question"), 200, f"{where}.question"), options=options,
|
||||
multiple=block.get("multiple") is True, other=block.get("other") is True)
|
||||
elif kind == "input":
|
||||
out.update(question=_text(block.get("question"), 200, f"{where}.question", True),
|
||||
placeholder=_text(block.get("placeholder"), 80, f"{where}.placeholder"))
|
||||
elif kind == "progress":
|
||||
value, top = block.get("value"), block.get("max", 100)
|
||||
if not all(isinstance(n, (int, float)) and not isinstance(n, bool) for n in (value, top)) or top <= 0:
|
||||
raise PanelError(f"{where}: value and max must be numbers, max above 0")
|
||||
out.update(label=_text(block.get("label"), 120, f"{where}.label"), value=max(0, min(value, top)), max=top)
|
||||
elif kind == "divider":
|
||||
pass
|
||||
else:
|
||||
raise PanelError(f"{where}.type {kind!r} is not one of heading, text, images, slider, picks, models, diagram, "
|
||||
"table, list, params, choices, input, progress, divider")
|
||||
if kind in INTERACTIVE or block.get("id") is not None:
|
||||
block_id = _text(block.get("id") or f"q{at + 1}", 40, f"{where}.id")
|
||||
if block_id in taken:
|
||||
raise PanelError(f"{where}.id {block_id!r} twice")
|
||||
taken.add(block_id)
|
||||
out["id"] = block_id
|
||||
return out
|
||||
|
||||
|
||||
def clean_blocks(blocks):
|
||||
blocks = _list(blocks, MAX_BLOCKS, "blocks")
|
||||
if not blocks:
|
||||
raise PanelError("blocks is empty")
|
||||
taken = set()
|
||||
return [clean_block(block, at, taken) for at, block in enumerate(blocks)]
|
||||
|
||||
|
||||
def _block_ids(blocks):
|
||||
"""Stable handles for deliberate edits; old records get them only when updated."""
|
||||
taken = {block["id"] for block in blocks if block.get("id")}
|
||||
out = []
|
||||
for block in blocks:
|
||||
block = dict(block)
|
||||
if not block.get("id"):
|
||||
block_id = uuid.uuid4().hex[:10]
|
||||
while block_id in taken:
|
||||
block_id = uuid.uuid4().hex[:10]
|
||||
block["id"] = block_id
|
||||
taken.add(block_id)
|
||||
out.append(block)
|
||||
return out
|
||||
|
||||
|
||||
def _merge_blocks(existing, incoming):
|
||||
"""Keep the order already seen, append new output, edit only identified blocks in place."""
|
||||
merged = list(existing)
|
||||
by_id = {block["id"]: at for at, block in enumerate(existing) if block.get("id")}
|
||||
used = set()
|
||||
for raw, block in zip(incoming, clean_blocks(incoming)):
|
||||
if block["type"] in INTERACTIVE and not raw.get("id"):
|
||||
# Positional q1/q2 defaults cannot identify a question in a partial append.
|
||||
block.pop("id")
|
||||
at = by_id.get(block.get("id"))
|
||||
if at is None and not block.get("id"):
|
||||
# A client may send its entire previous layout again. That cannot reorder it.
|
||||
at = next((i for i, old in enumerate(existing) if i not in used
|
||||
and {key: value for key, value in old.items() if key != "id"} == block), None)
|
||||
if at is None and block["type"] == "progress":
|
||||
matches = [i for i, old in enumerate(existing) if old["type"] == "progress"
|
||||
and old.get("label") == block.get("label")]
|
||||
if len(matches) == 1:
|
||||
at = matches[0]
|
||||
if at is None:
|
||||
merged.append(block)
|
||||
else:
|
||||
if existing[at]["type"] != block["type"]:
|
||||
raise PanelError("An existing block id must keep its type; use a new id for new output.")
|
||||
merged[at] = {**block, **({"id": existing[at]["id"]} if existing[at].get("id") else {})}
|
||||
used.add(at)
|
||||
if len(merged) > MAX_BLOCKS:
|
||||
raise PanelError(f"The panel would exceed {MAX_BLOCKS} blocks; use show_panel for the next stage of work.")
|
||||
return _block_ids(merged)
|
||||
|
||||
|
||||
def _find(panel_id):
|
||||
"""(task, step) holding panel `panel_id`, or (None, None). Call under the lock."""
|
||||
for task in ai_tasks._load().values():
|
||||
for step in task.get("steps") or []:
|
||||
if step.get("kind") == "panel" and step.get("panel", {}).get("id") == panel_id:
|
||||
return task, step
|
||||
return None, None
|
||||
|
||||
|
||||
def show(client, title, blocks, now=None, session="", task_id=None, tool="show_panel", extra=None):
|
||||
title = _text(title, 120, "title", True)
|
||||
panel = {"id": uuid.uuid4().hex[:10], "title": title, "blocks": _block_ids(clean_blocks(blocks)), "closed": False}
|
||||
now = time.time() if now is None else now
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": tool, "kind": "panel", "ok": True,
|
||||
"summary": f"{tool}: {title}", "panel": panel, "answers": {}, "results": [], **(extra or {})}
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks.current_task(client, now, session=session, task_id=task_id, follow_up=True)
|
||||
ai_tasks._add_step(task, step)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task["id"], {"panel": title, "asks": any(b["type"] in INTERACTIVE for b in panel["blocks"])})
|
||||
return panel
|
||||
|
||||
|
||||
def update(panel_id, title=None, blocks=None, closed=None):
|
||||
with ai_tasks._lock:
|
||||
task, step = _find(str(panel_id))
|
||||
if not step:
|
||||
raise PanelError(f"No panel {panel_id!r}; show_panel returns its id.")
|
||||
# Validate the whole update before replacing the saved panel.
|
||||
panel = dict(step["panel"])
|
||||
if title is not None:
|
||||
panel["title"] = _text(title, 120, "title", True)
|
||||
if blocks is not None:
|
||||
panel["blocks"] = _merge_blocks(panel["blocks"], blocks)
|
||||
panel["changed"] = time.time() # when its numbers were written: the page greys a progress bar the task went past
|
||||
if closed is not None:
|
||||
panel["closed"] = bool(closed)
|
||||
step["panel"] = panel
|
||||
task["updated"] = max(task.get("updated", 0), time.time())
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task["id"])
|
||||
return panel
|
||||
|
||||
|
||||
def answer(task_id, step_id, block_id, choice=None, text=None, now=None, skip=False, extra=None):
|
||||
"""The user's answer to one block of a panel (from the page), or `skip` it, with `extra` for the AI
|
||||
(ai_repaint.prepare). Returns the step's answers."""
|
||||
with _answered:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = next((item for item in (task or {}).get("steps") or [] if item.get("id") == step_id), None)
|
||||
if not step or step.get("kind") != "panel":
|
||||
raise PanelError("No such panel")
|
||||
if step["panel"].get("closed"):
|
||||
raise PanelError("The AI closed this panel")
|
||||
block = next((b for b in step["panel"]["blocks"] if b.get("id") == block_id), None)
|
||||
if not block or block["type"] not in INTERACTIVE:
|
||||
raise PanelError("No such question")
|
||||
if block_id in step.setdefault("answers", {}):
|
||||
raise PanelError("Already answered")
|
||||
entry = {"at": time.time() if now is None else now, "delivered": False, **(extra or {})}
|
||||
if skip:
|
||||
entry["skipped"] = True
|
||||
elif block["type"] == "choices" and block.get("other") and not choice and isinstance(text, str) and text.strip():
|
||||
entry.update(choice=["other"], labels=["other"], text=_text(text, 2000, "text", True))
|
||||
elif block["type"] in ("choices", "picks"):
|
||||
picked = choice if isinstance(choice, list) else [choice]
|
||||
options = block["options"] if block["type"] == "choices" else block["items"]
|
||||
known = {option["id"]: option.get("label") or option["id"] for option in options}
|
||||
picked = [str(item) for item in picked if str(item) in known]
|
||||
if not picked or (len(picked) > 1 and not block.get("multiple")):
|
||||
raise PanelError("Pick one of the options")
|
||||
entry.update(choice=picked, labels=[known[item] for item in picked])
|
||||
else:
|
||||
value = _text(text, 2000, "text", True)
|
||||
entry["text"] = value
|
||||
step["answers"][block_id] = entry
|
||||
task["updated"] = max(task.get("updated", 0), entry["at"])
|
||||
ai_tasks._save(task)
|
||||
_answered.notify_all()
|
||||
answers = dict(step["answers"])
|
||||
ai_tasks._notify(task_id)
|
||||
return answers
|
||||
|
||||
|
||||
MAX_MESSAGE = 2000
|
||||
|
||||
|
||||
def leave_message(task_id, text, refs=None, now=None):
|
||||
"""A message from the user to the AI working on task `task_id`, with what its "@" point at and
|
||||
the pictures attached (ai_message_refs.py); words may be left out when something is attached.
|
||||
Returns the step."""
|
||||
from . import ai_message_refs
|
||||
|
||||
try:
|
||||
refs = ai_message_refs.clean(refs)
|
||||
except ai_message_refs.RefsError as error:
|
||||
raise PanelError(str(error))
|
||||
text = _text(text, MAX_MESSAGE, "text", not refs)
|
||||
now = time.time() if now is None else now
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_message", "kind": "user", "ok": True,
|
||||
"summary": "user_message", "text": text, "delivered": False, "results": [], **({"refs": refs} if refs else {})}
|
||||
with _answered:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if not task:
|
||||
raise PanelError("No such task")
|
||||
task["steps"].append(step)
|
||||
del task["steps"][:-ai_tasks.MAX_STEPS]
|
||||
task["updated"] = max(task.get("updated", 0), now)
|
||||
task["ended"] = None # writing to the AI goes on with the task: its next call comes back here
|
||||
ai_tasks._save(task)
|
||||
_answered.notify_all()
|
||||
ai_tasks._notify(task_id)
|
||||
return step
|
||||
|
||||
|
||||
def _collect(client, panel_id, task_id=None):
|
||||
"""Answers and messages not given to the AI yet (its own tasks, task `task_id`, or panel
|
||||
`panel_id` and the messages of that panel's task), marked as given. Under the lock."""
|
||||
out, waiting, messages, changed = [], [], [], set()
|
||||
tasks = ai_tasks._load()
|
||||
panel_task = _find(panel_id)[0] if panel_id is not None else None
|
||||
named_task = tasks.get(task_id) if task_id else None
|
||||
for task in tasks.values():
|
||||
if panel_id is not None:
|
||||
if task is not panel_task:
|
||||
continue
|
||||
elif task_id is not None:
|
||||
if task is not named_task:
|
||||
continue
|
||||
elif task.get("client") != client:
|
||||
continue
|
||||
for step in task.get("steps") or []:
|
||||
if step.get("kind") == "user" and not step.get("delivered") and (panel_id is None or task is panel_task):
|
||||
step.update(delivered=True, delivered_at=time.time())
|
||||
changed.add(task["id"])
|
||||
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_pictures":
|
||||
from . import ai_pictures
|
||||
message.update(ai_pictures.message_of(step)) # the pictures themselves go with it
|
||||
elif step.get("refs"):
|
||||
from . import ai_message_refs
|
||||
message.update(ai_message_refs.message_of(step)) # what its "@" point at, pictures attached
|
||||
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":
|
||||
# Marks on a picture: the picture's path for the AI, and the marked one attached (ai_marks.image_items).
|
||||
message.update(kind="marks", image="/".join(part for part in (step["image"].get("subfolder"), step["image"]["filename"]) if part),
|
||||
mode=step.get("mode") or ("local" if step.get("inpaint") else "auto"),
|
||||
_marked=ai_marks.path_of(task["id"], step.get("marked")))
|
||||
if step.get("inpaint"):
|
||||
message.update(inpaint_image=step["inpaint"], inpaint_note=INPAINT_NOTE)
|
||||
messages.append(message)
|
||||
continue
|
||||
asked = step.get("gate") or {}
|
||||
if asked.get("timed_out") and asked.get("decision") and not asked.get("delivered") and panel_id is None:
|
||||
asked["delivered"] = True
|
||||
changed.add(task["id"])
|
||||
messages.append({"id": step["id"], "task": task["id"], "kind": "gate", "at": asked.get("at"),
|
||||
"text": (f"The user allowed {step['tool']}: call it again with the same arguments now."
|
||||
if asked["decision"] == "allow" else
|
||||
f"The user said no to {step['tool']}." + (f" They said: {asked['text']}" if asked.get("text") else ""))})
|
||||
continue
|
||||
panel = step.get("panel")
|
||||
if step.get("kind") != "panel" or (panel_id is not None and panel["id"] != panel_id):
|
||||
continue
|
||||
for block in panel["blocks"]:
|
||||
if block["type"] not in INTERACTIVE:
|
||||
continue
|
||||
given = step.get("answers", {}).get(block["id"])
|
||||
if not given:
|
||||
if not panel.get("closed"):
|
||||
waiting.append({"panel": panel["id"], "question": block["id"]})
|
||||
continue
|
||||
if given.get("delivered"):
|
||||
continue
|
||||
given.update(delivered=True, delivered_at=time.time())
|
||||
changed.add(task["id"])
|
||||
item = {"panel": panel["id"], "question": block["id"], "asked": block.get("question", "")}
|
||||
item.update({key: given[key] for key in ("choice", "labels", "text", "skipped", "inpaint_image", "note", "recommended_on_timeout") if key in given})
|
||||
out.append(item)
|
||||
for tid in changed:
|
||||
ai_tasks._save(tasks[tid])
|
||||
return out, waiting, messages, changed
|
||||
|
||||
|
||||
def wake():
|
||||
"""Anything waiting in get_answers looks again (the user added something to a task)."""
|
||||
with _answered:
|
||||
_answered.notify_all()
|
||||
|
||||
|
||||
def get_answers(client, panel_id=None, wait_seconds=0, task_id=None):
|
||||
"""The user's new answers and messages; with `wait_seconds`, waits up to that long (MAX_WAIT)
|
||||
for one: for a panel's question still open, or (no panel named) for anything the user sends."""
|
||||
deadline = time.monotonic() + max(0, min(float(wait_seconds or 0), MAX_WAIT))
|
||||
panel_id = str(panel_id) if panel_id else None
|
||||
notified = set()
|
||||
try:
|
||||
with _answered:
|
||||
if panel_id is not None and _find(panel_id)[1] is None:
|
||||
raise PanelError(f"No panel {panel_id!r}; show_panel returns its id.")
|
||||
while True:
|
||||
answers, waiting, messages, changed = _collect(client, panel_id, task_id=task_id)
|
||||
notified.update(changed)
|
||||
left = deadline - time.monotonic()
|
||||
if answers or messages or left <= 0 or (panel_id is not None and not waiting):
|
||||
answer = {"answers": answers, "still_waiting": waiting}
|
||||
if messages:
|
||||
answer["messages"] = messages
|
||||
return answer
|
||||
_answered.wait(left)
|
||||
finally:
|
||||
for tid in notified:
|
||||
ai_tasks._notify(tid)
|
||||
|
||||
|
||||
def attach_pending(result, client, task_id=None):
|
||||
"""Attaches unread user messages and panel answers to a successful tool result so an AI
|
||||
that does not call get_answers still receives them once."""
|
||||
if not isinstance(result, dict) or result.get("isError"):
|
||||
return result
|
||||
with _answered:
|
||||
answers, waiting, messages, changed = _collect(client, None, task_id=task_id)
|
||||
for tid in changed:
|
||||
ai_tasks._notify(tid)
|
||||
if not answers and not messages:
|
||||
return result
|
||||
data = result.get("structuredContent")
|
||||
data = dict(data) if isinstance(data, dict) else {}
|
||||
if messages:
|
||||
data["user_messages"] = messages
|
||||
if answers:
|
||||
data["user_answers"] = answers
|
||||
if waiting:
|
||||
data["still_waiting"] = waiting
|
||||
data["user_notice"] = (
|
||||
"The user sent new input in the AI workbench. Read user_messages / user_answers and follow them; you may "
|
||||
"reply in your chat or in Anomalous (report(reply_to=<its id>)), but once you and the user settle what to do, "
|
||||
"record it with log_decision so the decision stays in Anomalous; "
|
||||
"kind=change messages are changes the user made themselves while you worked: keep them, do not undo them; "
|
||||
"kind=marks messages are places the user marked on a picture to change (the marked picture is attached): "
|
||||
"say what you see and how you would fix it (show_panel can show the picture with your own marks), "
|
||||
"ask_user which fix to do, and log_decision the fix chosen (related: the marks' id); "
|
||||
"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; "
|
||||
"kind=pictures: pictures the user handed you (attached), with made_with read from each picture's own workflow."
|
||||
)
|
||||
pictures = ai_marks.image_items(messages)
|
||||
result["structuredContent"] = data
|
||||
content = list(result.get("content") or [])
|
||||
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
|
||||
content[0] = {"type": "text", "text": tool_text(data)}
|
||||
else:
|
||||
content.insert(0, {"type": "text", "text": tool_text(data)})
|
||||
result["content"] = content + pictures
|
||||
return result
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Pictures the user hands the AI working on a task (the gallery card's menu, the image inspector's
|
||||
✦): a user step with the pictures (shown on the timeline like the AI's own), which the AI gets as
|
||||
a message (kind "pictures", ai_panels._collect) with each picture attached as a 512 px copy and
|
||||
what made it in a few words (brief(): models, LoRAs, prompts, size, sampler; read from the
|
||||
picture's own workflow by ai_run_map.py), so nothing needs describing. Pictures handed one after
|
||||
another before the AI looked join one step. mark_gone() tells the page which of a task's
|
||||
pictures were deleted since. save_upload() keeps a picture the user dropped or pasted into a
|
||||
message in the output folder's ai_inbox, so the gallery has it and the message carries it like
|
||||
any other: read as a picture first, size-capped, never over a file.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from . import ai_run_map, ai_tasks
|
||||
from .image_search import read_png_text
|
||||
from .mcp_tools import ToolError, _resolve_image
|
||||
|
||||
MAX_PICTURES = 8
|
||||
INBOX = "ai_inbox" # under the output folder
|
||||
MAX_UPLOAD = 25 * 1024 * 1024
|
||||
UPLOAD_TYPES = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp", "GIF": ".gif"}
|
||||
|
||||
|
||||
class PicturesError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def brief(path):
|
||||
"""What made the picture at `path`, from its own workflow: {models, loras, positive, negative, size, sampler} or {}."""
|
||||
try:
|
||||
prompt = json.loads((read_png_text(path) if path.lower().endswith(".png") else {}).get("prompt") or "null")
|
||||
shape = ai_run_map.map_of(prompt) if isinstance(prompt, dict) else None
|
||||
except (OSError, ValueError):
|
||||
shape = None
|
||||
if not shape:
|
||||
return {}
|
||||
out = {}
|
||||
for box in shape["boxes"]:
|
||||
facts = box["facts"]
|
||||
if box["role"] == "model" and facts.get("file"):
|
||||
out.setdefault("models", []).append(facts["file"])
|
||||
elif box["role"] == "lora" and facts.get("file"):
|
||||
out.setdefault("loras", []).append(f"{facts['file']} ({facts.get('strength', '?')})")
|
||||
elif box["role"] == "prompt" and facts.get("text"):
|
||||
out.setdefault("negative" if facts.get("polarity") == "negative" else "positive", facts["text"])
|
||||
elif box["role"] == "sampler":
|
||||
out["sampler"] = {key: facts[key] for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise") if key in facts}
|
||||
if facts.get("width") and "size" not in out:
|
||||
out["size"] = f"{facts['width']}x{facts['height']}"
|
||||
return out
|
||||
|
||||
|
||||
def mark_gone(task):
|
||||
"""Marks each output picture `task` shows (results, panels, marks) that is no longer in the output
|
||||
folder, deleted or moved: "gone": true, so the page says so instead of a broken picture."""
|
||||
root = ai_tasks.folder_paths.get_output_directory()
|
||||
|
||||
def walk(value):
|
||||
if isinstance(value, dict):
|
||||
if value.get("folder") == "output" and value.get("filename") and \
|
||||
not os.path.isfile(os.path.join(root, value.get("subfolder") or "", value["filename"])):
|
||||
value["gone"] = True
|
||||
for item in value.values():
|
||||
walk(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
walk(item)
|
||||
|
||||
walk((task or {}).get("steps"))
|
||||
return task
|
||||
|
||||
|
||||
def save_upload(data, name="", now=None):
|
||||
"""Keeps picture bytes `data` (the file's own name `name`, for its stem) in output/ai_inbox.
|
||||
Returns its "ai_inbox/name.ext"."""
|
||||
if not data or len(data) > MAX_UPLOAD:
|
||||
raise PicturesError(f"A picture up to {MAX_UPLOAD // (1024 * 1024)} MB")
|
||||
try:
|
||||
with Image.open(io.BytesIO(data)) as image:
|
||||
kind = image.format
|
||||
image.verify()
|
||||
except (OSError, ValueError, SyntaxError, Image.DecompressionBombError):
|
||||
raise PicturesError("Not a picture Anomalous can read (PNG, JPEG, WebP or GIF)")
|
||||
if kind not in UPLOAD_TYPES:
|
||||
raise PicturesError("Not a picture Anomalous can read (PNG, JPEG, WebP or GIF)")
|
||||
stem = re.sub(r"[^\w\- ]+", "", os.path.splitext(os.path.basename(str(name or "")))[0]).strip()[:60] or "pasted"
|
||||
folder = os.path.join(ai_tasks.folder_paths.get_output_directory(), INBOX)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(time.time() if now is None else now))
|
||||
for count in range(1, 100):
|
||||
filename = f"{stem}_{stamp}{'' if count == 1 else f' ({count})'}{UPLOAD_TYPES[kind]}"
|
||||
try:
|
||||
with open(os.path.join(folder, filename), "xb") as out: # never over a file
|
||||
out.write(data)
|
||||
except FileExistsError:
|
||||
continue
|
||||
return f"{INBOX}/{filename}"
|
||||
raise PicturesError("No free name for the picture")
|
||||
|
||||
|
||||
def hand(task_id, images, text="", now=None):
|
||||
"""Hands output pictures `images` ("subfolder/name.png") to task `task_id`'s AI. Returns the step."""
|
||||
if not isinstance(images, list) or not 1 <= len(images) <= MAX_PICTURES:
|
||||
raise PicturesError(f"Hand 1 to {MAX_PICTURES} pictures")
|
||||
pictures = []
|
||||
for image in images:
|
||||
try:
|
||||
rel = _resolve_image(image)[0]
|
||||
except ToolError as error:
|
||||
raise PicturesError(str(error))
|
||||
subfolder, _, filename = rel.rpartition("/")
|
||||
picture = {"type": "image", "folder": "output", "filename": filename, "subfolder": subfolder}
|
||||
if picture not in pictures:
|
||||
pictures.append(picture)
|
||||
now = time.time() if now is None else now
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if not task or task.get("ended"):
|
||||
raise PicturesError("No such open task")
|
||||
last = task["steps"][-1] if task["steps"] else None
|
||||
if last and last.get("tool") == "user_pictures" and not last.get("delivered") and not text:
|
||||
step = last # the AI has not looked yet: one step with all of them
|
||||
step["results"] = (step["results"] + [item for item in pictures if item not in step["results"]])[:MAX_PICTURES]
|
||||
step["at"] = now
|
||||
else:
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_pictures", "kind": "user", "ok": True,
|
||||
"state": "done", "summary": "user_pictures", "delivered": False, "results": pictures,
|
||||
"text": ai_tasks._short(str(text or "").strip(), 1000)}
|
||||
task["steps"].append(step)
|
||||
del task["steps"][:-ai_tasks.MAX_STEPS]
|
||||
task["updated"] = max(task.get("updated", 0), now)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return step
|
||||
|
||||
|
||||
def message_of(step):
|
||||
"""The AI's message for a pictures step: the pictures' paths and what made each; their files go
|
||||
in "_pictures" for ai_marks.image_items to attach."""
|
||||
paths, briefs, files = [], [], []
|
||||
for item in step.get("results") or []:
|
||||
rel = "/".join(part for part in (item.get("subfolder"), item["filename"]) if part)
|
||||
try:
|
||||
path = _resolve_image(rel)[1]
|
||||
except ToolError:
|
||||
continue # deleted since: the rest still go
|
||||
paths.append(rel)
|
||||
briefs.append({"image": rel, **brief(path)})
|
||||
files.append(path)
|
||||
words = step.get("text") or "The user handed you these pictures in Anomalous."
|
||||
return {"kind": "pictures", "text": words, "images": paths, "made_with": briefs, "_pictures": files}
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Where to repaint a picture the user marked, as the AI proposes it and the user agrees on the AI
|
||||
workbench (the propose_repaint_area tool, mcp_task_tools.py).
|
||||
|
||||
The user's marks (ai_marks.py) are rough, and the AI sees the marked picture: it may move, reshape,
|
||||
grow and soften the area: `areas` (boxes or ellipses as fractions of the picture, x/y the top left
|
||||
as in the marks), `grow` (pixels added around each) and `feather` (pixels of soft edge). Anomalous
|
||||
draws the user's area and the AI's over the picture into the task's marks folder and puts both side
|
||||
by side on a panel (block "area") with three answers: the AI's, the user's own, or marking it
|
||||
again; a line of their own says what to change. When the user picks an area, the picture goes
|
||||
into ComfyUI's input folder with that area as its alpha (ai_marks.write_inpaint), so LoadImage
|
||||
gives the picture and the area as its MASK; the answer the AI reads carries the file's name and a
|
||||
short note of the usual repaint nodes. How to build the repaint on the canvas stays the AI's.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import ai_marks, ai_panels, ai_tasks
|
||||
|
||||
SHAPES = ("box", "ellipse")
|
||||
MAX_AREAS = 8
|
||||
MAX_GROW = 256
|
||||
MAX_FEATHER = 128
|
||||
PREVIEW_EDGE = 1024
|
||||
TINT = (255, 64, 64)
|
||||
CHOICES = ( # id, label for the AI, the page's words
|
||||
("ai", "Use the AI's area", "aiRepaintUseAi"),
|
||||
("mine", "Use the user's own marks", "aiRepaintUseMine"),
|
||||
("again", "Mark it again", "aiRepaintAgain"),
|
||||
)
|
||||
AGAIN_NOTE = "The user marks the picture again: wait for their new marks with get_answers, then propose again."
|
||||
WORDS_NOTE = "The user said what to change about the area: propose_repaint_area again with it."
|
||||
|
||||
|
||||
class RepaintError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _number(value, low, high, what):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
|
||||
raise RepaintError(f"{what} is a number from {low} to {high}")
|
||||
return value
|
||||
|
||||
|
||||
def _areas(areas):
|
||||
if not isinstance(areas, list) or not 0 < len(areas) <= MAX_AREAS:
|
||||
raise RepaintError(f"areas lists 1 to {MAX_AREAS} areas")
|
||||
out = []
|
||||
for at, area in enumerate(areas):
|
||||
where = f"areas[{at}]"
|
||||
if not isinstance(area, dict) or area.get("shape", "box") not in SHAPES:
|
||||
raise RepaintError(f"{where} is {{shape: box | ellipse, x, y, w, h}}")
|
||||
x, y = (_number(area.get(key), 0, 1, f"{where}.{key}") for key in ("x", "y"))
|
||||
w, h = (_number(area.get(key), 0.005, 1, f"{where}.{key}") for key in ("w", "h"))
|
||||
out.append({"shape": area.get("shape", "box"), "x": round(x, 4), "y": round(y, 4),
|
||||
"w": round(min(w, 1 - x), 4), "h": round(min(h, 1 - y), 4)})
|
||||
return out
|
||||
|
||||
|
||||
def _from_marks(marks):
|
||||
"""The user's marks as areas: a circle an ellipse in its box, a line or an arrow the box around it."""
|
||||
return [{"shape": "ellipse" if mark["shape"] == "circle" else "box", **{key: mark[key] for key in ("x", "y", "w", "h")}}
|
||||
for mark in marks if mark["w"] > 0 and mark["h"] > 0]
|
||||
|
||||
|
||||
def area_image(size, areas, grow=0, feather=0):
|
||||
"""An "L" image of `size`, white over `areas` grown by `grow` pixels, `feather` pixels soft at the edge."""
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
width, height = size
|
||||
area = Image.new("L", size, 0)
|
||||
draw = ImageDraw.Draw(area)
|
||||
for shape in areas:
|
||||
box = [shape["x"] * width - grow, shape["y"] * height - grow,
|
||||
(shape["x"] + shape["w"]) * width + grow, (shape["y"] + shape["h"]) * height + grow]
|
||||
(draw.ellipse if shape["shape"] == "ellipse" else draw.rectangle)(box, fill=255)
|
||||
return area.filter(ImageFilter.GaussianBlur(feather / 2)) if feather else area
|
||||
|
||||
|
||||
def _user_area(marks_step):
|
||||
"""How to make the user's own area: what they painted when they asked for only the marked areas, else their marks."""
|
||||
painted = marks_step.get("inpaint")
|
||||
if painted:
|
||||
def from_painted(size):
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
with Image.open(os.path.join(folder_paths.get_input_directory(), painted)) as kept:
|
||||
return ImageOps.invert(kept.getchannel("A")).resize(size)
|
||||
return from_painted
|
||||
areas = _from_marks(marks_step.get("marks") or [])
|
||||
if not areas:
|
||||
raise RepaintError("These marks hold no area to repaint")
|
||||
return lambda size: area_image(size, areas)
|
||||
|
||||
|
||||
def _preview(task_id, picture, area_of):
|
||||
"""The picture, made small, with the area tinted, kept in the task's marks folder: its name."""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(ai_marks.picture_path(picture)) as original:
|
||||
base = original.convert("RGB")
|
||||
except OSError:
|
||||
raise RepaintError("The marked picture cannot be read")
|
||||
base.thumbnail((PREVIEW_EDGE, PREVIEW_EDGE))
|
||||
area = area_of(base.size).point(lambda value: value * 55 // 100)
|
||||
shown = Image.composite(Image.new("RGB", base.size, TINT), base, area)
|
||||
name = f"{uuid.uuid4().hex[:10]}.png"
|
||||
os.makedirs(ai_tasks.marks_dir(task_id), exist_ok=True)
|
||||
with open(os.path.join(ai_tasks.marks_dir(task_id), name), "xb") as target:
|
||||
shown.save(target, "PNG")
|
||||
return name
|
||||
|
||||
|
||||
def propose(client, title, marks_id, areas=None, grow=12, feather=8, note="", session=""):
|
||||
"""Shows the user their area and the AI's for marks `marks_id`; returns the panel to wait on."""
|
||||
with ai_tasks._lock:
|
||||
found = next(((task, step) for task in ai_tasks._load().values() for step in task.get("steps") or []
|
||||
if step.get("id") == marks_id and step.get("tool") == "user_marks"), None)
|
||||
if not found:
|
||||
raise RepaintError("marks is the id of the user's marks (user_messages[].id, kind marks)")
|
||||
task, marks_step = found
|
||||
grow = int(_number(grow, 0, MAX_GROW, "grow"))
|
||||
feather = int(_number(feather, 0, MAX_FEATHER, "feather"))
|
||||
shapes = _areas(areas) if areas else _from_marks(marks_step.get("marks") or [])
|
||||
if not shapes:
|
||||
raise RepaintError("These marks hold no area: give areas")
|
||||
picture = marks_step["image"]
|
||||
with_edges = {"grow": grow, "feather": feather}
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(ai_marks.picture_path(picture)) as original:
|
||||
scale = min(1, PREVIEW_EDGE / max(original.size)) # grow and feather are pixels of the whole picture
|
||||
except OSError:
|
||||
raise RepaintError("The marked picture cannot be read")
|
||||
yours = _preview(task["id"], picture, _user_area(marks_step))
|
||||
ai = _preview(task["id"], picture, lambda size: area_image(size, shapes, grow * scale, feather * scale))
|
||||
blocks = [{"type": "area", "yours": yours, "ai": ai, **with_edges}]
|
||||
if note:
|
||||
blocks.append({"type": "text", "text": note})
|
||||
blocks.append({"type": "choices", "id": "answer", "other": True,
|
||||
"options": [{"id": key, "label": label, "label_key": words, **({"recommended": True} if key == "ai" else {})}
|
||||
for key, label, words in CHOICES]})
|
||||
try:
|
||||
return ai_panels.show(client, title, blocks, session=session, task_id=task["id"], tool="propose_repaint_area",
|
||||
extra={"repaint": {"marks": marks_id, "areas": shapes, **with_edges}})
|
||||
except ai_panels.PanelError as error:
|
||||
raise RepaintError(str(error))
|
||||
|
||||
|
||||
def prepare(task_id, step_id, choice, text, skip=False):
|
||||
"""What the user's answer to a repaint panel brings the AI, made before it is recorded: the repaint
|
||||
picture for an area picked (inpaint_image and its note; "your call" takes the AI's), else what to
|
||||
do now; {} for other panels."""
|
||||
step = next((item for item in (ai_tasks.get_task(task_id) or {}).get("steps") or [] if item.get("id") == step_id), None)
|
||||
asked = (step or {}).get("repaint")
|
||||
if not asked:
|
||||
return {}
|
||||
picked = "ai" if skip else (choice[0] if isinstance(choice, list) and choice else choice)
|
||||
if picked not in ("ai", "mine"):
|
||||
return {"note": WORDS_NOTE if str(text or "").strip() and not picked else AGAIN_NOTE}
|
||||
marks_step = next((item for task in ai_tasks._load().values() for item in task.get("steps") or []
|
||||
if item.get("id") == asked["marks"]), None)
|
||||
if not marks_step:
|
||||
raise RepaintError("The marks are gone")
|
||||
area_of = (_user_area(marks_step) if picked == "mine"
|
||||
else lambda size: area_image(size, asked["areas"], asked["grow"], asked["feather"]))
|
||||
try:
|
||||
name = ai_marks.write_inpaint(marks_step["image"], area_of, uuid.uuid4().hex[:10])
|
||||
except ai_marks.MarksError as error:
|
||||
raise RepaintError(str(error))
|
||||
return {"inpaint_image": name, "note": ai_panels.INPAINT_NOTE}
|
||||
|
||||
|
||||
def discard(prepared):
|
||||
"""The repaint picture `prepare` made, when the answer was refused after all (the plugin's own file)."""
|
||||
if prepared.get("inpaint_image"):
|
||||
try:
|
||||
os.remove(os.path.join(folder_paths.get_input_directory(), prepared["inpaint_image"]))
|
||||
except OSError:
|
||||
pass
|
||||
@@ -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,180 @@
|
||||
"""The shape of a workflow run in a few boxes, so the user sees what the AI really ran: read from
|
||||
the prompt ComfyUI queued (ai_task_runs.py keeps it on the run's step as "map"), never from
|
||||
what the AI says. Plumbing (reroutes, primitives, notes) is left out and wired through; each
|
||||
box has a role (model, lora, prompt, size, sampler, image, upscale, decode, save, other) and a
|
||||
few facts the page words in the user's language (ui_ai_run_map.js); the links carry ComfyUI's
|
||||
types. changes() says what differs from the run before it: a setting, a node added or gone.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from .model_constants import MODEL_EXTENSIONS
|
||||
|
||||
MAX_BOXES = 24 # what a stage diagram holds (ai_panels.MAX_NODES)
|
||||
MAX_EDGES = 40
|
||||
MAX_CHANGES = 10
|
||||
HIDDEN = {"Reroute", "Note", "MarkdownNote", "PrimitiveNode", "PrimitiveInt", "PrimitiveFloat", "PrimitiveString",
|
||||
"PrimitiveStringMultiline", "PrimitiveBoolean"}
|
||||
SEEDS = {"seed", "noise_seed"}
|
||||
SAMPLER_FACTS = ("steps", "cfg", "sampler_name", "scheduler", "denoise")
|
||||
TEXT_CHARS = 80
|
||||
VALUE_CHARS = 60
|
||||
|
||||
|
||||
def _linked(value):
|
||||
return isinstance(value, list) and len(value) == 2 and isinstance(value[0], (str, int))
|
||||
|
||||
|
||||
def _literal(value):
|
||||
return isinstance(value, (str, int, float, bool)) and not isinstance(value, list)
|
||||
|
||||
|
||||
def _file_name(value):
|
||||
return os.path.splitext(os.path.basename(str(value).replace("\\", "/")))[0]
|
||||
|
||||
|
||||
def _output_type(class_type, index):
|
||||
"""ComfyUI's type of output `index` of `class_type` ("MODEL", "LATENT"...), or ""."""
|
||||
try:
|
||||
import nodes
|
||||
types = getattr(nodes.NODE_CLASS_MAPPINGS.get(class_type), "RETURN_TYPES", None) or ()
|
||||
return str(types[int(index)]) if int(index) < len(types) else ""
|
||||
except Exception: # noqa: BLE001 - no ComfyUI (tests) or an odd node: the link stays unnamed
|
||||
return ""
|
||||
|
||||
|
||||
def _role(class_type, inputs):
|
||||
name = class_type.lower()
|
||||
if "lora_name" in inputs:
|
||||
return "lora"
|
||||
if _linked(inputs.get("latent_image")) or "sampler" in name and not name.endswith("select"):
|
||||
return "sampler"
|
||||
if name == "loadimage" or name.startswith("loadimage"):
|
||||
return "image"
|
||||
if "upscale" in name:
|
||||
return "upscale"
|
||||
if "loader" in name:
|
||||
return "model"
|
||||
if isinstance(inputs.get("text"), str) or "textencode" in name:
|
||||
return "prompt"
|
||||
if name.startswith("empty") and "latent" in name:
|
||||
return "size"
|
||||
if name.startswith("vaedecode"):
|
||||
return "decode"
|
||||
if name.startswith(("save", "preview")):
|
||||
return "save"
|
||||
return "other"
|
||||
|
||||
|
||||
def _facts(role, inputs):
|
||||
literal = {key: value for key, value in inputs.items() if _literal(value)}
|
||||
facts = {}
|
||||
model = next((value for value in literal.values() if isinstance(value, str) and value.lower().endswith(MODEL_EXTENSIONS)), None)
|
||||
if model:
|
||||
facts["file"] = _file_name(model)[:VALUE_CHARS]
|
||||
if role == "lora":
|
||||
for key in ("strength_model", "strength"):
|
||||
if isinstance(literal.get(key), (int, float)):
|
||||
facts["strength"] = round(float(literal[key]), 3)
|
||||
break
|
||||
elif role == "prompt":
|
||||
text = next((value for key, value in literal.items() if isinstance(value, str) and key.startswith("text")), "")
|
||||
facts["text"] = " ".join(text.split())[:TEXT_CHARS]
|
||||
elif role == "sampler":
|
||||
facts.update({key: literal[key] for key in SAMPLER_FACTS if key in literal})
|
||||
elif role == "image":
|
||||
facts["file"] = str(literal.get("image") or "")[:VALUE_CHARS]
|
||||
elif role == "save":
|
||||
facts["prefix"] = str(literal.get("filename_prefix") or "")[:VALUE_CHARS]
|
||||
if all(isinstance(literal.get(key), (int, float)) and literal[key] > 0 for key in ("width", "height")):
|
||||
facts.update(width=int(literal["width"]), height=int(literal["height"]))
|
||||
if isinstance(literal.get("batch_size"), int) and literal["batch_size"] > 1:
|
||||
facts["batch"] = literal["batch_size"]
|
||||
if role == "other" and not facts:
|
||||
facts["values"] = [[key, str(value)[:VALUE_CHARS]] for key, value in literal.items()
|
||||
if key not in SEEDS and str(value).strip()][:2]
|
||||
return facts
|
||||
|
||||
|
||||
def map_of(prompt):
|
||||
"""{"boxes": [{id, type, title, role, facts}], "links": [{from, to, type}], "params": {...}} of API prompt `prompt`."""
|
||||
nodes = {str(key): node for key, node in (prompt or {}).items() if isinstance(node, dict) and node.get("class_type")}
|
||||
shown = [key for key in nodes if nodes[key]["class_type"] not in HIDDEN]
|
||||
if len(shown) > MAX_BOXES: # keep what makes the picture; drop the helpers first
|
||||
rank = {"other": 2, "save": 1}
|
||||
shown = sorted(shown, key=lambda key: rank.get(_role(nodes[key]["class_type"], nodes[key].get("inputs") or {}), 0))[:MAX_BOXES]
|
||||
keep = set(shown)
|
||||
|
||||
def source(link, seen=()):
|
||||
"""The shown node (and the output's type) a link comes from, wired through hidden nodes."""
|
||||
key, index = str(link[0]), link[1]
|
||||
if key in keep:
|
||||
return key, _output_type(nodes[key]["class_type"], index)
|
||||
node = nodes.get(key)
|
||||
if not node or key in seen:
|
||||
return None, ""
|
||||
upstream = next((value for value in (node.get("inputs") or {}).values() if _linked(value)), None)
|
||||
return source(upstream, (*seen, key)) if upstream else (None, "")
|
||||
|
||||
boxes, links, params = [], [], {}
|
||||
for key in sorted(shown, key=lambda item: (len(item), item)):
|
||||
node = nodes[key]
|
||||
inputs = node.get("inputs") or {}
|
||||
role = _role(node["class_type"], inputs)
|
||||
boxes.append({"id": key, "type": node["class_type"][:80], "role": role, "facts": _facts(role, inputs),
|
||||
"title": str((node.get("_meta") or {}).get("title") or node["class_type"])[:80]})
|
||||
for name, value in inputs.items():
|
||||
if _linked(value):
|
||||
found, kind = source(value)
|
||||
if found and found != key and len(links) < MAX_EDGES:
|
||||
links.append({"from": found, "to": key, "type": kind or name.upper(), "input": name})
|
||||
elif _literal(value) and name not in SEEDS:
|
||||
params[f"{key}.{name}"] = str(value)[:300]
|
||||
_polarity(boxes, links)
|
||||
return {"boxes": boxes, "links": links, "params": params}
|
||||
|
||||
|
||||
def _polarity(boxes, links):
|
||||
"""Marks prompt boxes positive or negative by the sampler input they reach."""
|
||||
by_id = {box["id"]: box for box in boxes}
|
||||
feeds = {}
|
||||
for link in links:
|
||||
feeds.setdefault(link["from"], []).append(link)
|
||||
for box in boxes:
|
||||
if box["role"] != "prompt":
|
||||
continue
|
||||
todo, seen = [box["id"]], set()
|
||||
while todo and "polarity" not in box["facts"]:
|
||||
here = todo.pop()
|
||||
seen.add(here)
|
||||
for link in feeds.get(here, []):
|
||||
if link["input"] in ("positive", "negative") and by_id[link["to"]]["role"] == "sampler":
|
||||
box["facts"]["polarity"] = link["input"]
|
||||
break
|
||||
if link["to"] not in seen:
|
||||
todo.append(link["to"])
|
||||
|
||||
|
||||
def changes(before, after):
|
||||
"""What differs in map `after` from map `before`: [{node, title, key, from, to}] or a node added / gone."""
|
||||
if not before or not after:
|
||||
return []
|
||||
titles = {box["id"]: box["title"] for box in before.get("boxes", [])}
|
||||
titles.update({box["id"]: box["title"] for box in after.get("boxes", [])})
|
||||
old_ids = {box["id"]: box["type"] for box in before.get("boxes", [])}
|
||||
new_ids = {box["id"]: box["type"] for box in after.get("boxes", [])}
|
||||
out = []
|
||||
for key, kind in new_ids.items():
|
||||
if old_ids.get(key) != kind:
|
||||
out.append({"node": key, "title": titles[key], "change": "added"})
|
||||
for key, kind in old_ids.items():
|
||||
if new_ids.get(key) != kind:
|
||||
out.append({"node": key, "title": titles[key], "change": "removed"})
|
||||
gone = {item["node"] for item in out}
|
||||
old, new = before.get("params", {}), after.get("params", {})
|
||||
for name in sorted(set(old) | set(new), key=lambda item: (len(item.split(".")[0]), item)):
|
||||
node, key = name.split(".", 1)
|
||||
if node not in gone and old.get(name) != new.get(name):
|
||||
out.append({"node": node, "title": titles.get(node, node), "key": key,
|
||||
"from": (old.get(name) or "")[:VALUE_CHARS], "to": (new.get(name) or "")[:VALUE_CHARS]})
|
||||
return out[:MAX_CHANGES]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Attach explicitly identified external ComfyUI submissions to an AI task; never guess ownership."""
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
|
||||
from . import ai_task_runs, ai_tasks
|
||||
from .mcp_tools import ToolError
|
||||
|
||||
|
||||
def checked_wait(value):
|
||||
"""wait_seconds as seconds from 0 to 55: one call waits no longer, so a longer wait is cut to that."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise ToolError("wait_seconds is a number of seconds, up to 55.")
|
||||
return min(max(value, 0), 55)
|
||||
|
||||
|
||||
def track_run(ctx, prompt_ids, task=None, wait_seconds=15):
|
||||
wait_seconds = checked_wait(wait_seconds)
|
||||
if (not isinstance(prompt_ids, list) or not 1 <= len(prompt_ids) <= 8
|
||||
or any(not isinstance(pid, str) or not pid or len(pid) > 128 for pid in prompt_ids)
|
||||
or len(set(prompt_ids)) != len(prompt_ids)):
|
||||
raise ToolError("Give 1–8 unique prompt_ids returned by your own ComfyUI /prompt submissions.")
|
||||
client, session = ctx.get("client") or "AI", ctx.get("session") or ""
|
||||
task_id = task or ctx.get("task") or ai_tasks.active_task_id(client, session)
|
||||
with ai_tasks._lock:
|
||||
target = ai_tasks._load().get(task_id)
|
||||
if not target or target.get("ended"):
|
||||
raise ToolError("Begin or resume an open task before attaching a run; pass its task ID.")
|
||||
existing = None
|
||||
for candidate in ai_tasks._load().values():
|
||||
for step in candidate.get("steps", []):
|
||||
ids = step.get("prompt_ids") or []
|
||||
if set(ids).intersection(prompt_ids):
|
||||
if candidate["id"] != task_id or ids != prompt_ids:
|
||||
raise ToolError("A prompt ID is already attached to another run. Do not reassign it.")
|
||||
existing = step
|
||||
if existing is None:
|
||||
try:
|
||||
queue = ai_task_runs._queue()
|
||||
running, pending = queue.get_current_queue()
|
||||
known = {item[1] for item in [*running, *pending]}
|
||||
missing = [pid for pid in prompt_ids if pid not in known and not queue.get_history(prompt_id=pid).get(pid)]
|
||||
except Exception as error:
|
||||
raise ToolError("Cannot read ComfyUI's queue/history; nothing was attached.") from error
|
||||
if missing:
|
||||
raise ToolError("Unknown prompt IDs: " + ", ".join(missing) + ". Use this server's submission receipts.")
|
||||
receipt = {"prompt_ids": prompt_ids, "queued": len(prompt_ids)}
|
||||
result = {"structuredContent": receipt, "content": []}
|
||||
step = ai_tasks.record(client, "run_workflow", {}, result, time.time(), session=session, task_id=task_id)
|
||||
if not step:
|
||||
raise ToolError("Could not record the run; nothing was queued by this tool.")
|
||||
step.update(prompt_ids=list(prompt_ids), source="external")
|
||||
ai_tasks._save(target)
|
||||
ai_task_runs.watch(step["id"], receipt)
|
||||
existing = step
|
||||
answer = ai_task_runs.status(client, session, task_id, wait_seconds, step_id=existing["id"])
|
||||
return {"task": task_id, "attached": True, **answer}
|
||||
|
||||
|
||||
def wait_for_receipt(result, client, session, task_id, wait_seconds=15, step_id=None):
|
||||
"""Return authoritative execution state alongside submission receipts, with a bounded wait."""
|
||||
answer = ai_task_runs.status(client, session, task_id, wait_seconds, step_id=step_id)
|
||||
data = {**result.get("structuredContent", {}), **answer, "task": task_id}
|
||||
result["structuredContent"] = data
|
||||
content = list(result.get("content") or [])
|
||||
text = {"type": "text", "text": json.dumps(data, ensure_ascii=False)}
|
||||
if content and content[0].get("type") == "text":
|
||||
content[0] = text
|
||||
else:
|
||||
content.insert(0, text)
|
||||
result["content"] = content
|
||||
return result
|
||||
@@ -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."
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Each task's own canvas tab, kept by Anomalous rather than left to the AI: with "new" (the
|
||||
default, set in the Gates window; each task can change it) the page copies the workflow open
|
||||
now into a new tab named after the task before the AI's first change, and brings that tab back
|
||||
before each later change, read or run (web/modules/mcp_task_tab.js); new_workflow and
|
||||
open_workflow make the tab they open the task's. The user's own tab is never changed. With
|
||||
"current" the AI works on whatever tab is open, as before.
|
||||
|
||||
mcp_server.py marks each tool call with its task (begin_call); mcp_bridge.ask_page adds the
|
||||
task's canvas to the page actions that touch the canvas (spec) and keeps the tab the page
|
||||
reports (remember); tell() tells the AI once when a tab was made for it.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import ai_tasks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
MODES = ("new", "current")
|
||||
DEFAULT = "new"
|
||||
# Page actions (web/modules/mcp_bridge.js) that work on the canvas, so on the task's tab.
|
||||
TAB_ACTIONS = {"describe_canvas", "set_prompt", "set_model", "add_lora", "place_combo", "check_models", "fix_models",
|
||||
"run_workflow", "download_missing", "add_node", "add_nodes", "connect_nodes", "set_values", "remove_nodes", "new_workflow", "open_workflow",
|
||||
"fill_slots"}
|
||||
_call = contextvars.ContextVar("anomalous_task_canvas", default=None)
|
||||
|
||||
|
||||
def _path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_canvas.json")
|
||||
|
||||
|
||||
def default_mode():
|
||||
try:
|
||||
with open(_path(), encoding="utf-8") as source:
|
||||
mode = json.load(source).get("mode")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
mode = None
|
||||
return mode if mode in MODES else DEFAULT
|
||||
|
||||
|
||||
def set_default(mode):
|
||||
if mode not in MODES:
|
||||
raise ValueError("canvas_tab is new or current")
|
||||
atomic_write_json(_path(), {"mode": mode})
|
||||
return mode
|
||||
|
||||
|
||||
def mode_of(task):
|
||||
mode = ((task or {}).get("canvas") or {}).get("mode")
|
||||
return mode if mode in MODES else default_mode()
|
||||
|
||||
|
||||
def set_mode(task_id, mode):
|
||||
"""The user's choice for one task. Returns the task's canvas record."""
|
||||
if mode not in MODES:
|
||||
raise ValueError("canvas is new or current")
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if not task:
|
||||
raise ValueError("No such task")
|
||||
if (task.get("canvas") or {}).get("mode", default_mode()) != mode:
|
||||
# The user's choice: the AI hears of it, and it is one of the task's decisions (ai_decisions.py).
|
||||
now = time.time()
|
||||
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_canvas", "kind": "user", "ok": True,
|
||||
"state": "done", "summary": "user_canvas", "canvas": mode, "delivered": False, "results": [],
|
||||
"text": "The user chose that you work " + ("in a tab of the task's own." if mode == "new" else "on the canvas they have open.")})
|
||||
task["canvas"] = {**(task.get("canvas") or {}), "mode": mode}
|
||||
ai_tasks._save(task)
|
||||
canvas = dict(task["canvas"])
|
||||
ai_tasks._notify(task_id)
|
||||
return canvas
|
||||
|
||||
|
||||
def begin_call(task_id):
|
||||
"""Marks the tool call running now as task `task_id`'s; the holder says later what the page opened."""
|
||||
holder = {"task": task_id, "opened": None}
|
||||
_call.set(holder)
|
||||
return holder
|
||||
|
||||
|
||||
def spec(action):
|
||||
"""What the page needs for page action `action` of the call running now: {mode, tab, title}, or None."""
|
||||
holder = _call.get()
|
||||
if not holder or not holder.get("task") or action not in TAB_ACTIONS:
|
||||
return None
|
||||
task = ai_tasks.get_task(holder["task"])
|
||||
if not task:
|
||||
return None
|
||||
return {"mode": mode_of(task), "tab": (task.get("canvas") or {}).get("tab"), "title": (task.get("title") or "")[:60]}
|
||||
|
||||
|
||||
def remember(tab):
|
||||
"""Keeps the tab the page made or opened for the call's task ({path, name, made?})."""
|
||||
holder = _call.get()
|
||||
if not holder or not holder.get("task") or not isinstance(tab, dict) or not isinstance(tab.get("path"), str):
|
||||
return
|
||||
kept = {"path": tab["path"][:300], "name": str(tab.get("name") or "")[:120], "at": time.time()}
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(holder["task"])
|
||||
if not task:
|
||||
return
|
||||
task["canvas"] = {**(task.get("canvas") or {}), "tab": kept}
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(holder["task"])
|
||||
holder["opened"] = {**kept, "made": bool(tab.get("made"))}
|
||||
|
||||
|
||||
def tell(result, holder):
|
||||
"""Says in the tool's answer that Anomalous made the task a tab of its own (once, when it did)."""
|
||||
opened = (holder or {}).get("opened")
|
||||
if not opened or not opened["made"] or not isinstance(result, dict) or result.get("isError"):
|
||||
return result
|
||||
data = dict(result.get("structuredContent") or {})
|
||||
data["canvas_tab"] = {"name": opened["name"], "note": (
|
||||
f"Anomalous copied the user's open workflow to a new tab \"{opened['name']}\" for this task (the user's "
|
||||
"setting): your canvas changes, reads and runs go there; the user's own tab stays as it was. Do not switch "
|
||||
"tabs yourself.")}
|
||||
result["structuredContent"] = data
|
||||
content = list(result.get("content") or [])
|
||||
text = {"type": "text", "text": json.dumps(data, ensure_ascii=False)}
|
||||
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
|
||||
content[0] = text
|
||||
else:
|
||||
content.insert(0, text)
|
||||
result["content"] = content
|
||||
return result
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The user's own changes in Anomalous while an AI works on a task: each activity log entry the
|
||||
user makes (activity_log.py tells note_activity of every new entry) becomes a step of the task
|
||||
the AI called within USER_STEP_SECONDS, and the entry names that task back. The AI is told of it
|
||||
with its next call (ai_panels.attach_pending, a message of kind "change"), so it keeps the user's
|
||||
change instead of working over it. The AI's own canvas changes (via mcp) are its steps already;
|
||||
which actions count, and their stage, is action_catalog.USER_ACTIONS."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import action_catalog, activity_log, ai_tasks
|
||||
|
||||
USER_STEP_SECONDS = 10 * 60
|
||||
|
||||
|
||||
def _live_task(now):
|
||||
"""The task an AI is working on now (it called within USER_STEP_SECONDS), or None."""
|
||||
live = [task for task in ai_tasks._load().values() if not task.get("ended") and not task.get("waiting")
|
||||
and now - task.get("updated", 0) < USER_STEP_SECONDS]
|
||||
return max(live, key=lambda task: task.get("updated", 0)) if live else None
|
||||
|
||||
|
||||
def _change_text(entry):
|
||||
"""What the user changed, in words for the AI."""
|
||||
if entry.get("action") == "image_delete" and entry.get("target"):
|
||||
return (f"Deleted the picture {entry['target']} (to the Recycle Bin): it is gone from the output folder, "
|
||||
"so do not use, show or hand it in again; another picture is needed for what it was for.")
|
||||
if entry.get("source") != "canvas":
|
||||
words = str(entry.get("action", "")).replace("_", " ")
|
||||
return f"{words}: {entry['target']}" if entry.get("target") else words
|
||||
lines = []
|
||||
for change in (entry.get("detail") or {}).get("changes") or []:
|
||||
if change.get("kind") == "changed":
|
||||
lines.append(f"{change.get('node')} {change.get('widget')}: {change.get('before')} -> {change.get('after')}")
|
||||
elif change.get("kind") == "opened":
|
||||
lines.append(f"opened the workflow {change.get('node')}")
|
||||
else:
|
||||
lines.append(f"{change.get('kind')} {change.get('node')}")
|
||||
return "Changed on the canvas: " + "; ".join(lines[:8])
|
||||
|
||||
|
||||
def note_activity(entry):
|
||||
"""A new activity log entry: a step of the AI's task when the user made it while the AI worked."""
|
||||
stage = action_catalog.USER_ACTIONS.get(entry.get("action"))
|
||||
if stage is None or (entry.get("detail") or {}).get("via") == "mcp":
|
||||
return None
|
||||
at = entry.get("time") or time.time()
|
||||
with ai_tasks._lock:
|
||||
task = _live_task(at)
|
||||
if not task:
|
||||
return None
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": at, "ms": 0, "tool": "user_activity", "kind": "user", "stage": stage,
|
||||
"ok": True, "state": "done", "summary": entry.get("action", ""),
|
||||
"text": ai_tasks._short(_change_text(entry), ai_tasks.MAX_NOTE), "activity": entry["id"],
|
||||
"delivered": False, "results": []}
|
||||
# Appended without moving "updated": the user's own changes do not keep the AI's task open.
|
||||
task["steps"].append(step)
|
||||
del task["steps"][:-ai_tasks.MAX_STEPS]
|
||||
ai_tasks._save(task)
|
||||
activity_log.tag(entry["id"], task["id"])
|
||||
ai_tasks._notify(task["id"])
|
||||
return step
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Select a task to resume from unread user input, without changing task state or ownership;
|
||||
whether a look belongs to the task just done; how many of a task's failures still stand.
|
||||
|
||||
Called under ai_tasks' lock when a caller has no recent open task. Explicit task selection
|
||||
and the user's task waiting for the next AI take precedence in ai_tasks.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
REPLIES = {"user_message", "user_marks", "user_pictures", "user_toolbox"}
|
||||
REPLY_FRESH = 2 * 3600
|
||||
AFTER_DONE = 5 * 60 # a look this soon after the AI said its task is done is part of that task, not a new one
|
||||
|
||||
|
||||
def _reply_at(task, now):
|
||||
"""Latest unread user input; a tool still running keeps its task from automatic takeover."""
|
||||
if any(step.get("state") == "running" for step in task["steps"]):
|
||||
return 0
|
||||
replies = []
|
||||
for step in task["steps"]:
|
||||
if step.get("kind") == "user" and step.get("tool") in REPLIES:
|
||||
replies.append(step)
|
||||
elif step.get("kind") == "panel":
|
||||
replies.extend((step.get("answers") or {}).values())
|
||||
return max((r.get("at", 0) for r in replies if not r.get("delivered") and not r.get("recommended_on_timeout")
|
||||
and 0 <= now - r.get("at", 0) < REPLY_FRESH), default=0)
|
||||
|
||||
|
||||
def latest_replied(tasks, client, now):
|
||||
"""The task of this app with the latest eligible reply, or None."""
|
||||
candidates = ((at, task) for task in tasks.values() if task.get("client") == client and (at := _reply_at(task, now)))
|
||||
_, task = max(candidates, key=lambda item: item[0], default=(0, None))
|
||||
return task
|
||||
|
||||
|
||||
def just_done(task, tasks, waiting, client, now, explicit=False):
|
||||
"""`task` (the app's last one) when it said it is done (report done) moments ago: a look the AI
|
||||
takes right after (checking its result) goes there, the task staying ended, and what it shows or
|
||||
asks the user about it (a before and after, "happy with it?") opens it again; no new task is made
|
||||
for either. Automatic selection yields to a waiting task or unread reply; an explicit
|
||||
task id keeps the same precedence as selecting an open task."""
|
||||
if not task or not task.get("ended") or not 0 <= now - task["ended"] < AFTER_DONE:
|
||||
return None
|
||||
if not any(step.get("tool") == "report" and step.get("at") == task["ended"] for step in task["steps"]):
|
||||
return None # it ended by a pause or another task, not by the AI saying it is done
|
||||
if not explicit and ((waiting and not waiting.get("ended")) or latest_replied(tasks, client, now)):
|
||||
return None
|
||||
return task
|
||||
|
||||
|
||||
RECOVERY_TARGETS = {
|
||||
"set_model": ("node_id",), "set_prompt": ("node_id", "role"), "set_values": ("node_id",),
|
||||
"connect_nodes": ("from_node_id", "to_node_id", "input"), "remove_nodes": ("node_ids",),
|
||||
"run_workflow": ("workflow",), "open_workflow": ("workflow",),
|
||||
"get_model": ("model_id",), "move_model": ("model_id",), "download_model": ("url",),
|
||||
"get_image_info": ("image",), "zoom_image": ("image",),
|
||||
}
|
||||
|
||||
|
||||
def recovery_target(tool, arguments):
|
||||
"""Keep target identity, not prompt text or changed values, to distinguish independent retries."""
|
||||
target = {key: arguments[key] for key in RECOVERY_TARGETS.get(tool, ()) if arguments.get(key) is not None}
|
||||
if tool == "set_prompt":
|
||||
target["role"] = arguments.get("role") or "positive"
|
||||
for key, value in target.items():
|
||||
if key.endswith("node_id"):
|
||||
target[key] = str(value)
|
||||
elif key == "node_ids" and isinstance(value, list):
|
||||
target[key] = sorted(map(str, value))
|
||||
if tool == "set_values" and isinstance(arguments.get("values"), dict):
|
||||
values = arguments["values"]
|
||||
if arguments.get("node_id") is None and values and all(isinstance(value, dict) for value in values.values()):
|
||||
target["settings"] = {str(node): sorted(settings) for node, settings in values.items()}
|
||||
else:
|
||||
target["settings"] = sorted(values)
|
||||
return json.dumps(target, sort_keys=True, ensure_ascii=False) if target else None
|
||||
|
||||
|
||||
def unresolved(steps):
|
||||
"""Only completed successful calls to the same recorded target resolve an earlier failure. A
|
||||
failure that named no target (its arguments missing or misnamed, as a light model's first try
|
||||
often is) is resolved by any later completed success of the same tool."""
|
||||
later_ok, later_tools, count = set(), set(), 0
|
||||
for step in reversed(steps):
|
||||
key = (step.get("tool"), step.get("recovery_target"))
|
||||
if step.get("ok") and step.get("state") in (None, "done"):
|
||||
later_ok.add(key)
|
||||
later_tools.add(key[0])
|
||||
elif not step.get("ok") and key not in later_ok and not (key[1] is None and key[0] in later_tools):
|
||||
count += 1
|
||||
return count
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Which task a task goes on from ("接着"), so work split over tasks is found together: a task an
|
||||
AI begins as new while on another (ai_tasks.begin_task), or one the user starts while looking at
|
||||
another (ai_tasks.start_task), keeps that one as its "from". The user can link a task to another
|
||||
or take the link off (set_from); links() gives a task's own "from" and the tasks that go on from it,
|
||||
and an AI beginning a task that goes on from another hears what was decided there."""
|
||||
|
||||
from . import ai_decisions, ai_tasks
|
||||
|
||||
|
||||
class LinkError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def set_from(task_id, parent_id):
|
||||
"""Task `task_id` goes on from `parent_id` (None: from none). Refuses a loop. Returns the parent id."""
|
||||
with ai_tasks._lock:
|
||||
tasks = ai_tasks._load()
|
||||
task = tasks.get(task_id)
|
||||
if not task:
|
||||
raise LinkError("No such task")
|
||||
if parent_id is not None:
|
||||
if parent_id not in tasks or parent_id == task_id:
|
||||
raise LinkError("No such other task")
|
||||
seen, at = set(), parent_id
|
||||
while at and at not in seen: # going up from the parent must not come back here
|
||||
if at == task_id:
|
||||
raise LinkError("That task already goes on from this one")
|
||||
seen.add(at)
|
||||
at = (tasks.get(at) or {}).get("from")
|
||||
task["from"] = parent_id
|
||||
else:
|
||||
task.pop("from", None)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return parent_id
|
||||
|
||||
|
||||
def links(task_id):
|
||||
"""{from: {id, title} | None, next: [{id, title}]} of task `task_id`, the next oldest first."""
|
||||
with ai_tasks._lock:
|
||||
tasks = ai_tasks._load()
|
||||
task = tasks.get(task_id) or {}
|
||||
brief = lambda item: {"id": item["id"], "title": item.get("title") or ""} # noqa: E731
|
||||
parent = tasks.get(task.get("from"))
|
||||
later = sorted((item for item in tasks.values() if item.get("from") == task_id), key=lambda item: item.get("started", 0))
|
||||
return {"from": brief(parent) if parent else None, "next": [brief(item) for item in later]}
|
||||
|
||||
|
||||
def carried(task):
|
||||
"""For an AI beginning `task`: the task it goes on from and what was decided there, or None."""
|
||||
parent = ai_tasks.get_task(task.get("from")) if task.get("from") else None
|
||||
if not parent:
|
||||
return None
|
||||
return {"task": parent["id"], "title": parent.get("title") or "", "decisions": ai_decisions.lines(parent),
|
||||
"note": "This task goes on from that one; keep to what was decided there unless the user says otherwise."}
|
||||
@@ -0,0 +1,442 @@
|
||||
"""What a workflow run an AI started made: after run_workflow (mcp_server.py), the prompts
|
||||
ComfyUI queued for it are watched in its history until they end, and the pictures and videos
|
||||
they saved become the step's results (ai_tasks.py keeps the step). A thread per run, polling
|
||||
every RUN_POLL_SECONDS, for at most ai_tasks.RUN_WATCH_SECONDS; nothing runs when no AI ran
|
||||
anything. While it runs, where it stands (queued and how many are ahead, the node running and
|
||||
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. The console's lines,
|
||||
the time left and the card's memory join the progress, and the size each sampler works at with
|
||||
its warnings is the step's "estimate" (ai_run_watch.py), the run's shape in boxes its "map" with
|
||||
what changed since the task's run before it (ai_run_map.py); a run the user or the AI stopped
|
||||
(ai_run_control.py) ends "cancelled"."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import ai_gates, ai_run_map, ai_run_watch, ai_tasks
|
||||
from .mcp_tools import ToolError, _image_content, _resolve_image, retext, tool_text
|
||||
|
||||
RUN_POLL_SECONDS = 2.0
|
||||
MAX_STATUS_WAIT = 55
|
||||
MAX_PREVIEWS = 8 # the last PreviewImage pictures of a run the AI is told of
|
||||
MAX_SHOWN = 4 # thumbnails of a finished run's pictures that come with the answer first telling of its end
|
||||
|
||||
|
||||
def _queue():
|
||||
from server import PromptServer
|
||||
return PromptServer.instance.prompt_queue
|
||||
|
||||
|
||||
def outputs_of(record_, folder="output"):
|
||||
"""Saved pictures and videos in one history record; with `folder` "temp", what PreviewImage
|
||||
nodes showed (a pose skeleton, a mask): the AI's to look at, not the user's results."""
|
||||
out = []
|
||||
for output in (record_.get("outputs") or {}).values():
|
||||
for key in ("images", "gifs", "videos"):
|
||||
for item in output.get(key) or []:
|
||||
if isinstance(item, dict) and item.get("type") == folder and item.get("filename"):
|
||||
out.append({"type": "image", "filename": item["filename"], "subfolder": item.get("subfolder") or "",
|
||||
"folder": folder})
|
||||
return out
|
||||
|
||||
|
||||
def _registry():
|
||||
"""ComfyUI's per-node progress of the prompt running now, or None (an older ComfyUI has none)."""
|
||||
try:
|
||||
from comfy_execution import progress
|
||||
except ImportError:
|
||||
return None
|
||||
return progress.global_progress_registry
|
||||
|
||||
|
||||
def progress_of(prompts):
|
||||
"""Where the run of `prompts` stands: queued (how many are ahead), running (the node and its
|
||||
steps, nodes done of all), or None when ComfyUI cannot say."""
|
||||
try:
|
||||
running, pending = _queue().get_current_queue()
|
||||
except Exception: # noqa: BLE001 - no server (tests)
|
||||
return None
|
||||
running_ids = [item[1] for item in running]
|
||||
order = [item[1] for item in sorted(pending, key=lambda item: item[0])]
|
||||
current = next((pid for pid in prompts if pid in running_ids), None)
|
||||
if not current:
|
||||
waiting = [pid for pid in prompts if pid in order]
|
||||
if not waiting:
|
||||
return None
|
||||
return {"state": "queued", "ahead": order.index(waiting[0]) + len(running_ids)}
|
||||
out = {"state": "running", "prompt": prompts.index(current) + 1, "prompts": len(prompts)}
|
||||
registry = _registry()
|
||||
if registry is None or registry.prompt_id != current:
|
||||
return out
|
||||
nodes = dict(registry.nodes)
|
||||
out["nodes_done"] = sum(1 for node in nodes.values() if node["state"].value == "finished")
|
||||
out["nodes"] = len(registry.dynprompt.original_prompt)
|
||||
active = next(((node_id, node) for node_id, node in nodes.items() if node["state"].value == "running"), None)
|
||||
if active:
|
||||
try:
|
||||
info = registry.dynprompt.get_node(active[0]) or {}
|
||||
except Exception: # noqa: BLE001 - a node of the run that is gone already
|
||||
info = {}
|
||||
out["node"] = str((info.get("_meta") or {}).get("title") or info.get("class_type") or active[0])[:80]
|
||||
out["value"], out["max"] = int(active[1]["value"]), int(active[1]["max"])
|
||||
# Like ComfyUI's own tab title: the running node's steps; without steps, the nodes done.
|
||||
if out.get("max", 0) > 1:
|
||||
out["percent"] = max(0, min(100, round(100 * out["value"] / out["max"])))
|
||||
elif out["nodes"]:
|
||||
out["percent"] = max(0, min(100, round(100 * out["nodes_done"] / out["nodes"])))
|
||||
return out
|
||||
|
||||
|
||||
def _show_progress(task_id, step_id, progress):
|
||||
"""The run's progress on its step (kept in memory; the page hears of each change)."""
|
||||
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, queued=None):
|
||||
if not prompts:
|
||||
_stop_run(task_id, step_id, "没有找到已排队的运行 / No queued run was found; completion cannot be confirmed")
|
||||
return True
|
||||
images, previews, 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":
|
||||
failed = True
|
||||
done += 1
|
||||
elif status.get("completed"):
|
||||
done += 1
|
||||
for image in outputs_of(record_):
|
||||
if image not in images: # a prompt ComfyUI answers from its cache repeats the same file
|
||||
images.append(image)
|
||||
previews += [image for image in outputs_of(record_, "temp") if image not in previews]
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
|
||||
if not step:
|
||||
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]
|
||||
previews = previews[-MAX_PREVIEWS:]
|
||||
if (done < len(prompts) and results == step["results"] and step.get("prompts") == len(prompts)
|
||||
and previews == step.get("previews", []) and (not failed or step.get("ok") is False)):
|
||||
return False # nothing new to keep: no write
|
||||
step["results"] = results
|
||||
if previews:
|
||||
step["previews"] = previews
|
||||
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
|
||||
step["error"] = step.get("submission_error") or "ComfyUI 运行出错 / The run failed"
|
||||
if done >= len(prompts):
|
||||
step.pop("progress", None)
|
||||
step["ms"] = int((time.time() - step["at"]) * 1000)
|
||||
if step["state"] in ("done", "error") and not step.get("told") and ai_gates.run_wait() == "call":
|
||||
step["call_ai"] = True # the workbench offers the user a line to paste to the AI
|
||||
task["updated"] = max(task["updated"], time.time())
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return done >= len(prompts)
|
||||
|
||||
|
||||
def _stop_run(task_id, step_id, reason):
|
||||
"""Monitoring ended without proof of completion; never pretend the run succeeded."""
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
|
||||
if not step or step.get("state") != "running":
|
||||
return
|
||||
step.update(state="stopped", ok=False, error=reason, ms=max(0, int((time.time() - step["at"]) * 1000)))
|
||||
step.pop("progress", None)
|
||||
task["updated"] = max(task["updated"], time.time())
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
|
||||
|
||||
def watch(step_id, receipt):
|
||||
"""Follow only IDs returned by this submission, in batch order; never infer ownership from the queue."""
|
||||
with ai_tasks._lock:
|
||||
task_id = next((task["id"] for task in ai_tasks._load().values()
|
||||
if any(step["id"] == step_id for step in task.get("steps") or [])), None)
|
||||
if not task_id:
|
||||
return
|
||||
prompts = receipt.get("prompt_ids") or []
|
||||
if not prompts:
|
||||
_stop_run(task_id, step_id, "缺少运行 ID / Missing submission IDs; refresh ComfyUI before retrying")
|
||||
return
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
|
||||
if not step:
|
||||
return # The user may remove the task while its submission is returning.
|
||||
step["prompt_ids"] = list(prompts)
|
||||
step_at = step["at"]
|
||||
for other in task.get("steps", []): # a new run: no line to paste for an older one any more
|
||||
if other is not step:
|
||||
other.pop("call_ai", None)
|
||||
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
|
||||
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: # from the queue, or from the history of a run that was quicker than this look
|
||||
item = next((queued[pid] for pid in prompts if pid in (queued or {})), None) or next(((history.get(pid) or {}).get("prompt") for pid in prompts if (history.get(pid) or {}).get("prompt")), None)
|
||||
estimated = _estimate(task_id, step_id, item)
|
||||
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:
|
||||
_show_progress(task_id, step_id, progress)
|
||||
time.sleep(RUN_POLL_SECONDS)
|
||||
_stop_run(task_id, step_id, "运行跟踪已超时,未停止出图 / Monitoring timed out; the workflow was not cancelled. Check ComfyUI's queue")
|
||||
|
||||
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) and map (ai_run_map) from its queued prompt `item`,
|
||||
kept on the step once, with what changed since the task's run before."""
|
||||
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"))
|
||||
shape = ai_run_map.map_of(item[2])
|
||||
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)
|
||||
steps = (task or {}).get("steps", [])
|
||||
at = next((index for index, entry in enumerate(steps) if entry["id"] == step_id), None)
|
||||
if at is None:
|
||||
return True
|
||||
before = next((entry["map"] for entry in reversed(steps[:at]) if entry.get("tool") == "run_workflow" and entry.get("map")), None)
|
||||
steps[at].update(estimate=found, map=shape, changes=ai_run_map.changes(before, shape))
|
||||
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.{warning}"
|
||||
if state == "running":
|
||||
parts = []
|
||||
if progress.get("prompts", 0) > 1:
|
||||
parts.append(f"run {progress['prompt']} of {progress['prompts']}")
|
||||
if progress.get("node"):
|
||||
parts.append(f"{progress['node']} step {progress['value']}/{progress['max']}" if progress.get("max", 0) > 1
|
||||
else progress["node"])
|
||||
if "percent" in progress:
|
||||
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: {waiting_words()}; "
|
||||
"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])}."
|
||||
if state in ("asking", "allowed"):
|
||||
return "Your run waits for the user's OK in Anomalous; it has not started."
|
||||
return f"Your run did not finish: {run.get('error') or state}."
|
||||
|
||||
|
||||
def news(task_id):
|
||||
"""The run news for the AI working on task `task_id`: while its latest run goes, and once after it ends."""
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id) if task_id else None
|
||||
run = next((step for step in reversed((task or {}).get("steps", [])) if step.get("tool") == "run_workflow"), None)
|
||||
if not run or not run.get("prompt_ids") or run.get("told"):
|
||||
return None
|
||||
answer = {"state": run.get("state"), "summary": summary(run)}
|
||||
if run.get("state") == "running" and run.get("progress"):
|
||||
answer["progress"] = run["progress"]
|
||||
if run.get("state") not in ("running", "asking", "allowed"):
|
||||
run["told"], run["shown"] = True, False # show_pictures attaches them to this answer
|
||||
run.pop("call_ai", None)
|
||||
ai_tasks._save(task)
|
||||
return answer
|
||||
|
||||
|
||||
def waiting_words():
|
||||
"""How the AI is to wait for a run still going, as the user chose (ai_gates.run_wait)."""
|
||||
if ai_gates.run_wait() == "call":
|
||||
return ("the user tells you when it is done (Anomalous gives them a line to paste to you): say it is running "
|
||||
"and end your turn; do not wait with run_status")
|
||||
return "call run_status with wait_seconds to wait for it"
|
||||
|
||||
|
||||
def show_pictures(result, task_id):
|
||||
"""Attaches thumbnails of the latest run's pictures to the answer that first tells the AI it ended
|
||||
(status() or news() marked it), so it sees what came out without get_image_info; once per run."""
|
||||
if not isinstance(result, dict) or result.get("isError") or not task_id:
|
||||
return result
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
run = next((step for step in reversed((task or {}).get("steps", [])) if step.get("tool") == "run_workflow"), None)
|
||||
if not run or run.get("shown") is not False:
|
||||
return result
|
||||
run["shown"] = True
|
||||
ai_tasks._save(task)
|
||||
shown, items = [], []
|
||||
for item in run.get("results") or []:
|
||||
rel = "/".join(part for part in (item.get("subfolder"), item.get("filename")) if part)
|
||||
if item.get("type") != "image" or len(items) >= MAX_SHOWN:
|
||||
continue
|
||||
try:
|
||||
picture = _image_content(_resolve_image(rel)[1])
|
||||
except ToolError:
|
||||
picture = None
|
||||
if picture:
|
||||
shown.append(rel)
|
||||
items.append(picture)
|
||||
if not items:
|
||||
return result
|
||||
data = dict(result.get("structuredContent") or {})
|
||||
data["pictures_attached"] = shown
|
||||
retext(result, data)
|
||||
result["content"] = [*result["content"], *items]
|
||||
return result
|
||||
|
||||
|
||||
def tell(result, task_id):
|
||||
"""Puts the run news (news()) into a tool result, so the AI need not go and look."""
|
||||
run = news(task_id) if isinstance(result, dict) and not result.get("isError") else None
|
||||
if not run:
|
||||
return result
|
||||
data = result.get("structuredContent")
|
||||
data = dict(data) if isinstance(data, dict) else {}
|
||||
data["your_run"] = run
|
||||
retext(result, data)
|
||||
return result
|
||||
|
||||
|
||||
def status(client, session="", task_id=None, wait_seconds=0, step_id=None):
|
||||
"""The AI's run_status: its task's latest run, where it stands, and what it made; with
|
||||
`wait_seconds`, waits (up to MAX_STATUS_WAIT) until the run ends. No run yet is an answer, not
|
||||
an error (an AI asks before it runs, and the task would show a failed step)."""
|
||||
task_id = ai_tasks.active_task_id(client, session, task_id)
|
||||
deadline = time.monotonic() + max(0.0, min(float(wait_seconds or 0), MAX_STATUS_WAIT))
|
||||
while True:
|
||||
task = ai_tasks.get_task(task_id) if task_id else None
|
||||
run = next((step for step in reversed((task or {}).get("steps", []))
|
||||
if step.get("tool") == "run_workflow" and (not step_id or step["id"] == step_id)), None)
|
||||
if not run:
|
||||
return {"state": "none", "summary": "No run in this task yet.",
|
||||
"next": "run_workflow starts one (workflow: a saved one, with the prompt, seed or size to change)."}
|
||||
if run.get("state") not in ("running", "asking", "allowed") or time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(1)
|
||||
answer = {"state": run.get("state"), "seconds": round(run.get("ms", 0) / 1000) if run.get("state") != "running"
|
||||
else round(time.time() - run["at"]), "summary": summary(run)}
|
||||
if run.get("state") not in ("running", "asking", "allowed") and not run.get("told"):
|
||||
with ai_tasks._lock: # told here: the next answers need not repeat it
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = next((item for item in (task or {}).get("steps", []) if item["id"] == run["id"]), None)
|
||||
if step is not None:
|
||||
step["told"], step["shown"] = True, False # show_pictures attaches them to this answer
|
||||
step.pop("call_ai", 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)
|
||||
for item in run.get("results") or [] if item.get("type") == "image"]
|
||||
if pictures:
|
||||
answer["pictures"] = pictures
|
||||
previews = ["/".join(part for part in (item.get("subfolder"), item["filename"]) if part) for item in run.get("previews") or []]
|
||||
if previews:
|
||||
answer["previews"] = previews
|
||||
answer["previews_note"] = ("What the run's PreviewImage nodes showed (pose skeletons, masks…), in ComfyUI's temp "
|
||||
"folder: zoom_image(image, folder='temp') or get_image_info(image, folder='temp', "
|
||||
"include_image=true) shows them; there is no need to run a preprocessor yourself.")
|
||||
if run.get("error"):
|
||||
answer["error"] = run["error"]
|
||||
# What to do now, in one line a small model can follow (get_situation and the canvas tools say theirs too).
|
||||
answer["next"] = {
|
||||
"running": (f"It is still running: {waiting_words()}. Do not guess it is done or run it again."
|
||||
if ai_gates.run_wait() == "call" else
|
||||
"run_status(wait_seconds=55): it is still running; do not guess it is done or run it again."),
|
||||
"allowed": "run_status(wait_seconds=55): it is about to run.",
|
||||
"done": ("The first answer telling you it ended has its pictures attached (pictures_attached): look at them, "
|
||||
"then report what came out with images= its pictures; show_panel only to compare or ask. "
|
||||
"zoom_image looks closer."),
|
||||
"asking": "get_answers(wait_seconds=55): the run waits for the user's OK in Anomalous.",
|
||||
"cancelled": "It was stopped and nothing more comes of it: ask_user what to change before running again.",
|
||||
}.get(run.get("state"), "get_situation shows what went wrong (a missing model: check_workflow_models); fix it, then run again.")
|
||||
return answer
|
||||
+592
@@ -0,0 +1,592 @@
|
||||
"""AI tasks: what AI apps did through Anomalous's MCP endpoint, kept for the AI workbench page.
|
||||
|
||||
Every tool call an AI app makes (mcp_server.py) becomes a step of a task: a task is one piece
|
||||
of work, named by the AI with begin_task, else the calls of one app that follow each other
|
||||
without a long pause. A step starts in state "running" when the call begins and is updated in
|
||||
place when it finishes: what was done (the tool, a short summary of its arguments), whether it
|
||||
worked, and what came out of it, so the page can show it live and lead there: the models it
|
||||
used, the combo it placed, the canvas change it made (by its activity log entry, for undo),
|
||||
the pictures a run made (ai_task_runs.py watches ComfyUI's history until the run ends), the
|
||||
audio it spoke. The AI can also leave a note (report), with pictures it made another way. What
|
||||
each tool is (kind, stage of the work, gate) is action_catalog.py's; the user's own changes
|
||||
made while an AI works join its task through ai_task_changes.py. A step a restart left
|
||||
"running" is marked stopped when the record is read again.
|
||||
|
||||
The user can start a task first (New task, POST /anomalous/ai_tasks): it waits for an AI, and
|
||||
the next app that calls takes it over, so the work it does next starts there instead of joining
|
||||
the app's last task. Likewise an app whose task ended or paused goes on in the task the user wrote
|
||||
to (a message, marks, pictures, materials) that it has not read yet, rather than starting one.
|
||||
|
||||
Tasks are files in the ComfyUI user folder (anomalous/ai_tasks/<id>.json), the newest
|
||||
MAX_TASKS kept. Recording never fails a tool call: the record is a convenience. The page
|
||||
learns of a change through the "anomalous.ai_task" event. Reading is open like the other
|
||||
pages; renaming and removing are writes (local_guard.py keeps them to this computer). The
|
||||
routes are in ai_tasks_routes.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import action_catalog, activity_log
|
||||
from .ai_task_handoff import just_done, latest_replied, recovery_target, unresolved
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
FORMAT = 1
|
||||
MAX_TASKS = 300
|
||||
MAX_STEPS = 400
|
||||
MAX_TASK_BYTES = 2 * 1024 * 1024
|
||||
IDLE_SECONDS = 20 * 60 # a longer pause between calls of one app starts a new task
|
||||
MAX_TEXT = 300
|
||||
MAX_NOTE = 2000
|
||||
MAX_TITLE = 80
|
||||
MAX_RESULTS = 40
|
||||
RUN_WATCH_SECONDS = 30 * 60
|
||||
MEDIA_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm"}
|
||||
|
||||
# What each tool is (its kind, stage and gate) is action_catalog.py's.
|
||||
OWN_STEPS = {"report", "log_decision", "log_outside", "show_panel", "ask_user", "show_run_map", "propose_repaint_area"} # make their step themselves (with its text or panel)
|
||||
|
||||
_lock = threading.RLock()
|
||||
_tasks = None # id -> task
|
||||
_current = {} # client/session key -> id of the task its calls go to
|
||||
_waiting = [None] # id of the task the user started that no AI has taken yet
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- storage
|
||||
|
||||
def tasks_dir():
|
||||
user_dir = folder_paths.get_user_directory() if hasattr(folder_paths, "get_user_directory") else None
|
||||
if not user_dir:
|
||||
user_dir = os.path.join(folder_paths.base_path, "user")
|
||||
directory = os.path.join(user_dir, "anomalous", "ai_tasks")
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def _path(task_id):
|
||||
return os.path.join(tasks_dir(), f"{task_id}.json")
|
||||
|
||||
|
||||
def marks_dir(task_id):
|
||||
"""Where the pictures the user marked for a task are kept (ai_marks.py); gone with the task."""
|
||||
return os.path.join(os.path.dirname(tasks_dir()), "ai_marks", re.sub(r"[^\w-]", "", task_id))
|
||||
|
||||
|
||||
def _forget(task_id):
|
||||
"""The task's own files: its record and its marked pictures (the plugin's, not the user's pictures)."""
|
||||
try:
|
||||
os.remove(_path(task_id))
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(marks_dir(task_id), ignore_errors=True)
|
||||
|
||||
|
||||
def _load():
|
||||
global _tasks
|
||||
if _tasks is None:
|
||||
_tasks = {}
|
||||
for name in os.listdir(tasks_dir()):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(tasks_dir(), name), encoding="utf-8") as source:
|
||||
task = json.load(source)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if isinstance(task, dict) and task.get("format") == FORMAT and isinstance(task.get("id"), str):
|
||||
_tasks[task["id"]] = task
|
||||
if _stop_running(task):
|
||||
_save(task)
|
||||
# A task the user started before ComfyUI restarted still waits for the next AI.
|
||||
waiting = [task for task in _tasks.values() if task.get("waiting") and not task.get("ended")]
|
||||
if waiting and _waiting[0] is None:
|
||||
_waiting[0] = max(waiting, key=lambda task: task.get("started", 0))["id"]
|
||||
return _tasks
|
||||
|
||||
|
||||
def _stop_running(task):
|
||||
"""Steps a call or a run left "running" when ComfyUI stopped: nothing watches them any more."""
|
||||
stopped = False
|
||||
for step in task.get("steps") or []:
|
||||
if step.get("state") == "asking" and step.get("gate") and not step["gate"].get("timed_out"):
|
||||
step["gate"]["timed_out"] = True # no old process is still waiting for this decision
|
||||
stopped = True
|
||||
if step.get("state") == "running":
|
||||
step.update(state="stopped", ok=False, error="ComfyUI 重启时这一步还没结束 / ComfyUI restarted before this step ended")
|
||||
stopped = True
|
||||
return stopped
|
||||
|
||||
|
||||
def _save(task):
|
||||
try:
|
||||
atomic_write_json(_path(task["id"]), task, MAX_TASK_BYTES)
|
||||
except (OSError, ValueError) as error:
|
||||
log.warning("Anomalous AI tasks: %s not saved: %s", task["id"], error)
|
||||
|
||||
|
||||
def _drop_oldest():
|
||||
tasks = _load()
|
||||
if len(tasks) <= MAX_TASKS:
|
||||
return
|
||||
for task in sorted(tasks.values(), key=lambda item: item.get("updated", 0))[: len(tasks) - MAX_TASKS]:
|
||||
tasks.pop(task["id"], None)
|
||||
_forget(task["id"]) # the pictures and files it made stay
|
||||
|
||||
|
||||
def _notify(task_id, extra=None):
|
||||
try:
|
||||
from server import PromptServer
|
||||
PromptServer.instance.send_sync("anomalous.ai_task", {"id": task_id, **(extra or {})})
|
||||
except Exception: # noqa: BLE001 - tests and early start have no server; the page polls on open
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- who is calling (named in ai_callers.py)
|
||||
|
||||
def _owner_key(client, session=""):
|
||||
return f"{client}::{session}" if session else client
|
||||
|
||||
|
||||
def _bind_owner(client, session, task_id):
|
||||
_current[_owner_key(client, session)] = task_id
|
||||
if not session or client not in _current:
|
||||
_current[client] = task_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- tasks and steps
|
||||
|
||||
def _short(value, limit=MAX_TEXT):
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
||||
return text if len(text) <= limit else text[:limit] + "…"
|
||||
|
||||
|
||||
def _new_task(client, title="", now=None, session=""):
|
||||
now = time.time() if now is None else now
|
||||
task = {"format": FORMAT, "id": f"{int(now)}_{uuid.uuid4().hex[:8]}", "title": _short(title, MAX_TITLE),
|
||||
"title_from": "ai" if title else "auto", "client": client, "started": now, "updated": now,
|
||||
"ended": None, "steps": []}
|
||||
if session:
|
||||
task["session"] = session
|
||||
_load()[task["id"]] = task
|
||||
_bind_owner(client, session, task["id"])
|
||||
_drop_oldest()
|
||||
return task
|
||||
|
||||
|
||||
def _claim_explicit(tasks, task_id, client, session=""):
|
||||
if not task_id or task_id not in tasks or tasks[task_id].get("ended"):
|
||||
return None
|
||||
picked = tasks[task_id]
|
||||
if picked.get("waiting"):
|
||||
if _waiting[0] == task_id:
|
||||
_waiting[0] = None
|
||||
picked["waiting"] = False
|
||||
if not picked.get("client"):
|
||||
picked["client"] = client
|
||||
_bind_owner(client, session, task_id)
|
||||
return picked
|
||||
|
||||
|
||||
def _take_waiting(client, now, session=""):
|
||||
"""The task the user started, now this app's (its own open task ends), or None."""
|
||||
task = _load().get(_waiting[0])
|
||||
_waiting[0] = None
|
||||
if not task or task.get("ended"): # it waits until an AI takes it, as its page says, or the user starts another
|
||||
return None
|
||||
owner = _owner_key(client, session)
|
||||
previous = _load().get(_current.get(owner) or (None if session else _current.get(client)))
|
||||
if previous and previous is not task and not previous.get("ended"):
|
||||
previous["ended"] = previous.get("updated")
|
||||
_save(previous)
|
||||
task["client"] = client
|
||||
if session:
|
||||
task["session"] = session
|
||||
task["waiting"] = False
|
||||
_bind_owner(client, session, task["id"])
|
||||
return task
|
||||
|
||||
|
||||
ABOUT_TYPES = {"model": "model_id", "image": "image", "combo": "name", "workflow": "name"} # what a task the user starts can be about
|
||||
|
||||
|
||||
def _about(items):
|
||||
"""What the user handed to the AI with a task (a model, an output image, a combo), checked: plain strings only."""
|
||||
out = []
|
||||
for item in items if isinstance(items, list) else []:
|
||||
key = ABOUT_TYPES.get(item.get("type")) if isinstance(item, dict) else None
|
||||
if key and isinstance(item.get(key), str) and item[key].strip():
|
||||
out.append({"type": item["type"], key: _short(item[key].strip(), 300),
|
||||
**({"label": _short(item["label"], 120)} if isinstance(item.get("label"), str) else {})})
|
||||
return out[:5]
|
||||
|
||||
|
||||
def start_task(title="", now=None, about=None, from_task=None):
|
||||
"""A task the user starts; it waits for the next AI app that calls. `about`: what the user
|
||||
handed to the AI with it (from a model page, an image or a combo); `from_task`: the task it
|
||||
goes on from (ai_task_links.py). Returns the task."""
|
||||
now = time.time() if now is None else now
|
||||
with _lock:
|
||||
before = _load().get(_waiting[0])
|
||||
if before and not before.get("steps"): # started before and never taken: this one replaces it
|
||||
remove_task(before["id"])
|
||||
task = {"format": FORMAT, "id": f"{int(now)}_{uuid.uuid4().hex[:8]}", "title": _short(str(title or "").strip(), MAX_TITLE),
|
||||
"title_from": "user", "client": "", "started": now, "updated": now, "ended": None, "steps": [], "waiting": True}
|
||||
if not task["title"]:
|
||||
task["title_from"] = "auto"
|
||||
if _about(about):
|
||||
task["about"] = _about(about)
|
||||
if from_task in _load():
|
||||
task["from"] = from_task
|
||||
_load()[task["id"]] = task
|
||||
_waiting[0] = task["id"]
|
||||
_drop_oldest()
|
||||
_save(task)
|
||||
_notify(task["id"])
|
||||
return task
|
||||
|
||||
|
||||
def active_task_id(client, session="", task_id=None):
|
||||
"""The active task id for this caller without creating a new task."""
|
||||
with _lock:
|
||||
tasks = _load()
|
||||
if task_id and task_id in tasks:
|
||||
return task_id
|
||||
found = _current.get(_owner_key(client, session)) or (None if session else _current.get(client))
|
||||
return found if found in tasks else None
|
||||
|
||||
|
||||
def current_task(client, now=None, session="", task_id=None, follow_up=False, read_only=False):
|
||||
"""The task this app's calls go to now: an explicit task, one the user started for the next
|
||||
AI, else its open one, unless it paused too long. `follow_up`: a panel, question or note, which
|
||||
right after the AI said its task is done is about that task (ai_task_handoff.just_done).
|
||||
`read_only` keeps such a task ended while a follow-up reopens it; both honor an explicit id."""
|
||||
now = time.time() if now is None else now
|
||||
with _lock:
|
||||
tasks = _load()
|
||||
task = tasks.get(task_id or _current.get(_owner_key(client, session)))
|
||||
if (follow_up or read_only) and just_done(task, tasks, tasks.get(_waiting[0]), client, now, explicit=bool(task_id)):
|
||||
_bind_owner(client, session, task["id"])
|
||||
if follow_up:
|
||||
task["ended"] = None
|
||||
_save(task)
|
||||
return task
|
||||
picked = _claim_explicit(tasks, task_id, client, session)
|
||||
if picked:
|
||||
return picked
|
||||
waiting = _take_waiting(client, now, session=session)
|
||||
if waiting:
|
||||
return waiting
|
||||
task = tasks.get(_current.get(_owner_key(client, session)) or (None if session else _current.get(client)))
|
||||
if task and not task.get("ended") and now - task.get("updated", 0) < IDLE_SECONDS:
|
||||
_bind_owner(client, session, task["id"])
|
||||
return task
|
||||
if task and not task.get("ended"):
|
||||
task["ended"] = task.get("updated")
|
||||
_save(task)
|
||||
return _take_replied(tasks, client, now, session) or _new_task(client, now=now, session=session)
|
||||
|
||||
|
||||
def _take_replied(tasks, client, now, session="", previous=None):
|
||||
"""The newest task of this app the user wrote to lately, since its AI last read (an ended one
|
||||
opens again), now this app's instead of a new one (its own open task `previous` ends), or None."""
|
||||
replied = latest_replied(tasks, client, now)
|
||||
if replied is None:
|
||||
return None
|
||||
if previous and previous is not replied and not previous.get("ended"):
|
||||
previous["ended"] = previous.get("updated")
|
||||
_save(previous)
|
||||
replied["ended"] = None
|
||||
_bind_owner(client, session, replied["id"])
|
||||
_save(replied)
|
||||
return replied
|
||||
|
||||
|
||||
def begin_task(client, title, now=None, new_task=False, session="", task_id=None):
|
||||
"""Name or resume the active task; only an explicit new piece of work splits it."""
|
||||
now = time.time() if now is None else now
|
||||
with _lock:
|
||||
tasks = _load()
|
||||
previous = tasks.get(_current.get(_owner_key(client, session)) or (None if session else _current.get(client)))
|
||||
task = None if new_task else _claim_explicit(tasks, task_id, client, session)
|
||||
if task is None:
|
||||
task = _take_waiting(client, now, session=session)
|
||||
if task is None and not new_task and previous and not previous.get("ended") and now - previous.get("updated", 0) < IDLE_SECONDS:
|
||||
task = previous
|
||||
if task is None and not new_task:
|
||||
task = _take_replied(tasks, client, now, session, previous)
|
||||
if task is None:
|
||||
if previous and not previous.get("ended"):
|
||||
previous["ended"] = now
|
||||
_save(previous)
|
||||
task = _new_task(client, title, now, session=session)
|
||||
if new_task and previous:
|
||||
task["from"] = previous["id"] # begun from that one (ai_task_links.py)
|
||||
elif not task.get("title") or task.get("title_from") == "auto":
|
||||
task["title"] = _short(title, MAX_TITLE)
|
||||
task["title_from"] = "ai"
|
||||
_bind_owner(client, session, task["id"])
|
||||
task["updated"] = now
|
||||
_save(task)
|
||||
_notify(task["id"])
|
||||
return task
|
||||
|
||||
|
||||
def _add_step(task, step):
|
||||
task["steps"].append(step)
|
||||
del task["steps"][:-MAX_STEPS]
|
||||
task["updated"] = max(task.get("updated", 0), step["at"] + step.get("ms", 0) / 1000)
|
||||
if task.get("title_from") == "auto" and not task.get("title") and step["kind"] not in ("look", "note") and step["ok"]:
|
||||
task["title"] = step["summary"][:MAX_TITLE]
|
||||
|
||||
|
||||
def summary_of(tool, arguments):
|
||||
"""A few words on what a call asked for: its main argument."""
|
||||
for key in ("title", "query", "text", "name", "workflow", "character", "model_id", "url", "what", "names", "type", "types"):
|
||||
if arguments.get(key) not in (None, "", []):
|
||||
value = arguments[key]
|
||||
if key == "model_id": # "type:0:folder/file.safetensors": the file, and where it moves to
|
||||
value = str(value).split(":", 2)[-1].rsplit("/", 1)[-1] + (f" → {arguments['to_type']}" if arguments.get("to_type") else "")
|
||||
return _short(f"{tool}: {_short(value, 120)}", 160)
|
||||
return tool
|
||||
|
||||
|
||||
def _model_result(model_id):
|
||||
from .mcp_tools import _resolve_model
|
||||
try:
|
||||
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
|
||||
except Exception: # noqa: BLE001 - a model that went away is still worth its id
|
||||
return {"type": "model", "model_id": str(model_id)}
|
||||
rel = os.path.relpath(file_path, base_dir).replace(os.sep, "/")
|
||||
return {"type": "model", "model_id": str(model_id), "folder": folder_type, "path_idx": path_idx, "rel": rel,
|
||||
"name": os.path.basename(file_path)}
|
||||
|
||||
|
||||
def results_of(tool, arguments, data):
|
||||
"""What came out of a call, for the page to show and lead to."""
|
||||
data = data if isinstance(data, dict) else {}
|
||||
out = []
|
||||
if tool in ("set_model", "add_lora", "open_in_anomalous") and arguments.get("model_id"):
|
||||
out.append(_model_result(arguments["model_id"]))
|
||||
if tool == "place_combo" and arguments.get("name"):
|
||||
out.append({"type": "combo", "name": _short(arguments["name"], 120)})
|
||||
if isinstance(data.get("_activity"), str):
|
||||
out.append({"type": "canvas", "activity": data["_activity"]})
|
||||
if tool == "speak":
|
||||
out.extend({"type": "audio", "file": _short(f, 200)} for f in data.get("files") or [] if isinstance(f, str))
|
||||
if tool == "scan_models" and data.get("started"):
|
||||
out.append({"type": "scan"})
|
||||
if tool == "download_model" and data.get("started"):
|
||||
out.append({**_download_result(data), "name": _short(str(data.get("model") or ""), 160)})
|
||||
if tool == "download_missing_models":
|
||||
out.extend(_download_result(item) for item in data.get("started") or [] if isinstance(item, dict))
|
||||
return out[:MAX_RESULTS]
|
||||
|
||||
|
||||
def _download_result(started):
|
||||
"""A download the AI started (mcp_actions' answer): the file it makes, and its size when the site said."""
|
||||
size = started.get("size")
|
||||
return {"type": "download", "file": _short(str(started.get("to") or "").rsplit("/", 1)[-1], 200),
|
||||
"size": size if isinstance(size, int) and size > 0 else None}
|
||||
|
||||
|
||||
def _count_of(data):
|
||||
"""How many things a read found: the length of its first list."""
|
||||
if isinstance(data, dict):
|
||||
for value in data.values():
|
||||
if isinstance(value, list):
|
||||
return len(value)
|
||||
return None
|
||||
|
||||
|
||||
def begin_step(client, tool, arguments, started=None, read_only=False, session="", task_id=None):
|
||||
"""Starts an in-flight step in state 'running' before a tool executes. Returns (task_id, step_id) or None."""
|
||||
if not action_catalog.is_step(tool) or tool in OWN_STEPS:
|
||||
return None
|
||||
try:
|
||||
started = time.time() if started is None else started
|
||||
args = arguments if isinstance(arguments, dict) else {}
|
||||
what = action_catalog.entry(tool, read_only)
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": started, "ms": 0, "tool": tool, "kind": what.kind, "stage": what.stage,
|
||||
"ok": True, "state": "running", "summary": summary_of(tool, args), "results": [],
|
||||
"recovery_target": recovery_target(tool, args)}
|
||||
with _lock:
|
||||
task = current_task(client, started, session=session, task_id=task_id, read_only=read_only)
|
||||
_add_step(task, step)
|
||||
_save(task)
|
||||
_notify(task["id"])
|
||||
return task["id"], step["id"]
|
||||
except Exception: # noqa: BLE001 - the record must never fail the AI's call
|
||||
log.warning("Anomalous AI tasks: begin_step of %s failed", tool, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def finish_step(client, tool, arguments, result, started, read_only=False, step_ref=None, session="", task_id=None):
|
||||
"""Completes an in-flight step (or creates one if step_ref is None) with its final outcome. Never raises."""
|
||||
if not action_catalog.is_step(tool) or tool in OWN_STEPS:
|
||||
return None
|
||||
try:
|
||||
args = arguments if isinstance(arguments, dict) else {}
|
||||
data = result.get("structuredContent") if isinstance(result, dict) else None
|
||||
failed = not isinstance(result, dict) or bool(result.get("isError"))
|
||||
error = _short(" ".join(item.get("text", "") for item in result.get("content") or [] if isinstance(item, dict))) if failed and isinstance(result, dict) else ""
|
||||
ms = max(0, int((time.time() - started) * 1000))
|
||||
what = action_catalog.entry(tool, read_only)
|
||||
kind = what.kind
|
||||
waiting = isinstance(data, dict) and data.get("waiting_for_user") # a gate the user has not answered (ai_gates.py)
|
||||
state = "error" if failed else "asking" if waiting else "running" if tool == "run_workflow" else "done"
|
||||
summary = summary_of(tool, args)
|
||||
results = [] if failed else results_of(tool, args, data)
|
||||
with _lock:
|
||||
task = _load().get(step_ref[0]) if isinstance(step_ref, tuple) and len(step_ref) == 2 else None
|
||||
step = next((item for item in (task or {}).get("steps", []) if item.get("id") == step_ref[1]), None) if task else None
|
||||
# A user can answer just after check() times out, before this call returns.
|
||||
gate = (step or {}).get("gate") or {}
|
||||
if waiting and gate.get("decision"):
|
||||
failed = gate["decision"] == "deny"
|
||||
state = "error" if failed else "done" if gate.get("used") else "allowed"
|
||||
error = (step.get("error") or "你没有允许 / You said no") if failed else ""
|
||||
if task is None:
|
||||
task = current_task(client, started, session=session, task_id=task_id, read_only=read_only)
|
||||
if step is None:
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": started, "ms": ms, "tool": tool, "kind": kind, "stage": what.stage,
|
||||
"ok": not failed, "state": state, "summary": summary, "results": results}
|
||||
_add_step(task, step)
|
||||
else:
|
||||
step.update(ms=ms, ok=not failed, state=state, summary=summary, results=results)
|
||||
step["recovery_target"] = recovery_target(tool, args)
|
||||
if error:
|
||||
step["error"] = error
|
||||
else:
|
||||
step.pop("error", None)
|
||||
if kind == "look" and not failed and tool.startswith(("search_", "list_")):
|
||||
step["count"] = _count_of(data)
|
||||
task["updated"] = max(task.get("updated", 0), step["at"] + ms / 1000)
|
||||
if task.get("title_from") == "auto" and not task.get("title") and kind not in ("look", "note") and step["ok"]:
|
||||
task["title"] = summary[:MAX_TITLE]
|
||||
_save(task)
|
||||
for result in results:
|
||||
if result["type"] == "canvas":
|
||||
activity_log.tag(result["activity"], task["id"]) # the activity page links back to the task
|
||||
_notify(task["id"])
|
||||
return step
|
||||
except Exception: # noqa: BLE001 - the record must never fail the AI's call
|
||||
log.warning("Anomalous AI tasks: step of %s not recorded", tool, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def record(client, tool, arguments, result, started, read_only=False, session="", task_id=None):
|
||||
"""One tool call as a step of the app's current task. Never raises."""
|
||||
return finish_step(client, tool, arguments, result, started, read_only, session=session, task_id=task_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- notes from the AI
|
||||
|
||||
def _output_media(path):
|
||||
"""An output-folder file the AI names ("subfolder/name.png"), as an image result, or None."""
|
||||
root = os.path.realpath(folder_paths.get_output_directory())
|
||||
full = os.path.realpath(os.path.join(root, str(path).replace("\\", "/").lstrip("/")))
|
||||
if os.path.commonpath((root, full)) != root or not os.path.isfile(full):
|
||||
return None
|
||||
if os.path.splitext(full)[1].lower() not in MEDIA_EXTS:
|
||||
return None
|
||||
rel = os.path.relpath(full, root).replace(os.sep, "/")
|
||||
subfolder, _, filename = rel.rpartition("/")
|
||||
return {"type": "image", "filename": filename, "subfolder": subfolder, "folder": "output"}
|
||||
|
||||
|
||||
def report(client, text, images=None, done=False, now=None, session="", task_id=None, reply_to=None):
|
||||
"""A note from the AI, with pictures it made (in ComfyUI's output folder); `reply_to`: the user's
|
||||
message or marks it answers (both steps then point at each other). Returns the step."""
|
||||
text = str(text or "").strip()
|
||||
if not text and not images:
|
||||
raise ValueError("text or images is needed")
|
||||
results, missing = [], []
|
||||
for path in (images or [])[:MAX_RESULTS]:
|
||||
item = _output_media(path)
|
||||
(results.append(item) if item else missing.append(str(path)))
|
||||
now = time.time() if now is None else now
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "report", "kind": "note", "ok": True,
|
||||
"state": "done", "summary": "report", "text": _short(text, MAX_NOTE), "results": results}
|
||||
with _lock:
|
||||
task = current_task(client, now, session=session, task_id=task_id, follow_up=True)
|
||||
asked = next((item for item in task["steps"] if reply_to and item["id"] == reply_to and item.get("kind") == "user"), None)
|
||||
if reply_to and not asked:
|
||||
raise ValueError("reply_to is the id of the user's message or marks (user_messages[].id) in this task")
|
||||
if asked:
|
||||
step["reply_to"], asked["replied"] = asked["id"], step["id"]
|
||||
_add_step(task, step)
|
||||
if done:
|
||||
task["ended"] = now
|
||||
_save(task)
|
||||
_notify(task["id"])
|
||||
return step, missing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the page's view
|
||||
|
||||
def task_summary(task, now=None):
|
||||
now = time.time() if now is None else now
|
||||
steps = task.get("steps") or []
|
||||
kinds = {}
|
||||
for step in steps:
|
||||
kinds[step["kind"]] = kinds.get(step["kind"], 0) + 1
|
||||
images = [item for step in steps for item in step.get("results") or [] if item.get("type") == "image"]
|
||||
asks = sum(1 for step in steps if step.get("kind") == "panel" and not step["panel"].get("closed")
|
||||
for block in step["panel"]["blocks"] if block.get("type") in ("choices", "input", "picks")
|
||||
and block.get("id") and block["id"] not in step.get("answers", {}))
|
||||
asks += sum(1 for step in steps if step.get("state") == "asking")
|
||||
return {
|
||||
"id": task["id"], "title": task.get("title") or "", "client": task.get("client") or ("" if task.get("waiting") else "AI"),
|
||||
"started": task.get("started"), "updated": task.get("updated"), "ended": task.get("ended"),
|
||||
"waiting": bool(task.get("waiting")) and not task.get("ended"),
|
||||
"live": any(step.get("state") == "running" and now - step.get("at", 0) < RUN_WATCH_SECONDS for step in steps),
|
||||
"steps": len(steps), "kinds": kinds, "errors": unresolved(steps),
|
||||
"images": len(images), "cover": images[-1] if images else None, "asks": asks, "from": task.get("from"),
|
||||
}
|
||||
|
||||
|
||||
def list_tasks(limit=100):
|
||||
with _lock:
|
||||
tasks = sorted(_load().values(), key=lambda task: task.get("updated", 0), reverse=True)
|
||||
return [task_summary(task) for task in tasks[:limit]]
|
||||
|
||||
|
||||
def get_task(task_id):
|
||||
with _lock:
|
||||
task = _load().get(task_id)
|
||||
return json.loads(json.dumps(task)) if task else None
|
||||
|
||||
|
||||
def rename_task(task_id, title):
|
||||
with _lock:
|
||||
task = _load().get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
task["title"] = _short(str(title or "").strip(), MAX_TITLE)
|
||||
task["title_from"] = "user" if task["title"] else "auto"
|
||||
_save(task)
|
||||
return task
|
||||
|
||||
|
||||
def remove_task(task_id):
|
||||
with _lock:
|
||||
task = _load().pop(task_id, None)
|
||||
if not task:
|
||||
return False
|
||||
for client, current in list(_current.items()):
|
||||
if current == task_id:
|
||||
_current.pop(client, None)
|
||||
if _waiting[0] == task_id:
|
||||
_waiting[0] = None
|
||||
_forget(task_id)
|
||||
return True
|
||||
@@ -0,0 +1,291 @@
|
||||
"""The AI workbench's routes over ai_tasks.py (the record) and ai_panels.py (panels, answers,
|
||||
messages): the list and one task (reads, open like the other pages), and the writes the page
|
||||
makes - start a task, rename or remove one, answer a panel, write to the AI, answer a gate and
|
||||
set the gates' policies (ai_gates.py), stop a run (ai_run_control.py), hand the AI pictures
|
||||
(ai_pictures.py) - (local_guard.py keeps the writes to this computer)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import activity_log, ai_decisions, ai_gates, ai_marks, ai_pictures, ai_run_control, ai_task_canvas, ai_task_changes, ai_task_links, ai_tasks, ai_toolbox
|
||||
|
||||
|
||||
async def api_list(request):
|
||||
try:
|
||||
limit = max(1, min(300, int(request.query.get("limit", 100))))
|
||||
except ValueError:
|
||||
limit = 100
|
||||
return web.json_response({"tasks": ai_tasks.list_tasks(limit)})
|
||||
|
||||
|
||||
async def api_start(request):
|
||||
"""POST /anomalous/ai_tasks: {title, about?, from?} - the user starts a task for the next AI app that
|
||||
calls; `about` names what it is about ([{type: model | image | combo, model_id | image | name}]),
|
||||
`from` the task it goes on from."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
body = {}
|
||||
body = body if isinstance(body, dict) else {}
|
||||
title = body.get("title") if isinstance(body.get("title"), str) else ""
|
||||
parent = body.get("from") if isinstance(body.get("from"), str) else None
|
||||
return web.json_response({"task": ai_tasks.task_summary(ai_tasks.start_task(title, about=body.get("about"), from_task=parent))})
|
||||
|
||||
|
||||
async def api_get(request):
|
||||
task = ai_tasks.get_task(request.match_info["task_id"])
|
||||
if task:
|
||||
task["canvas_mode"] = ai_task_canvas.mode_of(task) # the task's own choice, else the user's default
|
||||
task["decisions"], task["decisions_gap"] = ai_decisions.decisions(task), ai_decisions.gap(task)
|
||||
task["links"] = ai_task_links.links(task["id"]) # the task it goes on from, those going on from it
|
||||
ai_pictures.mark_gone(task) # pictures deleted since: the page says so
|
||||
return web.json_response({"task": task}) if task else web.json_response({"error": "not_found"}, status=404)
|
||||
|
||||
|
||||
async def api_change(request):
|
||||
"""POST /anomalous/ai_tasks/{id}: {"title": "…"} renames it, {"remove": true} takes it off the list,
|
||||
{"canvas": "new" | "current"} sets whether the AI works in a tab of the task's own, {"from": id | null}
|
||||
links it to the task it goes on from (or takes the link off)."""
|
||||
task_id = request.match_info["task_id"]
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
body = None
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"error": "Expected {title} or {remove: true}"}, status=400)
|
||||
if body.get("remove") is True:
|
||||
return web.json_response({"removed": ai_tasks.remove_task(task_id)})
|
||||
if "canvas" in body:
|
||||
try:
|
||||
return web.json_response({"canvas": ai_task_canvas.set_mode(task_id, body["canvas"])})
|
||||
except ValueError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
if "from" in body:
|
||||
try:
|
||||
return web.json_response({"from": ai_task_links.set_from(task_id, body["from"] if isinstance(body["from"], str) else None)})
|
||||
except ai_task_links.LinkError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
if "title" in body:
|
||||
task = ai_tasks.rename_task(task_id, body["title"])
|
||||
return web.json_response({"task": ai_tasks.task_summary(task)}) if task else web.json_response({"error": "not_found"}, status=404)
|
||||
return web.json_response({"error": "Expected {title} or {remove: true}"}, status=400)
|
||||
|
||||
|
||||
async def api_answer(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/answer: {step, block, choice: [ids] | text | skip: true} - the user's answer on a panel."""
|
||||
from . import ai_panels, ai_repaint
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
body = None
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"error": "Expected {step, block, choice | text}"}, status=400)
|
||||
task_id, step_id, skip = request.match_info["task_id"], str(body.get("step")), body.get("skip") is True
|
||||
try:
|
||||
# A repaint area picked: its picture is made first (outside the lock), so the AI gets it with the answer.
|
||||
extra = await asyncio.to_thread(ai_repaint.prepare, task_id, step_id, body.get("choice"), body.get("text"), skip)
|
||||
except ai_repaint.RepaintError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
try:
|
||||
answers = ai_panels.answer(task_id, step_id, str(body.get("block")), body.get("choice"), body.get("text"), skip=skip, extra=extra)
|
||||
except ai_panels.PanelError as error:
|
||||
ai_repaint.discard(extra)
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
return web.json_response({"answers": answers})
|
||||
|
||||
|
||||
async def api_typing(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/typing: the user is typing an answer or a message (ai_answer_wait.py)."""
|
||||
from . import ai_answer_wait
|
||||
|
||||
if not ai_answer_wait.typing(request.match_info["task_id"]):
|
||||
return web.json_response({"error": "not_found"}, status=404)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def api_message(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/message: {text, refs?} - the user writes to the AI on that task (refs: its "@")."""
|
||||
from . import ai_panels
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
body = None
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"error": "Expected {text}"}, status=400)
|
||||
try:
|
||||
step = ai_panels.leave_message(request.match_info["task_id"], body.get("text"), body.get("refs"))
|
||||
except ai_panels.PanelError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
return web.json_response({"step": step})
|
||||
|
||||
|
||||
async def _body(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
return body if isinstance(body, dict) else None
|
||||
|
||||
|
||||
async def api_gate(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/gate: {step, decision: allow | deny, text?, remember?}."""
|
||||
body = await _body(request)
|
||||
if body is None:
|
||||
return web.json_response({"error": "Expected {step, decision}"}, status=400)
|
||||
try:
|
||||
step = ai_gates.decide(request.match_info["task_id"], str(body.get("step")), body.get("decision"),
|
||||
body.get("text") or "", bool(body.get("remember")))
|
||||
except ValueError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
return web.json_response({"step": step})
|
||||
|
||||
|
||||
async def api_gates(request):
|
||||
"""GET /anomalous/ai_gates: {policies: {gate: policy}, canvas_tab, tools}; POST {gate, policy} changes one,
|
||||
POST {canvas_tab: new | current} whether new tasks work in a tab of their own (ai_task_canvas.py),
|
||||
POST {runs: call | wait}
|
||||
how the AI waits for a run (ai_gates.run_wait), POST {answer_wait: 60 | 180 | 600} for the user's answer,
|
||||
POST {no_answer: stop | recommended} what happens when that time runs out."""
|
||||
def settings():
|
||||
return {"policies": ai_gates.policies(), "canvas_tab": ai_task_canvas.default_mode(),
|
||||
"runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer()}
|
||||
|
||||
if request.method == "GET":
|
||||
return web.json_response(settings())
|
||||
body = await _body(request)
|
||||
try:
|
||||
if "canvas_tab" in (body or {}):
|
||||
ai_task_canvas.set_default(body["canvas_tab"])
|
||||
elif "runs" in (body or {}):
|
||||
ai_gates.set_run_wait(body["runs"])
|
||||
elif "answer_wait" in (body or {}):
|
||||
ai_gates.set_answer_wait(body["answer_wait"])
|
||||
elif "no_answer" in (body or {}):
|
||||
ai_gates.set_no_answer(body["no_answer"])
|
||||
else:
|
||||
ai_gates.set_policy((body or {}).get("gate"), (body or {}).get("policy"))
|
||||
except ValueError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
return web.json_response(settings())
|
||||
|
||||
|
||||
async def api_marks(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/marks: {image, png, marks, text, mode, mask} - the user's marks on a picture for the AI."""
|
||||
from . import ai_panels
|
||||
|
||||
body = await _body(request)
|
||||
if body is None:
|
||||
return web.json_response({"error": "Expected {image, png, marks}"}, status=400)
|
||||
try:
|
||||
step = await asyncio.to_thread(ai_marks.save, request.match_info["task_id"], body.get("image"),
|
||||
body.get("png"), body.get("marks"), body.get("text"), None, body.get("mask"), body.get("mode"))
|
||||
except ai_marks.MarksError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
ai_panels.wake()
|
||||
return web.json_response({"step": step})
|
||||
|
||||
|
||||
async def api_marked(request):
|
||||
"""GET /anomalous/ai_tasks/{id}/marks/{name}: a marked picture the user sent."""
|
||||
path = ai_marks.path_of(request.match_info["task_id"], request.match_info["name"])
|
||||
if not path:
|
||||
return web.json_response({"error": "Not found"}, status=404)
|
||||
return web.FileResponse(path, headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
|
||||
async def api_toolbox_pick(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/toolbox: {mode, nodes, combos, workflows, prompts, models} - what the user hands the AI."""
|
||||
from . import ai_panels
|
||||
|
||||
body = await _body(request)
|
||||
try:
|
||||
step = ai_toolbox.pick(request.match_info["task_id"], body if body is not None else None, (body or {}).get("mode", "prefer"))
|
||||
except ai_toolbox.ToolboxError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
ai_panels.wake()
|
||||
return web.json_response({"step": step})
|
||||
|
||||
|
||||
async def api_pictures(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/pictures: {images: ["subfolder/name.png"], text?} - the user hands the AI pictures."""
|
||||
from . import ai_panels, ai_pictures
|
||||
|
||||
body = await _body(request)
|
||||
try:
|
||||
step = await asyncio.to_thread(ai_pictures.hand, request.match_info["task_id"], (body or {}).get("images"), (body or {}).get("text") or "")
|
||||
except ai_pictures.PicturesError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
ai_panels.wake()
|
||||
return web.json_response({"step": step})
|
||||
|
||||
|
||||
async def api_upload(request):
|
||||
"""POST /anomalous/ai_pictures/upload?name= with a picture's bytes - one the user dropped or pasted
|
||||
into a message, kept in the output folder (ai_pictures.save_upload): {image, filename, subfolder}."""
|
||||
from . import ai_pictures
|
||||
|
||||
data = await request.read()
|
||||
try:
|
||||
image = await asyncio.to_thread(ai_pictures.save_upload, data, request.query.get("name", ""))
|
||||
except ai_pictures.PicturesError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
except OSError as error:
|
||||
return web.json_response({"error": f"Could not keep the picture: {error}"}, status=500)
|
||||
subfolder, _, filename = image.rpartition("/")
|
||||
return web.json_response({"image": image, "filename": filename, "subfolder": subfolder})
|
||||
|
||||
|
||||
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"):
|
||||
if request.method == "GET":
|
||||
return web.json_response({"favorites": ai_toolbox.favorites()})
|
||||
body = await _body(request)
|
||||
try:
|
||||
return web.json_response({"favorites": ai_toolbox.set_favorites(body if body is not None else None)})
|
||||
except ai_toolbox.ToolboxError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
try:
|
||||
return web.json_response(await asyncio.to_thread(ai_toolbox.catalog, request.query.get("kind", "")))
|
||||
except ai_toolbox.ToolboxError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
except Exception as error: # noqa: BLE001 - ComfyUI's node list or a store unreadable: say so to the page
|
||||
return web.json_response({"error": str(error)}, status=502)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
activity_log.add_listener(ai_task_changes.note_activity) # the user's changes while an AI works
|
||||
app.router.add_get("/anomalous/ai_tasks", api_list)
|
||||
app.router.add_post("/anomalous/ai_tasks", api_start)
|
||||
app.router.add_get("/anomalous/ai_tasks/{task_id}", api_get)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}", api_change)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/answer", api_answer)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/message", api_message)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/typing", api_typing)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/gate", api_gate)
|
||||
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_post("/anomalous/ai_tasks/{task_id}/pictures", api_pictures)
|
||||
app.router.add_post("/anomalous/ai_pictures/upload", api_upload)
|
||||
app.router.add_get("/anomalous/ai_toolbox", api_toolbox)
|
||||
app.router.add_get("/anomalous/ai_toolbox/favorites", api_toolbox)
|
||||
app.router.add_post("/anomalous/ai_toolbox/favorites", api_toolbox)
|
||||
app.router.add_get("/anomalous/ai_gates", api_gates)
|
||||
app.router.add_post("/anomalous/ai_gates", api_gates)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""What the user hands the AI to work with ("给 AI 的素材", web/modules/ui_ai_toolbox.js): node
|
||||
types, whole custom node packs (by folder: every node type in it), combos, workflows, saved
|
||||
prompts and models picked for a task, and how strictly: prefer
|
||||
(the AI is told to use them first) or only (add_node and new_workflow refuse other node types,
|
||||
except ComfyUI's own built-in ones and the helpers that live only in the page). Favourites
|
||||
(user/anomalous/ai_toolbox.json) are what a task has until the user picks for it. A pick is a
|
||||
"user_toolbox" step the AI receives like a message (kind toolbox, ai_panels.py); begin_task
|
||||
names the task's picks and get_toolbox gives them in full. The catalogue the picker shows uses
|
||||
the same names the AI's tools use: node types from ComfyUI's /object_info (mcp_canvas_edit.py),
|
||||
the rest from ai_toolbox_catalog.py."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import ai_tasks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
KINDS = ("nodes", "packs", "combos", "workflows", "prompts", "models")
|
||||
MODES = ("prefer", "only")
|
||||
MAX_ITEMS = 60
|
||||
MAX_NAME = 300
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class ToolboxError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_toolbox.json")
|
||||
|
||||
|
||||
def clean(picks):
|
||||
"""{kind: [names]} for KINDS: strings only, each kind once per name, at most MAX_ITEMS."""
|
||||
if not isinstance(picks, dict):
|
||||
raise ToolboxError("Expected {nodes, packs, combos, workflows, prompts, models}")
|
||||
out = {}
|
||||
for kind in KINDS:
|
||||
items = picks.get(kind) or []
|
||||
if not isinstance(items, list) or len(items) > MAX_ITEMS:
|
||||
raise ToolboxError(f"{kind}: a list of up to {MAX_ITEMS} names")
|
||||
names = []
|
||||
for item in items:
|
||||
if not isinstance(item, str) or not item.strip() or len(item) > MAX_NAME:
|
||||
raise ToolboxError(f"{kind}: names are text of up to {MAX_NAME} characters")
|
||||
if item.strip() not in names:
|
||||
names.append(item.strip())
|
||||
out[kind] = names
|
||||
return out
|
||||
|
||||
|
||||
def count(box):
|
||||
return sum(len((box or {}).get(kind) or []) for kind in KINDS)
|
||||
|
||||
|
||||
def favorites():
|
||||
try:
|
||||
with open(_path(), encoding="utf-8") as source:
|
||||
return clean(json.load(source))
|
||||
except (OSError, ValueError):
|
||||
return clean({})
|
||||
|
||||
|
||||
def set_favorites(picks):
|
||||
picks = clean(picks)
|
||||
with _lock:
|
||||
atomic_write_json(_path(), picks)
|
||||
return picks
|
||||
|
||||
|
||||
def effective(task):
|
||||
"""The task's picks ({mode, kinds…, "from": task|favorites}), or None when it has none."""
|
||||
box = (task or {}).get("toolbox")
|
||||
if box:
|
||||
return {**box, "from": "task"}
|
||||
liked = favorites()
|
||||
return {"mode": "prefer", **liked, "from": "favorites"} if count(liked) else None
|
||||
|
||||
|
||||
def words(box):
|
||||
"""The picks in a sentence or two for the AI."""
|
||||
parts = []
|
||||
labels = {"nodes": "node types", "packs": "custom node packs (every node type in these folders; get_toolbox lists them)",
|
||||
"combos": "combos (place_combo)", "workflows": "workflows (open_workflow opens one in a new tab)",
|
||||
"prompts": "prompts (combo:<name> / workflow:<name> are the prompts inside that combo or workflow)",
|
||||
"models": "models (model_id for set_model / add_lora / get_model)"}
|
||||
for kind in KINDS:
|
||||
if box.get(kind):
|
||||
parts.append(f"{labels[kind]}: {', '.join(box[kind])}")
|
||||
how = ("Use ONLY these node types (and those of the picked packs) when you add nodes; ComfyUI's own built-in nodes stay allowed. "
|
||||
if box.get("mode") == "only" else "Use these first. ")
|
||||
return f"The user picked these for you to work with. {how}{'; '.join(parts)}. get_toolbox gives them in full."
|
||||
|
||||
|
||||
def pick(task_id, picks, mode="prefer", now=None):
|
||||
"""The user's picks for task `task_id`: kept on the task and given to the AI as a step. Returns the step."""
|
||||
if mode not in MODES:
|
||||
raise ToolboxError("mode is prefer or only")
|
||||
picks = clean(picks)
|
||||
now = time.time() if now is None else now
|
||||
box = {"mode": mode, **picks}
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if not task:
|
||||
raise ToolboxError("No such task")
|
||||
task["toolbox"] = box
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_toolbox", "kind": "user", "ok": True,
|
||||
"state": "done", "summary": "user_toolbox", "toolbox": box,
|
||||
"text": words(box) if count(box) else "The user took back the things they picked for you; use what fits.",
|
||||
"delivered": False, "results": []}
|
||||
task["steps"].append(step)
|
||||
del task["steps"][:-ai_tasks.MAX_STEPS]
|
||||
task["updated"] = max(task.get("updated", 0), now)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return step
|
||||
|
||||
|
||||
def builtin(info):
|
||||
"""Whether a node type (its /object_info entry) is ComfyUI's own and free: not a custom node, not a paid API node."""
|
||||
module = str((info or {}).get("python_module") or "")
|
||||
return module == "nodes" or module.startswith("comfy_extras")
|
||||
|
||||
|
||||
def chosen(box, name, info):
|
||||
"""Whether node type `name` (its /object_info entry) is among picks `box`: itself, or its pack."""
|
||||
return bool(box) and (name in (box.get("nodes") or []) or source_of(info) in (box.get("packs") or []))
|
||||
|
||||
|
||||
def refused(task_id, types, info):
|
||||
"""The node types among `types` the task's "only" picks do not allow (page-only helpers, unknown to /object_info, pass)."""
|
||||
box = effective(ai_tasks.get_task(task_id)) if task_id else None
|
||||
if not box or box.get("mode") != "only":
|
||||
return []
|
||||
return sorted({name for name in types if name in info and not chosen(box, name, info[name]) and not builtin(info[name])})
|
||||
|
||||
|
||||
def source_of(info):
|
||||
"""Where a node type comes from: its custom node folder, "builtin", or "api" (paid API nodes)."""
|
||||
module = str((info or {}).get("python_module") or "")
|
||||
if module.startswith("custom_nodes."):
|
||||
return module.split(".")[1]
|
||||
return "api" if module.startswith("comfy_api_nodes") else "builtin"
|
||||
|
||||
|
||||
def catalog(kind):
|
||||
"""What the picker lists besides node types (ai_toolbox_catalog.py); the page has those itself."""
|
||||
from .ai_toolbox_catalog import everything
|
||||
|
||||
if kind != "all":
|
||||
raise ToolboxError("kind is all; node types come from the page")
|
||||
return everything()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Everything the materials picker (web/modules/ui_ai_toolbox.js) lists besides node types, in
|
||||
one answer (GET /anomalous/ai_toolbox?kind=all): combos with their cover and prompt, saved
|
||||
workflows with their cover, tags and prompts, prompts (the saved ones, and the ones inside
|
||||
combos and workflows, ids "combo:<name>" and "workflow:<name>"), and models with their cover,
|
||||
by the names and ids the AI's tools use. Read in one go and kept a few seconds, so the picker
|
||||
opens at once; node types come from the page's own node definitions. prompt_entries() also
|
||||
gives get_toolbox the text of picked prompts."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import mcp_tools
|
||||
from .model_catalog import _collect_all_scan_models
|
||||
from .notebooks import _list_notebooks
|
||||
from .quick_index import compact_models
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
|
||||
CACHE_SECONDS = 15
|
||||
MAX_TEXT = 1500
|
||||
MAX_THUMBNAIL = 60_000 # an inline thumbnail larger than this is left out; the cover asset or the default stands in
|
||||
_cache = {"at": 0.0, "data": None}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _join(texts, limit=MAX_TEXT):
|
||||
text = "\n".join(str(item).strip() for item in texts or [] if str(item or "").strip())
|
||||
return text if len(text) <= limit else text[:limit] + "…"
|
||||
|
||||
|
||||
def _combos():
|
||||
out = []
|
||||
for note in _list_notebooks():
|
||||
data = note.get("data") or {}
|
||||
combo, loras = mcp_tools.combo_view(note)
|
||||
out.append({"name": combo["name"], "model": combo["main_model"], "loras": loras[:12], "base": combo.get("base_model") or "",
|
||||
"prompt": combo["prompt"], "structure": data.get("kind") == "nodes",
|
||||
"cover": (data.get("mainModel") or {}).get("preview_url") or "" if isinstance(data.get("mainModel"), dict) else "",
|
||||
"image": data.get("source_image") if isinstance(data.get("source_image"), dict) else None})
|
||||
return out
|
||||
|
||||
|
||||
def _workflows():
|
||||
out = []
|
||||
for recipe in _list_recipes(get_recipes_dir()):
|
||||
data = recipe.get("data") or {}
|
||||
params = data.get("params") if isinstance(data.get("params"), dict) else {}
|
||||
thumbnail = data.get("thumbnail") if isinstance(data.get("thumbnail"), str) and len(data["thumbnail"]) <= MAX_THUMBNAIL else None
|
||||
out.append({"name": data.get("name") or recipe["filename"], "filename": recipe["filename"], "tags": (data.get("tags") or [])[:8],
|
||||
"notes": str(data.get("notes") or "")[:300], "partial": data.get("workflow_scope") == "partial",
|
||||
"nodes": len(params.get("nodes") or []), "base": params.get("baseModel") or "",
|
||||
"prompt": _join(params.get("promptPositive")), "negative": _join(params.get("promptNegative"), 600),
|
||||
"cover_asset": (data.get("presentation") or {}).get("cover_asset_id"), "thumbnail": thumbnail,
|
||||
"image": data.get("source_image") if isinstance(data.get("source_image"), dict) else None,
|
||||
"saved": data.get("timestamp") or 0})
|
||||
return out
|
||||
|
||||
|
||||
def _models():
|
||||
out = []
|
||||
for model in compact_models(_collect_all_scan_models(1, 0).get("models") or []):
|
||||
place = f"{model['subfolder']}/{model['filename']}" if model["subfolder"] else model["filename"]
|
||||
out.append({"id": f"{model['type']}:{model['path_idx']}:{place}", "name": model["name"] or model["civitai_name"] or model["filename"],
|
||||
"file": model["filename"], "type": model["type"], "base": model["base"], "words": model["words"],
|
||||
"cover": model["preview_url"], "folder": model["subfolder"]})
|
||||
return out
|
||||
|
||||
|
||||
def prompt_entries(combos, workflows):
|
||||
"""The prompts the picker lists: saved ones (id = name), then those of combos and workflows."""
|
||||
entries = [{"id": prompt["name"], "name": prompt["name"], "source": "saved", "tags": prompt["tags"],
|
||||
"positive": prompt["positive"], "negative": prompt["negative"]} for prompt in mcp_tools.saved_prompts()]
|
||||
entries += [{"id": f"combo:{combo['name']}", "name": combo["name"], "source": "combo", "tags": [],
|
||||
"positive": combo["prompt"], "negative": ""} for combo in combos if combo["prompt"]]
|
||||
entries += [{"id": f"workflow:{flow['name']}", "name": flow["name"], "source": "workflow", "tags": flow["tags"],
|
||||
"positive": flow["prompt"], "negative": flow["negative"]} for flow in workflows if flow["prompt"] or flow["negative"]]
|
||||
return entries
|
||||
|
||||
|
||||
def everything(fresh=False):
|
||||
"""{combos, workflows, prompts, models}, read at most every CACHE_SECONDS."""
|
||||
with _lock:
|
||||
if fresh or _cache["data"] is None or time.monotonic() - _cache["at"] > CACHE_SECONDS:
|
||||
combos, workflows = _combos(), _workflows()
|
||||
_cache.update(at=time.monotonic(), data={"combos": combos, "workflows": workflows,
|
||||
"prompts": prompt_entries(combos, workflows), "models": _models()})
|
||||
return _cache["data"]
|
||||
+591
@@ -0,0 +1,591 @@
|
||||
"""Backups of the user's own data (Settings -> Backup): one .zip to keep anywhere (a cloud
|
||||
drive, a USB stick), and putting it back on this or another computer.
|
||||
|
||||
A backup holds the plugin's stores (Workflow Recipes, combos, materials and saved prompts,
|
||||
parameter sets), optionally ComfyUI's own saved workflows, the plugin settings without the
|
||||
Civitai key, what the user set on models: <model>.anomalous.json (name, notes, link) and
|
||||
the user's cover (any cover that is not a copy of Civitai's <model>.civitai_bak.*), what a
|
||||
scan found (<model>.info; Civitai's cover only when asked, it can be hundreds of MB), and the
|
||||
model list (each model's folder, path, size, SHA-256 and link) so the models a new computer
|
||||
lacks can be downloaded again. Models are found again by SHA-256, else by models folder, path
|
||||
and size, so they match after a move.
|
||||
|
||||
Putting back never erases: a file missing here is added; a file that differs stays unless
|
||||
"replace" is chosen, and then the one here goes to the Recycle Bin first. A model's user file
|
||||
is merged field by field (empty fields are filled; "replace" takes the backup's values), and
|
||||
the backup's cover takes the place of a Civitai cover (whose copy stays as civitai_bak).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from .activity_log import add_entry
|
||||
from .folder_types import config_path
|
||||
from .material_store import get_materials_dir
|
||||
from .metadata import USER_FIELDS, clear_metadata_cache, get_metadata
|
||||
from .model_constants import CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, RESOLVABLE_MODEL_TYPES, is_model_file
|
||||
from .model_download import settings_path as download_settings_path
|
||||
from .notebooks import get_notebooks_dir
|
||||
from .parameters import _invalidate_parameter_notebooks_cache, get_parameters_dir
|
||||
from .path_utils import atomic_write_json, require_filename, resolve_within
|
||||
from .recipe_store import get_recipes_dir
|
||||
from .trash import TrashUnavailable, move_to_trash, trash_failure
|
||||
from . import version_manager
|
||||
|
||||
try:
|
||||
from ..model_identity import USER_INFO_SUFFIX, read_json, scan_info_path
|
||||
from .. import model_sidecars as sidecars
|
||||
except ImportError:
|
||||
from model_identity import USER_INFO_SUFFIX, read_json, scan_info_path
|
||||
import model_sidecars as sidecars
|
||||
|
||||
FORMAT = 1
|
||||
APP = "Anomalous Model Browser"
|
||||
ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS # as scraper.py: <model>.preview.png or <model>.png
|
||||
SECRET_KEYS = {"CIVITAI_API_KEY", "DEEPL_API_KEY"} # never leave this computer
|
||||
SCAN_INFO_SUFFIXES = (".info", ".civitai.info")
|
||||
SKIP_FILES = {".legacy_imported.json"} # this computer's own migration marker
|
||||
MAX_BACKUP_BYTES = 4 * 1024 ** 3 # unpacked size of a backup that is still read
|
||||
MAX_MEMBERS = 200_000
|
||||
KEEP_EXPORTS = 3 # finished exports kept in the temp folder for downloading
|
||||
CHUNK = 1024 * 1024
|
||||
|
||||
PART_ORDER = ("recipes", "combos", "materials", "parameters", "comfy_workflows")
|
||||
|
||||
|
||||
def _comfy_workflows_dir():
|
||||
return os.path.join(folder_paths.get_user_directory(), "default", "workflows")
|
||||
|
||||
|
||||
def part_dirs():
|
||||
"""Each kept store and where it lives on this computer."""
|
||||
return {
|
||||
"recipes": get_recipes_dir(),
|
||||
"combos": get_notebooks_dir(),
|
||||
"materials": get_materials_dir(),
|
||||
"parameters": get_parameters_dir(),
|
||||
"comfy_workflows": _comfy_workflows_dir(),
|
||||
}
|
||||
|
||||
|
||||
def work_dir():
|
||||
path = os.path.join(folder_paths.get_temp_directory(), "anomalous_backups")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for block in iter(lambda: handle.read(CHUNK), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _store_files(directory):
|
||||
"""(path, '/'-relative path) of every file kept in a store."""
|
||||
if not os.path.isdir(directory):
|
||||
return
|
||||
for root, dirs, files in os.walk(directory):
|
||||
dirs[:] = sorted(d for d in dirs if d != "__pycache__")
|
||||
for name in sorted(files):
|
||||
if name in SKIP_FILES or name.endswith(".tmp"):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
yield path, os.path.relpath(path, directory).replace(os.sep, "/")
|
||||
|
||||
|
||||
def _model_roots():
|
||||
"""(models folder type, folder) once per folder: clip and text_encoders often share one."""
|
||||
seen = set()
|
||||
for folder_type in RESOLVABLE_MODEL_TYPES:
|
||||
try:
|
||||
roots = folder_paths.get_folder_paths(folder_type) or []
|
||||
except Exception: # noqa: BLE001 - a type this ComfyUI does not know
|
||||
continue
|
||||
for root in roots:
|
||||
key = os.path.normcase(os.path.abspath(root))
|
||||
if key in seen or not os.path.isdir(root):
|
||||
continue
|
||||
seen.add(key)
|
||||
yield folder_type, root
|
||||
|
||||
|
||||
def _models():
|
||||
"""(type, root, path, '/'-relative path) of every model file."""
|
||||
for folder_type, root in _model_roots():
|
||||
for walk_root, dirs, files in os.walk(root):
|
||||
dirs.sort()
|
||||
for name in sorted(files):
|
||||
if is_model_file(name):
|
||||
path = os.path.join(walk_root, name)
|
||||
yield folder_type, root, path, os.path.relpath(path, root).replace(os.sep, "/")
|
||||
|
||||
|
||||
def _model_facts(path):
|
||||
"""(SHA-256, download page) from the model's sidecars; nothing is computed."""
|
||||
try:
|
||||
meta = get_metadata(path) or {}
|
||||
except Exception: # noqa: BLE001 - an unreadable sidecar only means "match by path"
|
||||
return "", ""
|
||||
return str(meta.get("hash") or "").lower(), str(meta.get("source_url") or meta.get("civitai_url") or "")
|
||||
|
||||
|
||||
def _model_hash(path):
|
||||
return _model_facts(path)[0]
|
||||
|
||||
|
||||
def _settings_for_backup():
|
||||
data = read_json(config_path()) or {}
|
||||
return {key: value for key, value in data.items() if key not in SECRET_KEYS}
|
||||
|
||||
|
||||
def _version():
|
||||
try:
|
||||
return version_manager.current_state().get("label") or ""
|
||||
except Exception: # noqa: BLE001 - a copy that is no git checkout
|
||||
return ""
|
||||
|
||||
|
||||
# ---------- export ----------
|
||||
|
||||
def build_backup(include_models=True, include_comfy=True, include_library=True, include_scan=True, include_civitai_covers=False):
|
||||
"""Writes the backup .zip into the temp folder: (path, manifest)."""
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
path = os.path.join(work_dir(), f"AMB-backup-{stamp}.zip")
|
||||
manifest = {"format": FORMAT, "app": APP, "version": _version(), "created": time.time(), "parts": {}, "settings": False, "models": []}
|
||||
parts = [part for part in PART_ORDER if include_comfy or part != "comfy_workflows"]
|
||||
dirs = part_dirs()
|
||||
temporary = path + ".tmp"
|
||||
try:
|
||||
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
for part in parts:
|
||||
count = 0
|
||||
for file_path, rel in _store_files(dirs[part]):
|
||||
archive.write(file_path, f"stores/{part}/{rel}")
|
||||
count += 1
|
||||
manifest["parts"][part] = count
|
||||
archive.writestr("settings/config.json", json.dumps(_settings_for_backup(), ensure_ascii=False))
|
||||
if os.path.isfile(download_settings_path()):
|
||||
archive.write(download_settings_path(), "settings/download_settings.json")
|
||||
manifest["settings"] = True
|
||||
if include_library:
|
||||
manifest["library"] = []
|
||||
if include_models or include_library:
|
||||
for folder_type, _root, model_path, rel in _models():
|
||||
if include_library:
|
||||
digest, page = _model_facts(model_path)
|
||||
manifest["library"].append({"type": folder_type, "rel": rel, "size": os.path.getsize(model_path),
|
||||
"sha256": digest, "url": page})
|
||||
if not include_models:
|
||||
continue
|
||||
base = os.path.splitext(model_path)[0]
|
||||
user_file = sidecars.find(base, USER_INFO_SUFFIX)
|
||||
user_data = read_json(user_file) if user_file else None
|
||||
civitai, covers = sidecars.cover_owners(base)
|
||||
scan_info = scan_info_path(base) if include_scan else None
|
||||
backups = sidecars.backups(base)
|
||||
civitai_cover = civitai[0] if include_civitai_covers and civitai else None
|
||||
if not user_data and not covers and not scan_info and not civitai_cover:
|
||||
continue
|
||||
entry = {"id": len(manifest["models"]), "type": folder_type, "rel": rel,
|
||||
"size": os.path.getsize(model_path), "sha256": _model_hash(model_path), "user": bool(user_data), "cover": ""}
|
||||
folder = f"models/{entry['id']}/"
|
||||
if user_data:
|
||||
archive.writestr(folder + "user.json", json.dumps(user_data, ensure_ascii=False))
|
||||
if covers:
|
||||
entry["cover"] = covers[0][len(base):]
|
||||
archive.write(covers[0], f"{folder}cover{entry['cover']}")
|
||||
if scan_info:
|
||||
entry["info"] = sidecars.suffix_of(scan_info, base)
|
||||
archive.write(scan_info, f"{folder}scan{entry['info']}")
|
||||
if civitai_cover:
|
||||
# One copy: it is both the cover and Civitai's picture (kept aside or not).
|
||||
picture = backups[0] if backups else civitai_cover
|
||||
entry["civitai_cover"] = civitai_cover[len(base):]
|
||||
entry["civitai_bak"] = sidecars.suffix_of(backups[0], base) if backups else ".civitai_bak" + os.path.splitext(civitai_cover)[1]
|
||||
archive.write(picture, f"{folder}civitai{entry['civitai_bak']}")
|
||||
manifest["models"].append(entry)
|
||||
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=1))
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.remove(temporary)
|
||||
_prune_exports()
|
||||
return path, manifest
|
||||
|
||||
|
||||
def _prune_exports():
|
||||
"""Keeps the newest finished exports; older ones are this plugin's own temp files."""
|
||||
exports = sorted((name for name in os.listdir(work_dir()) if name.startswith("AMB-backup-") and name.endswith(".zip")), reverse=True)
|
||||
for name in exports[KEEP_EXPORTS:]:
|
||||
try:
|
||||
os.remove(os.path.join(work_dir(), name))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- reading a backup ----------
|
||||
|
||||
class BackupError(ValueError):
|
||||
"""A file that is no backup of this plugin, or one that cannot be read safely."""
|
||||
|
||||
|
||||
def _safe_rel(rel):
|
||||
parts = rel.split("/")
|
||||
if not rel or rel.startswith("/") or "\\" in rel or ":" in rel or any(part in ("", ".", "..") for part in parts):
|
||||
raise BackupError(f"unsafe path in the backup: {rel}")
|
||||
return parts
|
||||
|
||||
|
||||
def open_backup(path):
|
||||
"""The zip and its manifest, after checking it is a backup and safe to unpack."""
|
||||
try:
|
||||
archive = zipfile.ZipFile(path)
|
||||
except (zipfile.BadZipFile, OSError) as error:
|
||||
raise BackupError("not a zip file") from error
|
||||
try:
|
||||
infos = archive.infolist()
|
||||
if len(infos) > MAX_MEMBERS or sum(info.file_size for info in infos) > MAX_BACKUP_BYTES:
|
||||
raise BackupError("the backup is too large")
|
||||
try:
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
except (KeyError, ValueError) as error:
|
||||
raise BackupError("no manifest: not a backup of this plugin") from error
|
||||
if not isinstance(manifest, dict) or manifest.get("app") != APP or manifest.get("format") != FORMAT:
|
||||
raise BackupError("not a backup of this plugin, or one from a newer version")
|
||||
for info in infos:
|
||||
if not info.is_dir():
|
||||
_safe_rel(info.filename)
|
||||
return archive, manifest
|
||||
except Exception:
|
||||
archive.close()
|
||||
raise
|
||||
|
||||
|
||||
def _store_members(archive):
|
||||
"""(part, '/'-relative path, ZipInfo) of each kept store file in the backup."""
|
||||
for info in archive.infolist():
|
||||
name = info.filename
|
||||
if info.is_dir() or not name.startswith("stores/"):
|
||||
continue
|
||||
bits = name.split("/", 2)
|
||||
if len(bits) == 3 and bits[1] in PART_ORDER:
|
||||
_, part, rel = bits
|
||||
_safe_rel(rel)
|
||||
yield part, rel, info
|
||||
|
||||
|
||||
def _member_sha256(archive, info):
|
||||
digest = hashlib.sha256()
|
||||
with archive.open(info) as handle:
|
||||
for block in iter(lambda: handle.read(CHUNK), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _file_state(archive, info, target):
|
||||
if not os.path.isfile(target):
|
||||
return "new"
|
||||
if os.path.getsize(target) == info.file_size and _sha256_file(target) == _member_sha256(archive, info):
|
||||
return "same"
|
||||
return "differs"
|
||||
|
||||
|
||||
def _local_models():
|
||||
"""This computer's models: by SHA-256 and by (type, path) -> (path, size)."""
|
||||
by_hash, by_rel = {}, {}
|
||||
for folder_type, _root, path, rel in _models():
|
||||
size = os.path.getsize(path)
|
||||
digest = _model_hash(path)
|
||||
if digest:
|
||||
by_hash.setdefault(digest, path)
|
||||
by_rel[(folder_type, rel.lower())] = (path, size)
|
||||
return by_hash, by_rel
|
||||
|
||||
|
||||
def _match(entry, by_hash, by_rel):
|
||||
digest = str(entry.get("sha256") or "").lower()
|
||||
if digest and digest in by_hash:
|
||||
return by_hash[digest]
|
||||
found = by_rel.get((entry.get("type"), str(entry.get("rel") or "").lower()))
|
||||
return found[0] if found and found[1] == entry.get("size") else None
|
||||
|
||||
|
||||
def inspect_backup(path):
|
||||
"""What putting the backup back would do, before anything is written."""
|
||||
archive, manifest = open_backup(path)
|
||||
with archive:
|
||||
dirs = part_dirs()
|
||||
parts = {}
|
||||
for part, rel, info in _store_members(archive):
|
||||
counts = parts.setdefault(part, {"new": 0, "same": 0, "differs": 0})
|
||||
counts[_file_state(archive, info, resolve_within(dirs[part], *rel.split("/")))] += 1
|
||||
models = {"total": len(manifest.get("models") or []), "matched": 0, "missing": []}
|
||||
library = manifest.get("library") if isinstance(manifest.get("library"), list) else None
|
||||
summary = {"created": manifest.get("created"), "version": manifest.get("version") or "",
|
||||
"parts": {part: parts[part] for part in PART_ORDER if part in parts},
|
||||
"settings": bool(manifest.get("settings")), "models": models}
|
||||
if models["total"] or library:
|
||||
by_hash, by_rel = _local_models()
|
||||
for entry in manifest.get("models") or []:
|
||||
if _match(entry, by_hash, by_rel):
|
||||
models["matched"] += 1
|
||||
else:
|
||||
models["missing"].append(entry.get("rel") or "")
|
||||
if library is not None:
|
||||
# The models the old computer had and this one lacks: what can be downloaded again
|
||||
# (two copies of one file there are one download here).
|
||||
missing, seen = [], set()
|
||||
for entry in library:
|
||||
if not isinstance(entry, dict) or _match(entry, by_hash, by_rel):
|
||||
continue
|
||||
digest = str(entry.get("sha256") or "").lower()
|
||||
if digest and digest in seen:
|
||||
continue
|
||||
seen.add(digest)
|
||||
missing.append({key: entry.get(key) for key in ("type", "rel", "size", "sha256", "url")})
|
||||
summary["library"] = {"total": len(library), "missing": missing}
|
||||
return summary
|
||||
|
||||
|
||||
# ---------- putting back ----------
|
||||
|
||||
def _write_member(archive, info, target):
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".restore-", suffix=".tmp", dir=os.path.dirname(target))
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as output, archive.open(info) as source:
|
||||
shutil.copyfileobj(source, output, CHUNK)
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.remove(temporary)
|
||||
|
||||
|
||||
def _restore_stores(archive, wanted, replace, result):
|
||||
dirs = part_dirs()
|
||||
for part, rel, info in _store_members(archive):
|
||||
if part not in wanted:
|
||||
continue
|
||||
target = resolve_within(dirs[part], *rel.split("/"))
|
||||
state = _file_state(archive, info, target)
|
||||
if state == "same" or (state == "differs" and not replace):
|
||||
result["skipped"] += 1
|
||||
continue
|
||||
if state == "differs":
|
||||
try:
|
||||
move_to_trash(target)
|
||||
except (TrashUnavailable, OSError) as error:
|
||||
result["failed"].append(f"{part}/{rel}: {trash_failure(error)}")
|
||||
continue
|
||||
result["replaced"] += 1
|
||||
else:
|
||||
result["added"] += 1
|
||||
_write_member(archive, info, target)
|
||||
|
||||
|
||||
def _restore_scan(archive, entry, base, folder, replace, result):
|
||||
"""What a scan found: the scan file where there is none (or replaced on request), and
|
||||
Civitai's cover where the model has no cover at all."""
|
||||
suffix = str(entry.get("info") or "")
|
||||
if suffix:
|
||||
if suffix not in SCAN_INFO_SUFFIXES:
|
||||
raise BackupError(f"unknown scan file name: {suffix}")
|
||||
info = archive.getinfo(folder + "scan" + suffix)
|
||||
local = scan_info_path(base)
|
||||
if not local or (replace and _sha256_file(local) != _member_sha256(archive, info)):
|
||||
if local:
|
||||
move_to_trash(local)
|
||||
_write_member(archive, info, sidecars.target(base, suffix))
|
||||
result["model_infos"] += 1
|
||||
cover, saved = str(entry.get("civitai_cover") or ""), str(entry.get("civitai_bak") or "")
|
||||
if cover and saved:
|
||||
if cover not in ACTIVE_COVER_SUFFIXES or saved not in CIVITAI_BACKUP_SUFFIXES:
|
||||
raise BackupError(f"unknown cover name: {cover}")
|
||||
info = archive.getinfo(folder + "civitai" + saved)
|
||||
civitai, mine = sidecars.cover_owners(base)
|
||||
if not civitai and not mine and not entry.get("cover"):
|
||||
# Civitai's picture becomes the cover, noted as Civitai's (no second copy aside).
|
||||
_write_member(archive, info, base + cover)
|
||||
if not sidecars.note_cover_hash(base, base + cover) and not sidecars.backups(base):
|
||||
_write_member(archive, info, sidecars.target(base, saved))
|
||||
result["model_civitai_covers"] += 1
|
||||
elif mine and not sidecars.backups(base):
|
||||
_write_member(archive, info, sidecars.target(base, saved)) # aside, while the user's cover shows
|
||||
|
||||
|
||||
def _restore_model(archive, entry, model_path, replace, result):
|
||||
base = os.path.splitext(model_path)[0]
|
||||
folder = f"models/{int(entry['id'])}/"
|
||||
_restore_scan(archive, entry, base, folder, replace, result)
|
||||
if entry.get("user"):
|
||||
backup = json.loads(archive.read(folder + "user.json"))
|
||||
user_file = sidecars.target(base, USER_INFO_SUFFIX)
|
||||
local = read_json(user_file) if os.path.isfile(user_file) else None
|
||||
merged = dict(local or {"format": 1})
|
||||
for key in USER_FIELDS:
|
||||
value = str(backup.get(key) or "").strip()
|
||||
if value and (replace or not str(merged.get(key) or "").strip()):
|
||||
merged[key] = backup[key]
|
||||
if merged != (local or {"format": 1}):
|
||||
if local and replace:
|
||||
move_to_trash(user_file) # the old values stay recoverable
|
||||
atomic_write_json(user_file, merged)
|
||||
result["model_notes"] += 1
|
||||
suffix = str(entry.get("cover") or "")
|
||||
if suffix:
|
||||
if suffix not in ACTIVE_COVER_SUFFIXES:
|
||||
raise BackupError(f"unknown cover name: {suffix}")
|
||||
info = archive.getinfo(folder + "cover" + suffix)
|
||||
civitai, mine = sidecars.cover_owners(base)
|
||||
member_hash = _member_sha256(archive, info)
|
||||
if any(_sha256_file(cover) == member_hash for cover in mine):
|
||||
return
|
||||
if mine and not replace:
|
||||
result["covers_kept"] += 1
|
||||
return
|
||||
if civitai and not sidecars.backups(base):
|
||||
sidecars.keep_aside(base, civitai.pop(0)) # Civitai's picture stays, to bring back
|
||||
for cover in mine + civitai: # Civitai's own copy stays as <model>.civitai_bak.*
|
||||
move_to_trash(cover)
|
||||
_write_member(archive, info, base + suffix)
|
||||
result["model_covers"] += 1
|
||||
|
||||
|
||||
def _restore_settings(archive, result):
|
||||
names = set(archive.namelist())
|
||||
if "settings/config.json" in names:
|
||||
backup = json.loads(archive.read("settings/config.json"))
|
||||
local = read_json(config_path()) or {}
|
||||
merged = {**{key: value for key, value in backup.items() if key not in SECRET_KEYS},
|
||||
**{key: local[key] for key in SECRET_KEYS if key in local}}
|
||||
atomic_write_json(config_path(), merged)
|
||||
result["settings"] = True
|
||||
if "settings/download_settings.json" in names:
|
||||
os.makedirs(os.path.dirname(download_settings_path()), exist_ok=True)
|
||||
atomic_write_json(download_settings_path(), json.loads(archive.read("settings/download_settings.json")))
|
||||
|
||||
|
||||
def apply_backup(path, parts=PART_ORDER, models=True, settings=False, replace=False):
|
||||
"""Puts the backup back as chosen; returns the counts for the user and the activity log."""
|
||||
archive, manifest = open_backup(path)
|
||||
result = {"added": 0, "replaced": 0, "skipped": 0, "model_notes": 0, "model_covers": 0, "model_infos": 0,
|
||||
"model_civitai_covers": 0, "covers_kept": 0, "models_missing": 0, "settings": False, "failed": []}
|
||||
with archive:
|
||||
_restore_stores(archive, set(parts) & set(PART_ORDER), replace, result)
|
||||
if models and manifest.get("models"):
|
||||
by_hash, by_rel = _local_models()
|
||||
for entry in manifest["models"]:
|
||||
model_path = _match(entry, by_hash, by_rel)
|
||||
if not model_path:
|
||||
result["models_missing"] += 1
|
||||
continue
|
||||
try:
|
||||
_restore_model(archive, entry, model_path, replace, result)
|
||||
except (TrashUnavailable, OSError, KeyError, ValueError) as error:
|
||||
result["failed"].append(f"{entry.get('rel')}: {trash_failure(error) if isinstance(error, (TrashUnavailable, OSError)) else error}")
|
||||
clear_metadata_cache()
|
||||
if settings and manifest.get("settings"):
|
||||
_restore_settings(archive, result)
|
||||
_invalidate_parameter_notebooks_cache()
|
||||
return result
|
||||
|
||||
|
||||
# ---------- routes ----------
|
||||
|
||||
def _error(message, status=400):
|
||||
return web.json_response({"status": "error", "error": str(message)}, status=status)
|
||||
|
||||
|
||||
async def api_export(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception: # noqa: BLE001 - no body: the defaults
|
||||
body = {}
|
||||
path, manifest = await asyncio.to_thread(build_backup, body.get("models", True) is not False,
|
||||
body.get("comfy_workflows", True) is not False, body.get("library", True) is not False,
|
||||
body.get("scan", True) is not False, body.get("civitai_covers") is True)
|
||||
counts = {**manifest["parts"], "models": len(manifest["models"]), "library": len(manifest.get("library") or [])}
|
||||
await asyncio.to_thread(add_entry, "file", "backup_export", os.path.basename(path), {"backup": counts})
|
||||
return web.json_response({"status": "success", "name": os.path.basename(path), "size": os.path.getsize(path), "counts": counts})
|
||||
|
||||
|
||||
async def api_file(request):
|
||||
try:
|
||||
name = require_filename(request.query.get("name", ""))
|
||||
if not name.startswith("AMB-backup-") or not name.endswith(".zip"):
|
||||
raise ValueError("not a backup")
|
||||
path = resolve_within(work_dir(), name)
|
||||
except ValueError as error:
|
||||
return _error(error)
|
||||
if not os.path.isfile(path):
|
||||
return _error("This backup is gone; export it again.", 404)
|
||||
return web.FileResponse(path, headers={"Content-Disposition": f'attachment; filename="{name}"'})
|
||||
|
||||
|
||||
async def api_inspect(request):
|
||||
"""Receives the chosen .zip, keeps it in the temp folder and says what putting it back would do."""
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
except (AssertionError, ValueError): # not a file upload at all
|
||||
return _error("no file")
|
||||
if field is None or field.name != "file":
|
||||
return _error("no file")
|
||||
token = uuid.uuid4().hex
|
||||
path = os.path.join(work_dir(), f"incoming-{token}.zip")
|
||||
size = 0
|
||||
with open(path, "wb") as output:
|
||||
while chunk := await field.read_chunk(CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_BACKUP_BYTES:
|
||||
output.close()
|
||||
os.remove(path)
|
||||
return _error("the backup is too large")
|
||||
output.write(chunk)
|
||||
try:
|
||||
summary = await asyncio.to_thread(inspect_backup, path)
|
||||
except BackupError as error:
|
||||
os.remove(path)
|
||||
return _error(error)
|
||||
return web.json_response({"status": "success", "token": token, **summary})
|
||||
|
||||
|
||||
async def api_apply(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
token = str(body.get("token") or "")
|
||||
if not token.isalnum():
|
||||
raise ValueError("bad token")
|
||||
path = resolve_within(work_dir(), f"incoming-{token}.zip")
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _error(error)
|
||||
if not os.path.isfile(path):
|
||||
return _error("This backup is gone; choose the file again.", 404)
|
||||
parts = [part for part in body.get("parts") or [] if part in PART_ORDER]
|
||||
try:
|
||||
result = await asyncio.to_thread(apply_backup, path, parts, body.get("models") is not False,
|
||||
body.get("settings") is True, body.get("replace") is True)
|
||||
except BackupError as error:
|
||||
return _error(error)
|
||||
os.remove(path)
|
||||
await asyncio.to_thread(add_entry, "file", "backup_import", str(body.get("name") or ""), {"backup": {**result, "failed": result["failed"][:20]}})
|
||||
return web.json_response({"status": "success", **result})
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_post("/anomalous/backup/export", api_export)
|
||||
app.router.add_get("/anomalous/backup/file", api_file)
|
||||
app.router.add_post("/anomalous/backup/inspect", api_inspect)
|
||||
app.router.add_post("/anomalous/backup/apply", api_apply)
|
||||
+57
-28
@@ -9,6 +9,13 @@ from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
|
||||
try:
|
||||
from ..net_proxy import CONFIG_KEY as PROXY_KEY, configured as configured_proxy, normalize as normalize_proxy
|
||||
from ..model_sidecars import layout as _side_file_layout
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from net_proxy import CONFIG_KEY as PROXY_KEY, configured as configured_proxy, normalize as normalize_proxy
|
||||
from model_sidecars import layout as _side_file_layout
|
||||
|
||||
async def api_get_config(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
@@ -20,20 +27,27 @@ async def api_get_config(request):
|
||||
cfg = json.load(f)
|
||||
has_key = bool(cfg.get("CIVITAI_API_KEY", "").strip())
|
||||
folder_types = cfg.get("folder_types_config", [])
|
||||
except:
|
||||
pass
|
||||
if not has_key:
|
||||
legacy_config_path = os.path.join(os.path.dirname(script_dir), "config.json")
|
||||
if os.path.exists(legacy_config_path):
|
||||
try:
|
||||
with open(legacy_config_path, 'r', encoding='utf-8') as f:
|
||||
legacy_cfg = json.load(f)
|
||||
has_key = bool(legacy_cfg.get("CIVITAI_API_KEY", "").strip())
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"has_api_key": has_key, "folder_types_config": folder_types})
|
||||
except:
|
||||
pass
|
||||
if not has_key:
|
||||
legacy_config_path = os.path.join(os.path.dirname(script_dir), "config.json")
|
||||
if os.path.exists(legacy_config_path):
|
||||
try:
|
||||
with open(legacy_config_path, 'r', encoding='utf-8') as f:
|
||||
legacy_cfg = json.load(f)
|
||||
has_key = bool(legacy_cfg.get("CIVITAI_API_KEY", "").strip())
|
||||
except Exception:
|
||||
pass
|
||||
deepl_key = ""
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
deepl_key = json.load(f).get("DEEPL_API_KEY", "")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
pass
|
||||
return web.json_response({"has_api_key": has_key, "has_deepl_key": bool(isinstance(deepl_key, str) and deepl_key.strip()),
|
||||
"proxy": configured_proxy(), "sidecar_layout": _side_file_layout(), "folder_types_config": folder_types})
|
||||
|
||||
async def api_save_config(request):
|
||||
async def api_save_config(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
api_key = data.get("api_key")
|
||||
@@ -50,17 +64,32 @@ async def api_save_config(request):
|
||||
except:
|
||||
pass
|
||||
|
||||
if api_key is not None and not isinstance(api_key, str):
|
||||
return web.json_response({"status": "error", "message": "Invalid API key"}, status=400)
|
||||
if folder_types_config is not None and not isinstance(folder_types_config, list):
|
||||
return web.json_response({"status": "error", "message": "Invalid folder configuration"}, status=400)
|
||||
if "physical_folders_config" in data and not isinstance(data.get("physical_folders_config"), list):
|
||||
return web.json_response({"status": "error", "message": "Invalid physical folder configuration"}, status=400)
|
||||
if "folder_view_mode" in data and data.get("folder_view_mode") not in {"abstract", "physical"}:
|
||||
return web.json_response({"status": "error", "message": "Invalid folder view mode"}, status=400)
|
||||
|
||||
if api_key is not None:
|
||||
cfg["CIVITAI_API_KEY"] = api_key.strip()
|
||||
deepl_key = data.get("deepl_key")
|
||||
if api_key is not None and not isinstance(api_key, str):
|
||||
return web.json_response({"status": "error", "message": "Invalid API key"}, status=400)
|
||||
if deepl_key is not None and not isinstance(deepl_key, str):
|
||||
return web.json_response({"status": "error", "message": "Invalid DeepL key"}, status=400)
|
||||
proxy = None
|
||||
if "proxy" in data: # Settings -> Network (net_proxy.py); "" = the system's proxy
|
||||
try:
|
||||
proxy = normalize_proxy(data.get("proxy"))
|
||||
except ValueError as error:
|
||||
return web.json_response({"status": "error", "message": "Invalid proxy", "code": str(error)}, status=400)
|
||||
if folder_types_config is not None and not isinstance(folder_types_config, list):
|
||||
return web.json_response({"status": "error", "message": "Invalid folder configuration"}, status=400)
|
||||
if "physical_folders_config" in data and not isinstance(data.get("physical_folders_config"), list):
|
||||
return web.json_response({"status": "error", "message": "Invalid physical folder configuration"}, status=400)
|
||||
if "folder_view_mode" in data and data.get("folder_view_mode") not in {"abstract", "physical"}:
|
||||
return web.json_response({"status": "error", "message": "Invalid folder view mode"}, status=400)
|
||||
|
||||
if api_key is not None:
|
||||
cfg["CIVITAI_API_KEY"] = api_key.strip()
|
||||
|
||||
if deepl_key is not None: # Settings -> Translation (translation_routes.py)
|
||||
cfg["DEEPL_API_KEY"] = deepl_key.strip()
|
||||
|
||||
if proxy is not None:
|
||||
cfg[PROXY_KEY] = proxy
|
||||
|
||||
if folder_types_config is not None:
|
||||
cfg["folder_types_config"] = folder_types_config
|
||||
@@ -71,10 +100,10 @@ async def api_save_config(request):
|
||||
if "folder_view_mode" in data:
|
||||
cfg["folder_view_mode"] = data.get("folder_view_mode")
|
||||
|
||||
temp_path = config_path + ".tmp"
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4)
|
||||
os.replace(temp_path, config_path)
|
||||
temp_path = config_path + ".tmp"
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4)
|
||||
os.replace(temp_path, config_path)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Where a model the open workflow is missing can be downloaded from, for Model Check's
|
||||
"Download" (api/model_download.py fetches it).
|
||||
|
||||
Sources that name one exact file: Civitai by the file's hash (the workflow's fingerprint), a
|
||||
Civitai model version or file link, or a direct file link on Hugging Face (or its mirror) or
|
||||
GitHub, as a shared workflow carries them (AMB's model links, ComfyUI's own `properties.models`).
|
||||
Then ComfyUI-Manager's model list by file name (manager_catalog.py): with the fingerprint, only
|
||||
the entry whose file has it; without, the one entry of that name, marked `by_name` because a
|
||||
name is no proof (the page asks before it downloads one). Each answer says the file's size and
|
||||
SHA-256 when the site gives them, so the download can be checked.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
from ..civitai_client import USER_AGENT, _load_api_key, answered, api_hosts
|
||||
from ..net_proxy import urlopen
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from civitai_client import USER_AGENT, _load_api_key, answered, api_hosts
|
||||
from net_proxy import urlopen
|
||||
from . import manager_catalog
|
||||
|
||||
CIVITAI_HOSTS = ("civitai.com", "civitai.red")
|
||||
HF_HOSTS = ("huggingface.co", "hf-mirror.com")
|
||||
HF_MIRROR = "hf-mirror.com" # Hugging Face's mirror for mainland China (it sends other networks back)
|
||||
OTHER_HOSTS = ("github.com",)
|
||||
TIMEOUT = 15
|
||||
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
|
||||
|
||||
class SourceUnreachable(Exception):
|
||||
"""The site did not answer (network, timeout, server error)."""
|
||||
|
||||
|
||||
def host_of(url):
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(str(url).strip())
|
||||
except ValueError:
|
||||
return ""
|
||||
return (parsed.hostname or "").lower() if parsed.scheme == "https" else ""
|
||||
|
||||
|
||||
def _site(host, sites):
|
||||
return any(host == site or host.endswith("." + site) for site in sites)
|
||||
|
||||
|
||||
def allowed_download_url(url):
|
||||
"""Whether `url` is an https link on a site downloads may start from."""
|
||||
host = host_of(url)
|
||||
return bool(host) and _site(host, CIVITAI_HOSTS + HF_HOSTS + OTHER_HOSTS)
|
||||
|
||||
|
||||
def is_civitai(url):
|
||||
return _site(host_of(url), CIVITAI_HOSTS)
|
||||
|
||||
|
||||
def civitai_headers():
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
key = _load_api_key()
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
return headers
|
||||
|
||||
|
||||
def _get_json(url, headers):
|
||||
"""The JSON at `url`; None on 404; SourceUnreachable when it could not be asked."""
|
||||
try:
|
||||
with urlopen(urllib.request.Request(url, headers=headers), timeout=TIMEOUT) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code in (400, 404):
|
||||
return None
|
||||
raise SourceUnreachable(f"HTTP {error.code}") from error
|
||||
except (urllib.error.URLError, OSError, ValueError) as error:
|
||||
raise SourceUnreachable(str(error)) from error
|
||||
|
||||
|
||||
def _civitai_json(path):
|
||||
"""Civitai's API answer at `path` (/api/v1/…), from whichever of its addresses answers
|
||||
(civitai_client.api_hosts); None on 404."""
|
||||
unreachable = None
|
||||
for host in api_hosts():
|
||||
try:
|
||||
data = _get_json(f"https://{host}{path}", civitai_headers())
|
||||
except SourceUnreachable as error:
|
||||
if isinstance(error.__cause__, urllib.error.HTTPError):
|
||||
raise # Civitai answered with an error: the other address is the same Civitai
|
||||
unreachable = error
|
||||
continue
|
||||
answered(host)
|
||||
return data
|
||||
raise unreachable
|
||||
|
||||
|
||||
def _civitai_page(version):
|
||||
domain = "civitai.red" if (version.get("model") or {}).get("nsfw") or (version.get("nsfwLevel") or 1) > 1 else "civitai.com"
|
||||
model_id = version.get("modelId")
|
||||
return f"https://{domain}/models/{model_id}?modelVersionId={version.get('id')}" if model_id else ""
|
||||
|
||||
|
||||
def _pick_civitai_file(files, file_hash="", name=""):
|
||||
"""The version's file with this hash (SHA-256 or a prefix such as AutoV2; None if it has
|
||||
none such). Without a hash: the file of this name, else the version's primary model file —
|
||||
the link names that version, and people rename their files."""
|
||||
files = [item for item in files or [] if isinstance(item, dict) and item.get("downloadUrl")]
|
||||
wanted = str(file_hash or "").upper()
|
||||
if wanted:
|
||||
for item in files:
|
||||
hashes = {str(value).upper() for value in (item.get("hashes") or {}).values()}
|
||||
sha = str((item.get("hashes") or {}).get("SHA256") or "").upper()
|
||||
if wanted in hashes or (len(wanted) >= 10 and sha.startswith(wanted)):
|
||||
return item
|
||||
return None
|
||||
if name:
|
||||
for item in files:
|
||||
if str(item.get("name", "")).lower() == name.lower():
|
||||
return item
|
||||
models = [item for item in files if item.get("type", "Model") == "Model"]
|
||||
return next((item for item in models if item.get("primary")), models[0] if models else None)
|
||||
|
||||
|
||||
def _from_civitai_version(version, file_hash="", name=""):
|
||||
picked = _pick_civitai_file(version.get("files"), file_hash, name)
|
||||
if not picked:
|
||||
return None
|
||||
sha = str((picked.get("hashes") or {}).get("SHA256") or "")
|
||||
return {
|
||||
"found": True,
|
||||
"source": "civitai",
|
||||
"download_url": picked["downloadUrl"],
|
||||
"file_name": picked.get("name") or "",
|
||||
"size": int(float(picked.get("sizeKB") or 0) * 1024),
|
||||
"sha256": sha.lower() if SHA256.match(sha) else "",
|
||||
"format": ((picked.get("metadata") or {}).get("format") or ""),
|
||||
"base_model": version.get("baseModel") or "",
|
||||
"model_name": (version.get("model") or {}).get("name") or "",
|
||||
"version_name": version.get("name") or "",
|
||||
"page": _civitai_page(version),
|
||||
}
|
||||
|
||||
|
||||
def civitai_by_hash(file_hash):
|
||||
version = _civitai_json(f"/api/v1/model-versions/by-hash/{urllib.parse.quote(file_hash)}")
|
||||
return _from_civitai_version(version, file_hash) if version else None
|
||||
|
||||
|
||||
def _civitai_version_id(url):
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
if query.get("modelVersionId"):
|
||||
return query["modelVersionId"][0]
|
||||
match = re.search(r"/api/download/models/(\d+)", parsed.path) or re.search(r"/model-versions/(\d+)", parsed.path)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def civitai_by_link(url, name, file_hash=""):
|
||||
"""A Civitai link that names a model version: its file of this hash or name."""
|
||||
version_id = _civitai_version_id(url)
|
||||
if not version_id.isdigit():
|
||||
return None
|
||||
version = _civitai_json(f"/api/v1/model-versions/{version_id}")
|
||||
return _from_civitai_version(version, file_hash, name) if version else None
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def _hf_file_url(url, host=""):
|
||||
"""https://<hf>/<repo>/resolve/<rev>/<path> for a blob or resolve link to a file, else '';
|
||||
on `host` (Hugging Face or its mirror) when given."""
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
for marker in ("resolve", "blob"):
|
||||
if marker in parts:
|
||||
at = parts.index(marker)
|
||||
if at >= 2 and len(parts) > at + 2:
|
||||
parts[at] = "resolve"
|
||||
return urllib.parse.urlunsplit(("https", host or parsed.netloc, "/" + "/".join(parts), "", ""))
|
||||
return ""
|
||||
|
||||
|
||||
def _head(url):
|
||||
"""(headers, redirected) of a HEAD request that does not follow redirects; None on 404,
|
||||
"gated" on 401/403."""
|
||||
opener = urllib.request.build_opener(_NoRedirect)
|
||||
request = urllib.request.Request(url, method="HEAD", headers={"User-Agent": USER_AGENT})
|
||||
try:
|
||||
return opener.open(request, timeout=TIMEOUT).headers, False
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code in (401, 403):
|
||||
return "gated"
|
||||
if error.code == 404:
|
||||
return None
|
||||
if error.code not in (301, 302, 303, 307, 308):
|
||||
raise SourceUnreachable(f"HTTP {error.code}") from error
|
||||
return error.headers, True
|
||||
except (urllib.error.URLError, OSError) as error:
|
||||
raise SourceUnreachable(str(error)) from error
|
||||
|
||||
|
||||
def hugging_face_file(url, host=""):
|
||||
"""A Hugging Face file link, fetched from `host` (huggingface.co or the mirror; the link's
|
||||
own when empty): its size and SHA-256 from the site's headers."""
|
||||
file_url = _hf_file_url(url, host)
|
||||
if not file_url:
|
||||
return None
|
||||
answer = _head(file_url)
|
||||
if isinstance(answer, tuple) and answer[1] and not answer[0].get("x-linked-size"):
|
||||
# Sent on to the other Hugging Face host (the mirror does that outside China): ask there.
|
||||
location = urllib.parse.urljoin(file_url, answer[0].get("Location") or "")
|
||||
if _site(host_of(location), HF_HOSTS) and host_of(location) != host_of(file_url):
|
||||
answer = _head(location)
|
||||
if answer is None:
|
||||
return None
|
||||
if answer == "gated":
|
||||
# The repository's page, where the user logs in and accepts its terms.
|
||||
parts = urllib.parse.urlsplit(file_url).path.strip("/").split("/")
|
||||
return {"found": False, "reason": "gated", "page": f"https://huggingface.co/{'/'.join(parts[:2])}"}
|
||||
headers, redirected = answer
|
||||
etag = str(headers.get("x-linked-etag") or headers.get("etag") or "").strip('"').replace("W/", "").strip('"')
|
||||
# A redirect's own length is not the file's: without x-linked-size (small files kept
|
||||
# outside large-file storage) the size is unknown.
|
||||
size = headers.get("x-linked-size") or (0 if redirected else headers.get("content-length")) or 0
|
||||
return {
|
||||
"found": True,
|
||||
"source": "huggingface",
|
||||
"mirror": host_of(file_url) == HF_MIRROR,
|
||||
"download_url": file_url,
|
||||
"file_name": urllib.parse.unquote(file_url.rsplit("/", 1)[-1]),
|
||||
"size": int(size) if str(size).isdigit() else 0,
|
||||
"sha256": etag.lower() if SHA256.match(etag) else "",
|
||||
"format": "",
|
||||
"base_model": "",
|
||||
"model_name": "",
|
||||
"version_name": "",
|
||||
"page": url,
|
||||
}
|
||||
|
||||
|
||||
def plain_link(url):
|
||||
"""A direct file link on GitHub (a release asset, or a file in a repository, fetched raw): no
|
||||
size or hash to check against."""
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
path = parsed.path
|
||||
if "/blob/" in path: # the page about the file; its raw form is the file
|
||||
path = path.replace("/blob/", "/raw/", 1)
|
||||
if "/releases/download/" not in path and "/raw/" not in path:
|
||||
return None
|
||||
return {
|
||||
"found": True, "source": "link", "download_url": urllib.parse.urlunsplit(("https", parsed.netloc, path, "", "")),
|
||||
"file_name": urllib.parse.unquote(path.rsplit("/", 1)[-1]), "size": 0, "sha256": "",
|
||||
"format": "", "base_model": "", "model_name": "", "version_name": "", "page": url,
|
||||
}
|
||||
|
||||
|
||||
def same_file(expected, sha256):
|
||||
"""Whether a fingerprint (full SHA-256 or a prefix of 10+, AutoV2) names this SHA-256."""
|
||||
expected, sha256 = str(expected or "").lower(), str(sha256 or "").lower()
|
||||
return len(expected) >= 10 and sha256.startswith(expected)
|
||||
|
||||
|
||||
def _is_sha_prefix(value):
|
||||
return len(value) >= 10 and bool(re.fullmatch(r"[0-9a-fA-F]+", value))
|
||||
|
||||
|
||||
class _Sites:
|
||||
"""Which sites stopped answering during one lookup, so the next models skip only those
|
||||
(offline in mainland China, Civitai fails while Hugging Face through its mirror works)."""
|
||||
|
||||
def __init__(self, down=None):
|
||||
self.down = down if down is not None else set()
|
||||
self.missed = False
|
||||
|
||||
def ask(self, site, call, *args):
|
||||
if site in self.down:
|
||||
self.missed = True
|
||||
return None
|
||||
try:
|
||||
return call(*args)
|
||||
except SourceUnreachable:
|
||||
self.down.add(site)
|
||||
self.missed = True
|
||||
return None
|
||||
|
||||
|
||||
def _by_link(sites, url, name, file_hash, hf_host):
|
||||
host = host_of(url)
|
||||
if _site(host, CIVITAI_HOSTS):
|
||||
return sites.ask("civitai", civitai_by_link, url, name, file_hash)
|
||||
if _site(host, HF_HOSTS):
|
||||
return sites.ask("huggingface", hugging_face_file, url, hf_host)
|
||||
if _site(host, OTHER_HOSTS):
|
||||
return plain_link(url)
|
||||
return None
|
||||
|
||||
|
||||
def _from_manager_list(sites, name, folder_type, file_hash, hf_host):
|
||||
"""A file of this name in ComfyUI-Manager's model list. With the workflow's fingerprint,
|
||||
only an entry whose file has that SHA-256 (several of the same name are each asked); without,
|
||||
the one entry of that name, marked `by_name` (nothing can tell it is the same file)."""
|
||||
entries = manager_catalog.candidates(name, folder_type)
|
||||
if not entries:
|
||||
return None
|
||||
if _is_sha_prefix(file_hash):
|
||||
for entry in entries[:6]:
|
||||
found = _by_link(sites, entry["url"], name, "", hf_host)
|
||||
if found and found.get("found") and same_file(file_hash, found.get("sha256")):
|
||||
return {**found, "via": "manager_list", "list_name": entry["name"], "by_name": False,
|
||||
"list_base": entry["base"], "list_site": manager_catalog.site_of(entry["url"])}
|
||||
return None
|
||||
if len(entries) != 1:
|
||||
return None # several files of that name: which one is unknowable
|
||||
found = _by_link(sites, entries[0]["url"], name, "", hf_host)
|
||||
if found and found.get("found"):
|
||||
return {**found, "via": "manager_list", "list_name": entries[0]["name"], "by_name": True,
|
||||
"base_model": found.get("base_model") or entries[0]["base"],
|
||||
"list_base": entries[0]["base"], "list_site": manager_catalog.site_of(entries[0]["url"])}
|
||||
return found
|
||||
|
||||
|
||||
def _same_file_on_hugging_face(sites, civitai, url, name, folder_type, hf_host):
|
||||
"""With the mirror on (mainland China, where Civitai is slow or out of reach and some of its
|
||||
files need a login), the same file (same SHA-256) on Hugging Face, from the workflow's link
|
||||
or ComfyUI-Manager's list, is fetched there instead. Civitai's name, base model and page stay."""
|
||||
sha = civitai.get("sha256") or ""
|
||||
if not SHA256.match(sha):
|
||||
return civitai
|
||||
other = None
|
||||
if url and _site(host_of(url), HF_HOSTS):
|
||||
other = _by_link(sites, url, name, "", hf_host)
|
||||
if not (other and other.get("found") and same_file(sha, other.get("sha256"))):
|
||||
other = None
|
||||
if other is None and name:
|
||||
other = _from_manager_list(sites, name, folder_type, sha, hf_host)
|
||||
if not other:
|
||||
return civitai
|
||||
keep = {key: civitai[key] for key in ("base_model", "model_name", "version_name", "page") if civitai.get(key)}
|
||||
return {**other, **keep, "via": other.get("via", ""), "also_on": "civitai"}
|
||||
|
||||
|
||||
def find_source(file_hash="", url="", name="", hf_mirror=False, folder_type="", down=None):
|
||||
"""Where one missing model can come from: {found, source, download_url, file_name, size,
|
||||
sha256, base_model, model_name, version_name, page}, or {found: False, reason}. Asked in
|
||||
turn: Civitai by the fingerprint, the workflow's link, ComfyUI-Manager's model list by file
|
||||
name. With `hf_mirror`, Hugging Face files come from its mirror (and without, never from it).
|
||||
`down` (a set kept across one lookup) names the sites that stopped answering."""
|
||||
sites = _Sites(down)
|
||||
hf_host = HF_MIRROR if hf_mirror else "huggingface.co"
|
||||
looked = False
|
||||
found = None
|
||||
if file_hash:
|
||||
looked = True
|
||||
found = sites.ask("civitai", civitai_by_hash, file_hash)
|
||||
if not found and url and host_of(url):
|
||||
looked = True
|
||||
found = _by_link(sites, url, name, file_hash, hf_host)
|
||||
if not (found and found.get("found")) and name:
|
||||
listed = _from_manager_list(sites, name, folder_type, file_hash, hf_host)
|
||||
looked = looked or listed is not None or bool(manager_catalog.candidates(name, folder_type))
|
||||
found = listed or found
|
||||
if found and found.get("found") and found.get("source") == "civitai" and hf_mirror:
|
||||
found = _same_file_on_hugging_face(sites, found, url, name, folder_type, hf_host)
|
||||
if found:
|
||||
return found
|
||||
if sites.missed:
|
||||
return {"found": False, "reason": "network"}
|
||||
return {"found": False, "reason": "not_found" if looked else "no_source"}
|
||||
+41
-4
@@ -1,4 +1,9 @@
|
||||
"""Configured model-folder visibility and scan-scope helpers."""
|
||||
"""Configured model-folder visibility and scan-scope helpers.
|
||||
|
||||
Shown folders are also the ones scans read, so a workflow can carry each model's fingerprint
|
||||
only for them. Text encoders, CLIP vision and upscalers are shown by default since
|
||||
2026-10 (FOLDER_DEFAULTS_VERSION 2); an older config.json gets them turned on once.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -6,12 +11,44 @@ import os
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
DEFAULT_TYPES = ['checkpoints', 'loras', 'diffusion_models', 'unet', 'controlnet', 'vae',
|
||||
'text_encoders', 'clip', 'clip_vision', 'upscale_models']
|
||||
# Added to the defaults in version 2 (the components a workflow's fingerprints need most).
|
||||
ADDED_IN_2 = {'text_encoders', 'clip', 'clip_vision', 'upscale_models'}
|
||||
FOLDER_DEFAULTS_VERSION = 2
|
||||
|
||||
DEFAULT_PHYSICAL_FOLDER_NAMES = {
|
||||
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae'
|
||||
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae',
|
||||
'text_encoders', 'clip', 'clip_vision', 'upscale_models',
|
||||
}
|
||||
|
||||
|
||||
def config_path():
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||||
|
||||
|
||||
def upgrade_folder_defaults(path=None):
|
||||
"""Once per config.json: the folders added to the defaults are shown (and scanned) even
|
||||
where an earlier version saved them as hidden. True when the file changed."""
|
||||
path = path or config_path()
|
||||
try:
|
||||
with open(path, encoding='utf-8') as source:
|
||||
cfg = json.load(source)
|
||||
except (OSError, ValueError):
|
||||
return False # no config yet: the defaults apply as they are
|
||||
if not isinstance(cfg, dict) or int(cfg.get("folder_defaults_version") or 1) >= FOLDER_DEFAULTS_VERSION:
|
||||
return False
|
||||
for key in ("folder_types_config", "physical_folders_config"):
|
||||
for item in cfg.get(key) or []:
|
||||
if isinstance(item, dict) and item.get("type") in ADDED_IN_2:
|
||||
item["visible"] = True
|
||||
cfg["folder_defaults_version"] = FOLDER_DEFAULTS_VERSION
|
||||
atomic_write_json(path, cfg)
|
||||
return True
|
||||
|
||||
|
||||
def get_folder_view_mode():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
@@ -149,7 +186,7 @@ def get_active_folder_types():
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
all_types = list(folder_paths.folder_names_and_paths.keys())
|
||||
default_types = ['checkpoints', 'loras', 'diffusion_models', 'unet', 'controlnet', 'vae']
|
||||
default_types = DEFAULT_TYPES
|
||||
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
@@ -215,7 +252,7 @@ async def api_get_all_folder_types(request):
|
||||
else:
|
||||
# Abstract mode
|
||||
all_types = list(folder_paths.folder_names_and_paths.keys())
|
||||
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
|
||||
default_types = DEFAULT_TYPES
|
||||
configured_types = set()
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
|
||||
+60
-10
@@ -9,13 +9,22 @@ from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .image_search import filter_images
|
||||
from .media_routes import CARD_THUMBNAIL_STATIC_EXTENSIONS, _build_card_thumbnail
|
||||
from .media_routes import (
|
||||
CARD_POSTER_VIDEO_EXTENSIONS,
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS,
|
||||
_build_card_thumbnail,
|
||||
_build_video_poster,
|
||||
)
|
||||
from .path_utils import require_filename, resolve_within
|
||||
from .trash import move_to_trash, trash_failure
|
||||
|
||||
|
||||
GALLERY_SNAPSHOT_SECONDS = 10
|
||||
MAX_CACHED_GALLERY_IMAGES = 50_000
|
||||
GALLERY_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'}
|
||||
# Videos a browser plays in the viewer; not .avi (Chrome and Edge cannot).
|
||||
GALLERY_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.mkv'}
|
||||
GALLERY_MEDIA = ("all", "image", "video")
|
||||
_gallery_snapshot_lock = threading.Lock()
|
||||
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
|
||||
|
||||
@@ -24,7 +33,8 @@ def _collect_gallery_images(output_dir):
|
||||
images = []
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for filename in files:
|
||||
if os.path.splitext(filename)[1].lower() not in {'.png', '.jpg', '.jpeg', '.webp', '.gif'}:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in GALLERY_IMAGE_EXTENSIONS and ext not in GALLERY_VIDEO_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
mtime = os.path.getmtime(os.path.join(root, filename))
|
||||
@@ -34,7 +44,9 @@ def _collect_gallery_images(output_dir):
|
||||
images.append({
|
||||
"filename": filename,
|
||||
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
|
||||
"type": "output", "mtime": mtime,
|
||||
"type": "output",
|
||||
"mtime": mtime,
|
||||
"media_type": "video" if ext in GALLERY_VIDEO_EXTENSIONS else "image",
|
||||
})
|
||||
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
|
||||
return images
|
||||
@@ -58,33 +70,66 @@ def _invalidate_gallery_snapshot():
|
||||
_gallery_snapshot.update(root=None, expires=0, images=[])
|
||||
|
||||
|
||||
def _gallery_listing(output_dir, refresh, query):
|
||||
def _format(filename):
|
||||
"""The format name the gallery's settings use: the extension, with .jpeg counted as jpg."""
|
||||
ext = os.path.splitext(filename)[1].lower().lstrip('.')
|
||||
return 'jpg' if ext == 'jpeg' else ext
|
||||
|
||||
|
||||
def _gallery_listing(output_dir, refresh, query, media="image", hidden=frozenset()):
|
||||
"""Outputs, newest first. `media`: "image" (the default, for callers that show pictures),
|
||||
"video" or "all"; `hidden`: format names left out (see _format)."""
|
||||
images = _gallery_images(output_dir, refresh)
|
||||
if media != "all" or hidden:
|
||||
images = [item for item in images
|
||||
if media in ("all", item["media_type"]) and _format(item["filename"]) not in hidden]
|
||||
return filter_images(os.path.realpath(output_dir), images, query) if query else images
|
||||
|
||||
|
||||
def _gallery_page_source(output_dir, refresh, query, media, hidden, kept_only=False):
|
||||
"""The listing (only the images kept as a recipe, combo or prompt when `kept_only`), and
|
||||
whether the output folder has any video (the gallery offers its Pictures / Videos switch
|
||||
only then)."""
|
||||
images = _gallery_listing(output_dir, refresh, query, media, hidden)
|
||||
if kept_only:
|
||||
from .kept_images import kept_keys # here: kept_images reaches back to this module through the stores
|
||||
kept = kept_keys()
|
||||
images = [item for item in images if (item.get("subfolder") or "", item["filename"]) in kept]
|
||||
has_videos = any(item["media_type"] == "video" for item in _gallery_images(output_dir))
|
||||
return images, has_videos
|
||||
|
||||
|
||||
async def api_get_gallery_images(request):
|
||||
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q) - Output images, optionally searched by embedded generation info."""
|
||||
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q)&media=all|image|video&hide=mkv,gif&kept=1
|
||||
- Outputs (pictures only unless `media` says otherwise), optionally searched by embedded
|
||||
generation info, without the `hide` formats; with `kept=1` only those kept as something."""
|
||||
try:
|
||||
page = max(1, int(request.query.get('page', 1)))
|
||||
limit = min(200, max(1, int(request.query.get('limit', 50))))
|
||||
# Repeated `term` params keep phrases whole; `q` is split on whitespace.
|
||||
terms = [term for term in request.query.getall('term', []) if term.strip()]
|
||||
query = terms or request.query.get('q', '').strip()
|
||||
images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query)
|
||||
media = request.query.get('media', 'image')
|
||||
if media not in GALLERY_MEDIA:
|
||||
raise ValueError(media)
|
||||
hidden = frozenset(part.strip().lower().lstrip('.') for part in request.query.get('hide', '').split(','))
|
||||
images, has_videos = await asyncio.to_thread(
|
||||
_gallery_page_source, folder_paths.get_output_directory(), request.query.get('refresh') == '1',
|
||||
query, media, hidden - {''}, request.query.get('kept') == '1')
|
||||
total = len(images)
|
||||
start = (page - 1) * limit
|
||||
return web.json_response({"images": images[start:start + limit], "total": total,
|
||||
"page": page, "pages": (total + limit - 1) // limit})
|
||||
"page": page, "pages": (total + limit - 1) // limit,
|
||||
"has_videos": has_videos})
|
||||
except (TypeError, ValueError):
|
||||
return web.json_response({"error": "Invalid pagination"}, status=400)
|
||||
return web.json_response({"error": "Invalid page or filter"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"error": "Could not list output images"}, status=500)
|
||||
|
||||
|
||||
async def api_output_thumbnail(request):
|
||||
"""GET /anomalous/output_thumbnail?filename&subfolder - the gallery grid's 512 px WebP of an
|
||||
output image (cached like model cards); the original stays at ComfyUI's /view."""
|
||||
output image/video (cached like model cards); the original stays at ComfyUI's /view."""
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
try:
|
||||
filename = require_filename(request.query.get('filename'))
|
||||
@@ -94,8 +139,13 @@ async def api_output_thumbnail(request):
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
served = file_path
|
||||
if os.path.splitext(file_path)[1].lower() in CARD_THUMBNAIL_STATIC_EXTENSIONS:
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
|
||||
served = await asyncio.to_thread(_build_card_thumbnail, file_path)
|
||||
elif ext in CARD_POSTER_VIDEO_EXTENSIONS:
|
||||
poster = await asyncio.to_thread(_build_video_poster, file_path)
|
||||
if poster:
|
||||
served = poster
|
||||
# Revalidate (cheap 304s): an output file can be replaced under the same name.
|
||||
return web.FileResponse(served, headers={'Cache-Control': 'no-cache'})
|
||||
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
"""What a Hugging Face repository says about a model downloaded from it (model_download.py):
|
||||
the model card's first example image becomes its cover, and its trigger words, example prompts
|
||||
and base model go into its notes, with the repository as its link. Hugging Face cannot be asked
|
||||
by fingerprint, so this works only for files downloaded from it (the repository is known then).
|
||||
|
||||
Nothing the user or a scan already has is replaced: a cover only when the model has none, notes
|
||||
and link only when empty (in the user's layer, <model>.anomalous.json).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
try:
|
||||
from ..model_identity import USER_INFO_SUFFIX, read_json
|
||||
from ..model_sidecars import target
|
||||
from ..civitai_client import USER_AGENT
|
||||
from ..net_proxy import urlopen
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import USER_INFO_SUFFIX, read_json
|
||||
from model_sidecars import target
|
||||
from civitai_client import USER_AGENT
|
||||
from net_proxy import urlopen
|
||||
|
||||
TIMEOUT = 15
|
||||
MAX_COVER_BYTES = 20 * 1024 * 1024
|
||||
IMAGE_TYPES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
|
||||
IMAGE_FILE = (".png", ".jpg", ".jpeg", ".webp")
|
||||
HF_HOSTS = ("huggingface.co", "hf-mirror.com")
|
||||
MAX_PROMPTS = 3
|
||||
|
||||
|
||||
def repo_of(url):
|
||||
"""("org/name", host) of a Hugging Face file link, or None."""
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
if (parsed.hostname or "") not in HF_HOSTS or "resolve" not in parts or parts.index("resolve") < 2:
|
||||
return None
|
||||
return "/".join(parts[:2]), parsed.hostname
|
||||
|
||||
|
||||
def _get(url, limit=None):
|
||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urlopen(request, timeout=TIMEOUT) as response:
|
||||
data = response.read(limit + 1 if limit else -1)
|
||||
return data, str(response.headers.get("Content-Type", "")).split(";")[0].strip().lower()
|
||||
|
||||
|
||||
def _file_url(repo, host, path):
|
||||
"""A link to a file of the repository, on `host` (the mirror when the download used it)."""
|
||||
if path.startswith(("https://", "http://")):
|
||||
parsed = urllib.parse.urlsplit(path)
|
||||
if parsed.hostname not in HF_HOSTS:
|
||||
return ""
|
||||
return urllib.parse.urlunsplit(("https", host, parsed.path, parsed.query, ""))
|
||||
return f"https://{host}/{repo}/resolve/main/{urllib.parse.quote(path.lstrip('/'))}"
|
||||
|
||||
|
||||
def _as_list(value):
|
||||
if isinstance(value, str):
|
||||
return [value] if value.strip() else []
|
||||
return [str(item) for item in value if str(item).strip()] if isinstance(value, list) else []
|
||||
|
||||
|
||||
def read_card(repo, host):
|
||||
"""{images, prompts, trigger, base} from the repository's model card (empty when it has none)."""
|
||||
data, _type = _get(f"https://{host}/api/models/{repo}?expand[]=cardData&expand[]=siblings")
|
||||
info = json.loads(data.decode("utf-8"))
|
||||
card = info.get("cardData") or {}
|
||||
widgets = [item for item in card.get("widget") or [] if isinstance(item, dict)]
|
||||
images = [(item.get("output") or {}).get("url") for item in widgets]
|
||||
images = [url for url in (_file_url(repo, host, str(path)) for path in images if path) if url]
|
||||
if not images: # no examples in the card: the repository's own pictures
|
||||
files = [item.get("rfilename", "") for item in info.get("siblings") or [] if isinstance(item, dict)]
|
||||
images = [_file_url(repo, host, name) for name in files if name.lower().endswith(IMAGE_FILE)][:1]
|
||||
prompts = [str(item.get("text")).strip() for item in widgets if isinstance(item.get("text"), str) and item["text"].strip()]
|
||||
return {"images": images, "prompts": prompts[:MAX_PROMPTS],
|
||||
"trigger": _as_list(card.get("instance_prompt")), "base": _as_list(card.get("base_model"))}
|
||||
|
||||
|
||||
def save_cover(image_url, base_path):
|
||||
"""The image as the model's cover (<model>.preview.<ext>) when it has none yet; True if saved."""
|
||||
if any(os.path.exists(base_path + suffix) for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS):
|
||||
return False
|
||||
data, content_type = _get(image_url, MAX_COVER_BYTES)
|
||||
extension = IMAGE_TYPES.get(content_type)
|
||||
if not extension or len(data) > MAX_COVER_BYTES:
|
||||
return False
|
||||
target = base_path + ".preview" + extension
|
||||
temporary = target + ".part"
|
||||
with open(temporary, "wb") as output:
|
||||
output.write(data)
|
||||
os.replace(temporary, target)
|
||||
return True
|
||||
|
||||
|
||||
def notes_text(repo, card):
|
||||
lines = [f"Hugging Face: {repo}"]
|
||||
if card["base"]:
|
||||
lines.append("Base model: " + ", ".join(card["base"]))
|
||||
if card["trigger"]:
|
||||
lines.append("Trigger words: " + ", ".join(card["trigger"]))
|
||||
if card["prompts"]:
|
||||
lines.append("Example prompts:")
|
||||
lines.extend("- " + prompt[:400] for prompt in card["prompts"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def save_notes(base_path, repo, card):
|
||||
"""The card's words as the model's notes and the repository as its link, where both are empty."""
|
||||
path = target(base_path, USER_INFO_SUFFIX)
|
||||
if os.path.exists(path) and read_json(path) is None:
|
||||
return False # unreadable: leave it to the user
|
||||
user = read_json(path) or {"format": 1}
|
||||
changed = False
|
||||
if not str(user.get("custom_notes") or "").strip():
|
||||
user["custom_notes"] = notes_text(repo, card)
|
||||
changed = True
|
||||
if not str(user.get("source_url") or "").strip():
|
||||
user["source_url"] = f"https://huggingface.co/{repo}"
|
||||
changed = True
|
||||
if changed:
|
||||
atomic_write_json(path, user)
|
||||
return changed
|
||||
|
||||
|
||||
def enrich(model_path, download_url):
|
||||
"""Cover and notes for a model just downloaded from Hugging Face: {cover, notes}."""
|
||||
located = repo_of(download_url)
|
||||
if not located:
|
||||
return {"cover": False, "notes": False}
|
||||
repo, host = located
|
||||
base_path = os.path.splitext(model_path)[0]
|
||||
card = read_card(repo, host)
|
||||
cover = False
|
||||
for image in card["images"][:2]:
|
||||
try:
|
||||
cover = save_cover(image, base_path)
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
return {"cover": cover, "notes": save_notes(base_path, repo, card)}
|
||||
+7
-2
@@ -76,11 +76,12 @@ def _normalise_model_ref(value):
|
||||
|
||||
|
||||
def build_record(path):
|
||||
"""Searchable text, recorded model hashes and referenced model files for one image."""
|
||||
"""Searchable text, recorded model hashes, referenced model files and node types for one image."""
|
||||
name = os.path.basename(path)
|
||||
texts = [name]
|
||||
hashes = set()
|
||||
models = set()
|
||||
types = set()
|
||||
if path.lower().endswith(".png"):
|
||||
try:
|
||||
chunks = read_png_text(path)
|
||||
@@ -92,7 +93,11 @@ def build_record(path):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
texts.append(str(node.get("class_type", "")))
|
||||
if isinstance(node.get("class_type"), str):
|
||||
types.add(node["class_type"])
|
||||
for key, value in (node.get("inputs") or {}).items():
|
||||
if isinstance(value, dict) and isinstance(value.get("lora"), str):
|
||||
value = value["lora"] # a row of rgthree's Power Lora Loader
|
||||
if isinstance(value, str):
|
||||
texts.append(value)
|
||||
if MODEL_FILE.search(value):
|
||||
@@ -111,7 +116,7 @@ def build_record(path):
|
||||
models |= _normalise_model_ref(model_name)
|
||||
if chunks.get("parameters"):
|
||||
texts.append(chunks["parameters"])
|
||||
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models}
|
||||
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models, "types": types}
|
||||
|
||||
|
||||
def _load_json(raw):
|
||||
|
||||
@@ -35,6 +35,11 @@ def _kept_images():
|
||||
return [{"subfolder": subfolder, "filename": filename, **entry} for (subfolder, filename), entry in kept.items()]
|
||||
|
||||
|
||||
def kept_keys():
|
||||
"""{(subfolder, filename)} of every output image kept as anything (the gallery's ★ view)."""
|
||||
return {(item["subfolder"], item["filename"]) for item in _kept_images()}
|
||||
|
||||
|
||||
async def api_kept_images(request):
|
||||
try:
|
||||
images = await asyncio.to_thread(_kept_images)
|
||||
|
||||
+39
-4
@@ -6,9 +6,16 @@ version switches, saved settings) answers only requests from the computer ComfyU
|
||||
GET is local-only unless it is a read sent as POST (OPEN_POSTS); a GET is open unless it is not
|
||||
a plain read (LOCAL_GETS). A request is local when it comes from a loopback address, through no
|
||||
proxy, naming a loopback host; checking the Host also keeps out a page that reached this server
|
||||
under another name (DNS rebinding)."""
|
||||
under another name (DNS rebinding).
|
||||
|
||||
ComfyUI on a rented cloud server is only ever opened from another device. An empty file named
|
||||
`allow_remote_changes` in the plugin's folder, made on that server, lets every device that can
|
||||
open the page change things too (may_change); it is read once, when ComfyUI starts, and the
|
||||
page cannot make or remove it. MCP keeps to this computer either way."""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import shlex
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
@@ -26,11 +33,19 @@ OPEN_POSTS = frozenset({
|
||||
LOCAL_GETS = frozenset({
|
||||
"/anomalous/backup/file",
|
||||
"/anomalous/version/releases",
|
||||
"/anomalous/tts_version/releases",
|
||||
"/anomalous/import/folders",
|
||||
"/anomalous/civitai_check",
|
||||
"/anomalous/usage/check", # reads every output and saved workflow
|
||||
})
|
||||
# Routes that answer other devices themselves (MCP replies in JSON-RPC).
|
||||
OWN_CHECK = frozenset({"/anomalous/mcp"})
|
||||
READS = ("GET", "HEAD", "OPTIONS")
|
||||
REMOTE_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "allow_remote_changes")
|
||||
REMOTE_CHANGES = os.path.isfile(REMOTE_FILE)
|
||||
if REMOTE_CHANGES:
|
||||
print("[Anomalous Browser] allow_remote_changes 文件存在:任何能打开页面的设备都能扫描、移动、删除模型和改设置。"
|
||||
" The allow_remote_changes file is there: every device that can open the page may change things.", flush=True)
|
||||
MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览。"
|
||||
" Only the computer ComfyUI runs on can do this; other devices can only look.")
|
||||
|
||||
@@ -56,6 +71,11 @@ def is_local(request):
|
||||
return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname)
|
||||
|
||||
|
||||
def may_change(request):
|
||||
"""The request may change things: it comes from this computer, or remote changes are allowed."""
|
||||
return REMOTE_CHANGES or is_local(request)
|
||||
|
||||
|
||||
def needs_local(method, path):
|
||||
"""Whether a request to `path` with `method` must come from this computer."""
|
||||
if not path.startswith(PREFIX) or path in OWN_CHECK:
|
||||
@@ -72,11 +92,26 @@ def refusal():
|
||||
|
||||
@web.middleware
|
||||
async def local_only_middleware(request, handler):
|
||||
if needs_local(request.method, request.path) and not is_local(request):
|
||||
if needs_local(request.method, request.path) and not may_change(request):
|
||||
return refusal()
|
||||
return await handler(request)
|
||||
|
||||
|
||||
def switch_command():
|
||||
"""The line to paste into the server's terminal that makes the allow_remote_changes file, or
|
||||
removes it when it is there. On Windows `echo off >` makes the file in cmd and PowerShell
|
||||
alike (only that it exists counts)."""
|
||||
if os.name == "nt":
|
||||
return f'del "{REMOTE_FILE}"' if REMOTE_CHANGES else f'echo off > "{REMOTE_FILE}"'
|
||||
return ("rm -f " if REMOTE_CHANGES else "touch ") + shlex.quote(REMOTE_FILE)
|
||||
|
||||
|
||||
async def api_access(request):
|
||||
"""GET /anomalous/access - whether this page may change things: {"local": bool}."""
|
||||
return web.json_response({"local": is_local(request)})
|
||||
"""GET /anomalous/access - whether this page is on this computer and whether it may change
|
||||
things: {"local": bool, "changes": bool}; for another device also "command", the line that
|
||||
turns remote changes on or off (it only runs where someone types it, on the server)."""
|
||||
local = is_local(request)
|
||||
answer = {"local": local, "changes": may_change(request)}
|
||||
if not local:
|
||||
answer["command"] = switch_command()
|
||||
return web.json_response(answer)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""What a LoRA file trains, read from its safetensors header: whether it also has text-encoder
|
||||
weights, so inserting it wires the CLIP line too (graph_splice.js planLoraInsertion).
|
||||
|
||||
GET /anomalous/lora_info?name=<the loader's lora_name> -> {text_encoder}.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import struct
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
# Key prefixes of text-encoder weights: kohya lora_te / lora_te1 / lora_te2, te1 / te2, diffusers text_encoder.
|
||||
TEXT_ENCODER_KEYS = ("lora_te", "te_", "te1", "te2", "text_encoder")
|
||||
MAX_HEADER_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def trains_text_encoder(path):
|
||||
"""Whether the LoRA at `path` has text-encoder weights. Other formats, or a header that
|
||||
cannot be read, count as yes: the CLIP line then goes through it, which costs nothing."""
|
||||
if not str(path).lower().endswith(".safetensors"):
|
||||
return True
|
||||
try:
|
||||
with open(path, "rb") as source:
|
||||
size = struct.unpack("<Q", source.read(8))[0]
|
||||
if size > MAX_HEADER_BYTES:
|
||||
return True
|
||||
keys = json.loads(source.read(size))
|
||||
except (OSError, ValueError, struct.error):
|
||||
return True
|
||||
return any(key.startswith(TEXT_ENCODER_KEYS) for key in keys if key != "__metadata__")
|
||||
|
||||
|
||||
async def api_lora_info(request):
|
||||
name = request.query.get("name", "")
|
||||
path = folder_paths.get_full_path("loras", name) if name else None # only names ComfyUI lists
|
||||
if not path:
|
||||
return web.json_response({"error": "Unknown LoRA"}, status=404)
|
||||
return web.json_response({"text_encoder": await asyncio.to_thread(trains_text_encoder, path)})
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Where ComfyUI-Manager's model list says a model file comes from, for Model Check's downloads
|
||||
(download_sources.find_source). The list (model-list.json, a few hundred common models: base
|
||||
models, VAEs, text encoders, upscalers, ControlNets… mostly on Hugging Face) is read from the
|
||||
Manager installed here; without it there is nothing to find. A list entry is only a file of the
|
||||
same name: download_sources checks it against the workflow's fingerprint, or offers it as such.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
|
||||
import folder_paths
|
||||
|
||||
# The list's model types and save paths -> ComfyUI folder types.
|
||||
TYPE_FOLDERS = {
|
||||
"checkpoint": "checkpoints", "checkpoints": "checkpoints", "lora": "loras", "loras": "loras",
|
||||
"vae": "vae", "taesd": "vae_approx", "vae_approx": "vae_approx", "clip": "text_encoders",
|
||||
"text_encoders": "text_encoders", "clip_vision": "clip_vision", "unet": "diffusion_models",
|
||||
"diffusion_model": "diffusion_models", "diffusion_models": "diffusion_models", "controlnet": "controlnet",
|
||||
"upscale": "upscale_models", "upscale_models": "upscale_models", "embeddings": "embeddings",
|
||||
"embedding": "embeddings", "gligen": "gligen", "style_models": "style_models",
|
||||
}
|
||||
# ComfyUI's older names for the same folders.
|
||||
SAME_FOLDER = {"unet": "diffusion_models", "clip": "text_encoders"}
|
||||
|
||||
_cache = {"path": "", "mtime": 0.0, "index": {}}
|
||||
|
||||
|
||||
def list_path():
|
||||
"""The Manager's model-list.json on this computer, or ''."""
|
||||
roots = []
|
||||
try:
|
||||
roots = list(folder_paths.get_folder_paths("custom_nodes") or [])
|
||||
except Exception:
|
||||
pass
|
||||
for root in roots:
|
||||
try:
|
||||
names = sorted(os.listdir(root))
|
||||
except OSError:
|
||||
continue
|
||||
for name in names:
|
||||
path = os.path.join(root, name, "model-list.json")
|
||||
if "manager" in name.lower() and os.path.isfile(path):
|
||||
return path
|
||||
try: # the Manager installed as a Python package
|
||||
spec = importlib.util.find_spec("comfyui_manager")
|
||||
for location in (spec.submodule_search_locations or []) if spec else []:
|
||||
path = os.path.join(location, "model-list.json")
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
except (ImportError, ValueError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def site_of(url):
|
||||
"""Where a list entry's file is: the host and, on Hugging Face or GitHub, the repository."""
|
||||
parts = str(url or "").split("/")
|
||||
if len(parts) < 3:
|
||||
return ""
|
||||
host = parts[2].lower()
|
||||
return "/".join([host, *parts[3:5]]) if host in ("huggingface.co", "hf-mirror.com", "github.com") and len(parts) >= 5 else host
|
||||
|
||||
|
||||
def _folder(entry):
|
||||
path = str(entry.get("save_path") or "").replace("\\", "/").strip("/")
|
||||
first = path.split("/")[0].lower() if path and path != "default" else ""
|
||||
return TYPE_FOLDERS.get(first) or TYPE_FOLDERS.get(str(entry.get("type") or "").lower(), "")
|
||||
|
||||
|
||||
def _index():
|
||||
path = list_path()
|
||||
if not path:
|
||||
return {}
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
return {}
|
||||
if path != _cache["path"] or mtime != _cache["mtime"]:
|
||||
index = {}
|
||||
try:
|
||||
with open(path, encoding="utf-8") as source:
|
||||
models = json.load(source).get("models") or []
|
||||
except (OSError, ValueError, AttributeError):
|
||||
models = []
|
||||
for entry in models:
|
||||
if not isinstance(entry, dict) or not entry.get("filename") or not str(entry.get("url", "")).startswith("https://"):
|
||||
continue
|
||||
index.setdefault(str(entry["filename"]).lower(), []).append({
|
||||
"name": str(entry.get("name") or entry["filename"]), "url": entry["url"],
|
||||
"folder": _folder(entry), "base": str(entry.get("base") or ""),
|
||||
})
|
||||
_cache.update(path=path, mtime=mtime, index=index)
|
||||
return _cache["index"]
|
||||
|
||||
|
||||
def candidates(file_name, folder_type=""):
|
||||
"""The list's entries for a file of this name (any case) that fit `folder_type`, one per URL.
|
||||
When the node's folder type is known, an entry whose type the list does not say does not fit."""
|
||||
wanted = SAME_FOLDER.get(folder_type, folder_type)
|
||||
seen, found = set(), []
|
||||
for entry in _index().get(os.path.basename(str(file_name)).lower(), []):
|
||||
if wanted and SAME_FOLDER.get(entry["folder"], entry["folder"]) != wanted:
|
||||
continue
|
||||
if entry["url"] not in seen:
|
||||
seen.add(entry["url"])
|
||||
found.append(entry)
|
||||
return found
|
||||
@@ -399,6 +399,10 @@ async def api_get_materials_by_node_type(request):
|
||||
continue
|
||||
blocks = [block for block in _material_node_blocks(material, include_values=True) if block["type"] == node_type]
|
||||
if blocks:
|
||||
# Where the values came from, for Current node: a kept picture, or a node saved there.
|
||||
source = material.get("source") if isinstance(material.get("source"), dict) else {}
|
||||
image = source.get("image") if isinstance(source.get("image"), dict) else {}
|
||||
preview = str((material.get("image") if isinstance(material.get("image"), dict) else {}).get("preview_asset_id") or "")
|
||||
matches.append({
|
||||
"filename": summary["filename"],
|
||||
"name": summary["name"],
|
||||
@@ -406,6 +410,10 @@ async def api_get_materials_by_node_type(request):
|
||||
"timestamp": summary["timestamp"],
|
||||
"blocks": blocks,
|
||||
"workflow_hashes": _workflow_hashes_for_blocks(material["workflow"], blocks),
|
||||
"origin": str(source.get("type") or ""),
|
||||
"source_image": {"filename": str(image["filename"]), "subfolder": str(image.get("subfolder") or "")}
|
||||
if isinstance(image.get("filename"), str) and image["filename"] else None,
|
||||
"preview": preview if preview.lower().endswith((".png", ".webp")) else "",
|
||||
})
|
||||
return matches
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""The MCP tools that act: on the open canvas, through the page (mcp_bridge.py and
|
||||
web/modules/mcp_bridge.js, which use Anomalous's own canvas code, so every change is one
|
||||
Ctrl+Z step and lands in the activity log marked as the AI's), and on this ComfyUI server
|
||||
through its own routes (scans, Anomalous TTS speech), so they behave exactly as from the UI.
|
||||
|
||||
Nothing here deletes or renames files. Tools that overwrite something on the canvas say so
|
||||
(destructiveHint); AI apps ask the user before calling tools that are not read-only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from . import ai_gates, mcp_fill, model_advice, run_refusal
|
||||
from .lora_info import trains_text_encoder
|
||||
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
|
||||
from .mcp_tools import MAX_LIMIT, ToolError, _resolve_model, _schema
|
||||
|
||||
SPEAK_WAIT_SECONDS = 120
|
||||
HISTORY_POLL_SECONDS = 1.5
|
||||
|
||||
|
||||
def _page(action, args=None, timeout=30):
|
||||
try:
|
||||
return ask_page(action, args, timeout)
|
||||
except (PageUnavailable, PageRefused) as error:
|
||||
raise ToolError(str(error))
|
||||
|
||||
|
||||
def _rel(base_dir, file_path):
|
||||
return os.path.relpath(file_path, base_dir).replace(os.sep, "/")
|
||||
|
||||
|
||||
def _local(ctx, method, path, body=None, timeout=20):
|
||||
"""(status, JSON) of one request to this ComfyUI server's own routes."""
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
request = urllib.request.Request(ctx["base_url"] + path, data=data, method=method,
|
||||
headers={"Content-Type": "application/json"})
|
||||
# Loopback only (mcp_server checked the host); a self-signed certificate is the user's own.
|
||||
context = ssl._create_unverified_context() if ctx["base_url"].startswith("https:") else None
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
|
||||
return response.status, json.loads(response.read() or b"null")
|
||||
except urllib.error.HTTPError as error:
|
||||
try:
|
||||
return error.code, json.loads(error.read() or b"null")
|
||||
except ValueError:
|
||||
return error.code, None
|
||||
except (urllib.error.URLError, OSError) as error:
|
||||
raise ToolError(f"Could not reach ComfyUI's own server: {error}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- canvas (through the page)
|
||||
|
||||
def describe_canvas(ctx):
|
||||
return _page("describe_canvas")
|
||||
|
||||
|
||||
def set_prompt(ctx, prompt, role="positive", node_id=None, mode="replace"):
|
||||
if role not in ("positive", "negative") or mode not in ("replace", "append"):
|
||||
raise ToolError("role is positive or negative; mode is replace or append.")
|
||||
return _page("set_prompt", {"text": str(prompt), "role": role, "node_id": node_id, "mode": mode})
|
||||
|
||||
|
||||
def _base_note(result, folder_type, file_path):
|
||||
"""The page's answer without the model line it reports, and base_model_note when a LoRA on that
|
||||
line does not suit its main model (model_advice.py)."""
|
||||
if not isinstance(result, dict):
|
||||
return result
|
||||
line = result.pop("line", None) or {}
|
||||
if folder_type == "loras":
|
||||
note = model_advice.lora_note(file_path, line)
|
||||
else:
|
||||
notes = (model_advice.lora_note(path, {"model": line.get("model")}) for path in model_advice.lora_paths(line.get("loras")))
|
||||
note = next((item for item in notes if item), None)
|
||||
if note:
|
||||
result["base_model_note"] = note
|
||||
return result
|
||||
|
||||
|
||||
def set_model(ctx, node_id, model_id):
|
||||
folder_type, _idx, base_dir, file_path = _resolve_model(model_id)
|
||||
result = _page("set_model", {"node_id": node_id, "model": {"type": folder_type, "path": _rel(base_dir, file_path)}})
|
||||
return _base_note(result, folder_type, file_path)
|
||||
|
||||
|
||||
def add_lora(ctx, model_id, after_node_id=None, strength_model=1.0, strength_clip=1.0):
|
||||
folder_type, _idx, base_dir, file_path = _resolve_model(model_id)
|
||||
if folder_type != "loras":
|
||||
raise ToolError("add_lora takes a model from the loras folder.")
|
||||
result = _page("add_lora", {"model": {"type": folder_type, "path": _rel(base_dir, file_path)},
|
||||
"text_encoder": trains_text_encoder(file_path), "after_node_id": after_node_id,
|
||||
"strength_model": float(strength_model), "strength_clip": float(strength_clip)})
|
||||
return _base_note(result, folder_type, file_path)
|
||||
|
||||
|
||||
def place_combo(ctx, name):
|
||||
return _page("place_combo", {"name": str(name)})
|
||||
|
||||
|
||||
def check_workflow_models(ctx):
|
||||
return _page("check_models", timeout=90)
|
||||
|
||||
|
||||
def fix_workflow_models(ctx):
|
||||
return _page("fix_models", timeout=90)
|
||||
|
||||
|
||||
def download_missing_models(ctx, names=None):
|
||||
return _page("download_missing", {"names": [str(name) for name in names or []]}, timeout=120)
|
||||
|
||||
|
||||
def download_model(ctx, type, url="", hash="", folder=None, name=""):
|
||||
if not (url or hash):
|
||||
raise ToolError("Give url (a Civitai, Hugging Face or GitHub link to the file or model version) or hash.")
|
||||
return _page("download_model", {"url": str(url), "hash": str(hash), "type": str(type),
|
||||
"folder": None if folder is None else str(folder), "name": str(name or "")}, timeout=90)
|
||||
|
||||
|
||||
def download_status(ctx):
|
||||
status, data = _local(ctx, "GET", "/anomalous/download/status")
|
||||
if status != 200:
|
||||
raise ToolError(f"Download status unavailable ({status}).")
|
||||
jobs = []
|
||||
for job in (data or {}).get("jobs", []):
|
||||
total = job.get("total") or 0
|
||||
jobs.append({"file": job.get("rel"), "type": job.get("type"), "state": job.get("state"), "error": job.get("error") or None,
|
||||
"percent": round(job["received"] * 100 / total) if total else None, "size": total or None,
|
||||
"checked": job.get("verified"), "for_model": job.get("value") or None})
|
||||
return {"downloads": jobs, "meaning": "state: queued, running, verifying, done (checked: its fingerprint matched), "
|
||||
"failed (error says why; starting it again continues it), cancelled."}
|
||||
|
||||
|
||||
def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, **slots):
|
||||
from .ai_run_tracking import checked_wait
|
||||
checked_wait(wait_seconds) # Reject a wait that is no number before anything can be submitted.
|
||||
batch = max(1, min(8, int(batch)))
|
||||
filled = mcp_fill.fill(ctx, workflow, **slots) # a saved workflow and its blanks first (mcp_fill.py)
|
||||
try:
|
||||
answer = ask_page("run_workflow", {"batch": batch}, required_capability="prompt_ids")
|
||||
except (PageUnavailable, PageRefused) as error:
|
||||
raise ToolError(str(error))
|
||||
ids = answer.get("prompt_ids") if isinstance(answer, dict) else None
|
||||
page = answer if isinstance(answer, dict) else {}
|
||||
if (not isinstance(ids, list) or not ids or len(ids) > batch
|
||||
or any(not isinstance(pid, str) or not pid or len(pid) > 128 for pid in ids) or len(set(ids)) != len(ids)):
|
||||
raise ToolError((run_refusal.say(answer.get("refusal")) or answer.get("submission_error") if isinstance(answer, dict) else None)
|
||||
or "No valid submission receipts. Check ComfyUI's queue before retrying; do not repeat automatically.")
|
||||
answer = {"prompt_ids": ids, "queued": len(ids), "requested": batch,
|
||||
"note": ("If this answer does not say it is done, the user tells you when it is: say it is running and end "
|
||||
"your turn; run_status then brings its pictures." if ai_gates.run_wait() == "call" else
|
||||
"Follow these runs with run_status (wait_seconds up to 55).")
|
||||
+ " Do not repeat the batch; accepted prompts are already queued."}
|
||||
if filled:
|
||||
answer["filled"] = filled
|
||||
# Some prompts may have been accepted before validation or a transport failure stopped the batch.
|
||||
if len(ids) < batch:
|
||||
answer["submission_error"] = f"Only {len(ids)} of {batch} prompts were accepted."
|
||||
told = run_refusal.say(page.get("refusal"))
|
||||
if told:
|
||||
answer["submission_error"] += f" {told}"
|
||||
return answer
|
||||
|
||||
|
||||
def open_in_anomalous(ctx, model_id):
|
||||
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
|
||||
return _page("open_model", {"type": folder_type, "path_idx": path_idx, "rel": _rel(base_dir, file_path)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- this server
|
||||
|
||||
def scan_models(ctx, what="new", model_ids=None):
|
||||
body = {"offline_only": False, "skip_rename": False, "virtual_rename": True, "physical_rename": False,
|
||||
"force_overwrite": False, "retry_unmatched": what == "retry_unmatched"}
|
||||
if what == "models":
|
||||
folders = {}
|
||||
for model_id in model_ids or []:
|
||||
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
|
||||
subfolder, _, filename = _rel(base_dir, file_path).rpartition("/")
|
||||
folders.setdefault((folder_type, path_idx, "/" + subfolder), []).append(filename)
|
||||
if not folders:
|
||||
raise ToolError("what=models needs model_ids from search_models.")
|
||||
body["targets"] = [{"type": t, "path_idx": i, "subfolder": s, "files": files} for (t, i, s), files in folders.items()]
|
||||
elif what not in ("new", "retry_unmatched"):
|
||||
raise ToolError("what is new, retry_unmatched or models.")
|
||||
status, data = _local(ctx, "POST", "/anomalous/scan_all", body)
|
||||
if status == 409:
|
||||
raise ToolError("A scan is already running; call scan_report later to see it.")
|
||||
if status != 200 or (data or {}).get("status") != "ok":
|
||||
raise ToolError(f"The scan did not start: {(data or {}).get('message') or status}")
|
||||
return {"started": True, "note": "Scans run in the background and only read .safetensors files. "
|
||||
"Call scan_report in a minute or two for what it found."}
|
||||
|
||||
|
||||
def _tts_characters(ctx):
|
||||
status, data = _local(ctx, "GET", "/anomalous_tts/characters")
|
||||
if status == 404:
|
||||
raise ToolError("Anomalous TTS is not installed, so there are no voices.")
|
||||
if status != 200 or not isinstance(data, dict):
|
||||
raise ToolError(f"Could not read the voices ({status}).")
|
||||
return data.get("characters") or []
|
||||
|
||||
|
||||
def list_voices(ctx):
|
||||
return {"voices": [{"character": item.get("name"), "language": item.get("language") or "",
|
||||
"emotions": sorted((item.get("emotions") or {}).keys()),
|
||||
**({"problem": item.get("error") or item.get("settings_error")}
|
||||
if item.get("error") or item.get("settings_error") else {})}
|
||||
for item in _tts_characters(ctx)]}
|
||||
|
||||
|
||||
def _output_prefix(character):
|
||||
"""Where the audio page saves a character's lines (audio_script.js ttsOutputPrefix)."""
|
||||
parts = [re.sub(r'[<>:"\\|?*\x00-\x1f]', "_", part).strip() or "_" for part in str(character).split("/")]
|
||||
parts = ["_" if re.fullmatch(r"\.+", part) else part for part in parts]
|
||||
return f"audio/{'/'.join(parts)}/{parts[-1]}"
|
||||
|
||||
|
||||
def speak(ctx, character, text, seed=None, language="auto"):
|
||||
names = {item.get("name") for item in _tts_characters(ctx)}
|
||||
if character not in names:
|
||||
raise ToolError(f"No voice called {character!r}; list_voices names them.")
|
||||
if not str(text).strip():
|
||||
raise ToolError("text is empty.")
|
||||
seed = int(seed) if seed is not None else int(time.time() * 1000) % 2**31
|
||||
prompt = {"1": {"class_type": "AnomalousTTS_CharacterSpeech",
|
||||
"inputs": {"character": character, "text": str(text), "seed": seed, "language": language}},
|
||||
"2": {"class_type": "SaveAudio", "inputs": {"audio": ["1", 0], "filename_prefix": _output_prefix(character)}}}
|
||||
status, data = _local(ctx, "POST", "/prompt", {"prompt": prompt, "client_id": "anomalous-mcp"})
|
||||
prompt_id = (data or {}).get("prompt_id")
|
||||
if status != 200 or not prompt_id:
|
||||
errors = (data or {}).get("node_errors") or (data or {}).get("error") or status
|
||||
raise ToolError(f"ComfyUI refused the speech job: {json.dumps(errors, ensure_ascii=False)[:500]}")
|
||||
deadline = time.monotonic() + SPEAK_WAIT_SECONDS
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(HISTORY_POLL_SECONDS)
|
||||
_status, history = _local(ctx, "GET", f"/history/{prompt_id}")
|
||||
record = (history or {}).get(prompt_id) or {}
|
||||
state = record.get("status") or {}
|
||||
if state.get("status_str") == "error":
|
||||
messages = [m[1].get("exception_message") for m in state.get("messages") or [] if m and m[0] == "execution_error"]
|
||||
raise ToolError(f"Speech failed: {messages[0] if messages else 'see the ComfyUI console'}")
|
||||
if state.get("completed"):
|
||||
files = [f"{item.get('subfolder')}/{item.get('filename')}".replace("\\", "/").strip("/")
|
||||
for output in (record.get("outputs") or {}).values() for item in output.get("audio") or []]
|
||||
return {"done": True, "seed": seed, "files": files,
|
||||
"note": "Saved under ComfyUI's output folder; it shows in Anomalous's audio gallery."}
|
||||
return {"done": False, "prompt_id": prompt_id, "seed": seed,
|
||||
"note": "Still generating (ComfyUI may be busy with other jobs); search_audio will list it when done."}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- registry
|
||||
|
||||
_NODE = {"type": "integer", "description": "A node id."}
|
||||
_MODEL = {"type": "string", "description": "model_id from search_models."}
|
||||
|
||||
|
||||
def _action(name, title, description, run, properties=None, required=(), destructive=False, open_world=False,
|
||||
read_only=False):
|
||||
return name, {"run": run, "context": True, "spec": {
|
||||
"name": name, "title": title, "description": description,
|
||||
"inputSchema": _schema(properties, required),
|
||||
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive,
|
||||
"idempotentHint": read_only, "openWorldHint": open_world},
|
||||
}}
|
||||
|
||||
|
||||
ACTIONS = dict([
|
||||
_action("describe_canvas", "Read the canvas",
|
||||
"The workflow open in ComfyUI now: each node's id, type, title, settings, which prompt box is "
|
||||
"positive or negative, and where its inputs come from. Call it before changing settings; "
|
||||
"get_situation gives the canvas in brief (a line a node, what is still unwired and what fits it).",
|
||||
describe_canvas, read_only=True),
|
||||
_action("set_prompt", "Write a prompt",
|
||||
"Writes text into the workflow's positive or negative prompt box (or the one on node_id). "
|
||||
"mode=append adds to what is there. One Ctrl+Z step.",
|
||||
set_prompt, {"prompt": {"type": "string"}, "role": {"type": "string", "enum": ["positive", "negative"], "default": "positive"},
|
||||
"node_id": _NODE, "mode": {"type": "string", "enum": ["replace", "append"], "default": "replace"}},
|
||||
["prompt"], destructive=True),
|
||||
_action("set_model", "Swap a node's model",
|
||||
"Puts a model into a loader node (checkpoint, LoRA, VAE…) on the canvas, replacing the one it had. "
|
||||
"base_model_note in the answer: a LoRA and the main model are for different base models.",
|
||||
set_model, {"node_id": _NODE, "model_id": _MODEL}, ["node_id", "model_id"], destructive=True),
|
||||
_action("add_lora", "Add a LoRA",
|
||||
"Inserts a LoRA loader after after_node_id (default: the main model loader, past its LoRAs) and moves "
|
||||
"what that node fed behind it; the CLIP line too when the LoRA trains the text encoder. base_model_note in "
|
||||
"the answer: the LoRA is for another base model than the main model.",
|
||||
add_lora, {"model_id": _MODEL, "after_node_id": _NODE,
|
||||
"strength_model": {"type": "number", "default": 1.0}, "strength_clip": {"type": "number", "default": 1.0}},
|
||||
["model_id"]),
|
||||
_action("place_combo", "Place a combo",
|
||||
"Adds one of the user's combos (list_combos) to the canvas as a new wired group: model loader, LoRAs, "
|
||||
"positive and negative prompt, or a node-structure combo's own nodes and links. Nothing already there "
|
||||
"changes; ports to nodes outside the combo (sampler…) stay open.",
|
||||
place_combo, {"name": {"type": "string", "description": "Combo name from list_combos."}}, ["name"]),
|
||||
_action("check_workflow_models", "Check missing models",
|
||||
"Models the open workflow names that this computer lacks, and which Anomalous can put back (the same "
|
||||
"file by hash, or a likely one).",
|
||||
check_workflow_models, read_only=True),
|
||||
_action("fix_workflow_models", "Fix missing models",
|
||||
"Puts back every missing model found by hash; likely ones are left to the user. One Ctrl+Z step.",
|
||||
fix_workflow_models, destructive=True),
|
||||
_action("download_missing_models", "Download missing models",
|
||||
"Downloads the models the open workflow is missing whose source is certain: Civitai found by the "
|
||||
"workflow's fingerprint, or a file link the workflow carries. A file name alone is never used. Files go "
|
||||
"where the user's download settings say and are checked against the fingerprint before use; finished "
|
||||
"ones are put into their nodes. names limits it to those files. Large files take a while.",
|
||||
download_missing_models, {"names": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT,
|
||||
"description": "Only these model file names (default: all missing)."}},
|
||||
open_world=True),
|
||||
_action("download_model", "Download a model",
|
||||
"Downloads one model into this computer's models folder of `type` (loras, checkpoints, vae, "
|
||||
"diffusion_models, text_encoders, upscale_models…): from url (a Civitai model-version link, or a Hugging "
|
||||
"Face / GitHub link to the file) or by its Civitai hash. folder is a subfolder inside that type's folder "
|
||||
"(default: the user's download settings); name the file name (default: the site's). Never replaces a file.",
|
||||
download_model, {"type": {"type": "string"}, "url": {"type": "string"}, "hash": {"type": "string"},
|
||||
"folder": {"type": "string"}, "name": {"type": "string"}}, ["type"], open_world=True),
|
||||
_action("download_status", "Downloads", "How the downloads of this ComfyUI run are going.", download_status,
|
||||
read_only=True),
|
||||
_action("run_workflow", "Run the workflow",
|
||||
"Queues the workflow (uses the GPU). In the same call: workflow (a saved one) opens in the task's tab "
|
||||
"once and is reused; prompt, negative, seed (every sampler), width/height (the start latent), image "
|
||||
"(its LoadImage) and values (any setting by name: {\"steps\": 30}, or {\"12\": {\"denoise\": 0.5}} when "
|
||||
"several nodes have it) fill it in; the rest stays. Without workflow they fill the open canvas. Then "
|
||||
"run_status, never the output folder.",
|
||||
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1},
|
||||
"wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 55,
|
||||
"description": "Seconds to wait for it to end."},
|
||||
"workflow": {"type": "string", "description": "A name from list_workflows."},
|
||||
"prompt": {"type": "string"}, "negative": {"type": "string"},
|
||||
"seed": {"type": "integer", "minimum": 0},
|
||||
"width": {"type": "integer", "minimum": 16, "maximum": 16384},
|
||||
"height": {"type": "integer", "minimum": 16, "maximum": 16384},
|
||||
"image": {"type": "string", "description": "subfolder/name.png, output or input folder."},
|
||||
"image_node": {"type": "integer", "description": "Which LoadImage, when there are several."},
|
||||
"values": {"type": "object", "description": "Other settings by name, any node's."}}),
|
||||
_action("open_in_anomalous", "Show a model",
|
||||
"Opens Anomalous Model Browser on this model's page, so the user sees its cover and details.",
|
||||
open_in_anomalous, {"model_id": _MODEL}, ["model_id"]),
|
||||
_action("scan_models", "Scan models",
|
||||
"Starts a scan that fetches covers, trigger words and base models from Civitai (by file hash) for "
|
||||
"models not scanned yet (new), looks unmatched ones up again (retry_unmatched), or scans model_ids.",
|
||||
scan_models, {"what": {"type": "string", "enum": ["new", "retry_unmatched", "models"], "default": "new"},
|
||||
"model_ids": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT}},
|
||||
open_world=True),
|
||||
_action("list_voices", "Voices",
|
||||
"The Anomalous TTS characters (GPT-SoVITS voices) with their language and emotions.", list_voices,
|
||||
read_only=True),
|
||||
_action("speak", "Speak a line",
|
||||
"Has an Anomalous TTS character read text and saves the audio. Emotion tags like {happy} switch "
|
||||
"emotion mid-text; waits up to two minutes.",
|
||||
speak, {"character": {"type": "string", "description": "From list_voices."}, "text": {"type": "string"},
|
||||
"seed": {"type": "integer"}, "language": {"type": "string", "default": "auto"}},
|
||||
["character", "text"]),
|
||||
])
|
||||
@@ -0,0 +1,213 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anomalous</title>
|
||||
<!-- Anomalous's card in the AI app's conversation (api/mcp_apps.py). Everything the AI or the task
|
||||
says is drawn with textContent; pictures are inline thumbnails; nothing is loaded from elsewhere. -->
|
||||
<style>
|
||||
:root { --bg: #ffffff; --soft: #f4f4f6; --line: #e2e2e8; --text: #1d1d22; --dim: #6b6b75; --accent: #7c5cdb; --ok: #2f9e6e; --warn: #c98a04; --bad: #d64545; }
|
||||
:root.dark { --bg: #1d1d22; --soft: #26262d; --line: #36363f; --text: #ececf1; --dim: #9a9aa6; --accent: #b39eef; --ok: #52b98d; --warn: #fbbf24; --bad: #f87171; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 14px 16px; background: var(--bg); color: var(--text); font: 13.5px/1.55 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }
|
||||
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.brand { color: var(--accent); font-weight: 600; font-size: 12px; }
|
||||
.title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
||||
.state { padding: 1px 8px; border-radius: 999px; background: var(--soft); color: var(--dim); font-size: 11.5px; }
|
||||
.state.live { color: var(--ok); }
|
||||
.steps { display: flex; flex-direction: column; gap: 4px; margin: 0 0 10px; padding: 0; list-style: none; }
|
||||
.steps li { display: flex; gap: 8px; color: var(--dim); font-size: 12.5px; }
|
||||
.steps li::before { content: "•"; color: var(--ok); }
|
||||
.steps li.running::before { color: var(--accent); }
|
||||
.steps li.asking::before, .steps li.allowed::before { color: var(--warn); }
|
||||
.steps li.bad::before { color: var(--bad); }
|
||||
.steps li span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pictures { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 6px; margin-bottom: 10px; }
|
||||
.pictures img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; background: var(--soft); }
|
||||
.box { margin-bottom: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--soft); }
|
||||
.box.warn { border-color: var(--warn); }
|
||||
.question { margin-bottom: 8px; font-weight: 600; }
|
||||
.options { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
button { padding: 6px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer; }
|
||||
button:hover:not(:disabled) { border-color: var(--accent); }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
button.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
|
||||
input { flex: 1; min-width: 0; padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); font: inherit; }
|
||||
.row { display: flex; gap: 6px; }
|
||||
.foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--dim); font-size: 12px; }
|
||||
.link { padding: 0; border: 0; background: none; color: var(--accent); }
|
||||
.note { margin-bottom: 8px; color: var(--dim); font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="card"><div class="note" id="loading"></div></div>
|
||||
<script>
|
||||
(() => {
|
||||
let zh = /^zh/i.test(navigator.language || '');
|
||||
const words = {
|
||||
loading: ['Reading the task from Anomalous…', '正在从 AMB 读取任务…'],
|
||||
live: ['Working', '进行中'], done: ['Done', '已完成'],
|
||||
asking: ['The AI waits for your OK in Anomalous', 'AI 在等你在 AMB 里点头'],
|
||||
askingHow: ['Allowing is only done in Anomalous\'s AI workbench, so the AI can never allow itself.', '为了安全,允许只能在 AMB 的 AI 工作台里点,AI 自己没法替你点。'],
|
||||
openAmb: ['Open in Anomalous', '在 AMB 里看'], send: ['Send', '发送'], refresh: ['Refresh', '刷新'],
|
||||
picked: ['I picked in the card: ', '我在卡片里选了:'], typed: ['My answer in the card: ', '我在卡片里回答:'],
|
||||
youAnswered: ['You answered: ', '你回答了:'], none: ['No task to show yet.', '还没有任务可看。'],
|
||||
};
|
||||
const t = key => words[key][zh ? 1 : 0];
|
||||
const card = document.getElementById('card');
|
||||
document.getElementById('loading').textContent = t('loading');
|
||||
|
||||
// JSON-RPC with the AI app (MCP Apps): requests wait for their answer by id.
|
||||
let nextId = 1;
|
||||
const waiting = new Map();
|
||||
const send = (message) => window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
|
||||
const request = (method, params) => new Promise((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
waiting.set(id, { resolve, reject });
|
||||
send({ id, method, params });
|
||||
});
|
||||
const notify = (method, params) => send({ method, params });
|
||||
const resize = () => notify('ui/notifications/size-changed', { width: document.body.scrollWidth, height: document.body.scrollHeight });
|
||||
|
||||
let target = {}; // { panel } or { task }: what the card shows
|
||||
let openUrl = '';
|
||||
let polls = 0;
|
||||
let timer = null;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = String(text);
|
||||
return node;
|
||||
}
|
||||
|
||||
async function callTool(name, args) {
|
||||
const result = await request('tools/call', { name, arguments: args });
|
||||
if (result?.isError) throw new Error(result.content?.[0]?.text || 'error');
|
||||
return result?.structuredContent || {};
|
||||
}
|
||||
|
||||
async function answer(question, choice, text, label) {
|
||||
card.querySelectorAll('button, input').forEach((node) => { node.disabled = true; });
|
||||
try {
|
||||
await callTool('app_answer', { panel: question.panel, block: question.block, ...(choice ? { choice: [choice] } : { text }) });
|
||||
// Also said in the conversation, so the AI goes on even when it is not waiting.
|
||||
await request('ui/message', { role: 'user', content: [{ type: 'text', text: (choice ? t('picked') : t('typed')) + label }] }).catch(() => {});
|
||||
} catch (error) {
|
||||
card.append(el('div', 'note', error.message));
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
function questionBox(question) {
|
||||
const box = el('div', 'box');
|
||||
box.append(el('div', 'question', question.question));
|
||||
if (question.type === 'input') {
|
||||
const row = el('div', 'row');
|
||||
const field = el('input');
|
||||
const go = el('button', 'primary', t('send'));
|
||||
go.onclick = () => field.value.trim() && answer(question, null, field.value.trim(), field.value.trim());
|
||||
field.onkeydown = (event) => { if (event.key === 'Enter') go.click(); };
|
||||
row.append(field, go);
|
||||
box.append(row);
|
||||
} else {
|
||||
const options = el('div', 'options');
|
||||
for (const option of question.options) {
|
||||
const pick = el('button', '', option.label);
|
||||
pick.onclick = () => answer(question, option.id, null, option.label);
|
||||
options.append(pick);
|
||||
}
|
||||
box.append(options);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function draw(view) {
|
||||
openUrl = view.open_url || openUrl;
|
||||
const head = el('div', 'head');
|
||||
head.append(el('span', 'brand', 'Anomalous'), el('span', 'title', view.title || ''),
|
||||
el('span', `state${view.ended ? '' : ' live'}`, t(view.ended ? 'done' : 'live')));
|
||||
const steps = el('ul', 'steps');
|
||||
for (const step of view.steps || []) {
|
||||
const line = el('li', `${step.state || ''}${step.ok === false ? ' bad' : ''}`);
|
||||
line.append(el('span', '', step.text || step.tool));
|
||||
steps.append(line);
|
||||
}
|
||||
const parts = [head, steps];
|
||||
if (view.pictures?.length) {
|
||||
const pictures = el('div', 'pictures');
|
||||
for (const picture of view.pictures) {
|
||||
const image = el('img');
|
||||
image.alt = picture.name;
|
||||
image.title = picture.name;
|
||||
image.src = picture.src; // a data: thumbnail from Anomalous
|
||||
image.onload = resize;
|
||||
pictures.append(image);
|
||||
}
|
||||
parts.push(pictures);
|
||||
}
|
||||
for (const item of view.answered || []) parts.push(el('div', 'note', `${t('youAnswered')}${item.question ? `${item.question} ` : ''}${item.answer}`));
|
||||
for (const question of view.questions || []) parts.push(questionBox(question));
|
||||
if (view.asking?.length) {
|
||||
const box = el('div', 'box warn');
|
||||
box.append(el('div', 'question', t('asking')), el('div', 'note', t('askingHow')));
|
||||
parts.push(box);
|
||||
}
|
||||
const foot = el('div', 'foot');
|
||||
const open = el('button', 'link', t('openAmb'));
|
||||
open.onclick = () => openUrl && request('ui/open-link', { url: openUrl }).catch(() => {});
|
||||
const again = el('button', 'link', t('refresh'));
|
||||
again.onclick = () => { polls = 0; load(); };
|
||||
foot.append(open, again);
|
||||
parts.push(foot);
|
||||
card.replaceChildren(...parts);
|
||||
resize();
|
||||
// Follows the task for a few minutes while it goes on; Refresh starts again.
|
||||
clearTimeout(timer);
|
||||
if (!view.ended && polls++ < 36) timer = setTimeout(load, 5000);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
draw(await callTool('app_task_view', target));
|
||||
} catch (_) {
|
||||
card.replaceChildren(el('div', 'note', t('none')));
|
||||
resize();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window.parent) return;
|
||||
const message = event.data;
|
||||
if (!message || message.jsonrpc !== '2.0') return;
|
||||
if (message.id !== undefined && waiting.has(message.id)) {
|
||||
const { resolve, reject } = waiting.get(message.id);
|
||||
waiting.delete(message.id);
|
||||
if (message.error) reject(new Error(message.error.message));
|
||||
else resolve(message.result);
|
||||
return;
|
||||
}
|
||||
if (message.method === 'ui/notifications/tool-result') {
|
||||
const data = message.params?.structuredContent || {};
|
||||
target = data.panel ? { panel: String(data.panel) } : data.task ? { task: String(data.task) } : {};
|
||||
polls = 0;
|
||||
load();
|
||||
} else if (message.method === 'ui/notifications/host-context-changed') {
|
||||
document.documentElement.classList.toggle('dark', message.params?.theme === 'dark');
|
||||
}
|
||||
});
|
||||
|
||||
request('ui/initialize', { appInfo: { name: 'anomalous-task-card', version: '1.0.0' }, appCapabilities: {}, protocolVersion: '2026-01-26' })
|
||||
.then((result) => {
|
||||
const context = result?.hostContext || {};
|
||||
if (context.locale) zh = /^zh/i.test(context.locale);
|
||||
document.documentElement.classList.toggle('dark', context.theme === 'dark');
|
||||
notify('ui/notifications/initialized', {});
|
||||
resize();
|
||||
})
|
||||
.catch(() => {});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
"""Anomalous's card inside the AI app's conversation (the MCP Apps extension,
|
||||
io.modelcontextprotocol/ui): AI apps that support it (Claude, ChatGPT, VS Code…) show the page
|
||||
mcp_app/task_card.html under the results of the tools in CARD_TOOLS, in a sandboxed frame of
|
||||
theirs. It is Anomalous's own page, never anything an AI wrote; the AI's data reaches it only as
|
||||
text. The card reads the task through app_task_view and answers the AI's questions through
|
||||
app_answer, two tools for the card only (visibility "app": apps that follow the extension keep
|
||||
them from the AI; an AI that calls them anyway only reads its own task or answers its own
|
||||
question). Their results go to the card, not the AI. The card never answers a gate:
|
||||
an OK for what an AI may do is only given in Anomalous (ai_gates.py), so a tool the AI could
|
||||
reach can never grant it. The pictures it shows are the gallery's thumbnails, inline (the frame
|
||||
loads nothing from this server).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import ai_panels, ai_tasks
|
||||
from .mcp_tools import ToolError, _image_content, _resolve_image, _schema
|
||||
|
||||
EXTENSION = "io.modelcontextprotocol/ui"
|
||||
MIME = "text/html;profile=mcp-app"
|
||||
CARD_URI = "ui://anomalous/task-card.html"
|
||||
CARD_TOOLS = ("show_panel", "ask_user", "report", "run_workflow", "begin_task")
|
||||
MAX_PICTURES = 6
|
||||
MAX_STEPS = 8
|
||||
HTML = os.path.join(os.path.dirname(__file__), "mcp_app", "task_card.html")
|
||||
|
||||
|
||||
def resources():
|
||||
return [{"uri": CARD_URI, "name": "task-card", "title": "Anomalous task card", "mimeType": MIME,
|
||||
"description": "What the AI is doing in Anomalous: its steps, pictures and questions."}]
|
||||
|
||||
|
||||
def read(uri):
|
||||
"""resources/read for the card, or None for another address."""
|
||||
if uri != CARD_URI:
|
||||
return None
|
||||
with open(HTML, encoding="utf-8") as source:
|
||||
html = source.read()
|
||||
return {"contents": [{"uri": CARD_URI, "mimeType": MIME, "text": html, "_meta": {"ui": {"prefersBorder": True}}}]}
|
||||
|
||||
|
||||
def with_card(spec):
|
||||
"""A tool's spec naming the card that shows its results (and the older flat key some apps read)."""
|
||||
if spec["name"] not in CARD_TOOLS:
|
||||
return spec
|
||||
return {**spec, "_meta": {**spec.get("_meta", {}), "ui": {"resourceUri": CARD_URI}, "ui/resourceUri": CARD_URI}}
|
||||
|
||||
|
||||
def _picture(result):
|
||||
rel = "/".join(part for part in (result.get("subfolder"), result.get("filename")) if part)
|
||||
try:
|
||||
_, path = _resolve_image(rel)
|
||||
except ToolError:
|
||||
return None
|
||||
item = _image_content(path)
|
||||
return {"name": result["filename"], "src": f"data:{item['mimeType']};base64,{item['data']}"} if item else None
|
||||
|
||||
|
||||
def _words(step):
|
||||
"""A line for a step: its note or panel title, else what it was about (its summary without the tool)."""
|
||||
summary = step.get("summary", "")
|
||||
about = summary.split(": ", 1)[1] if summary.startswith(f"{step['tool']}: ") else summary
|
||||
return ai_tasks._short(step.get("text") or (step.get("panel") or {}).get("title") or about or step["tool"], 120)
|
||||
|
||||
|
||||
def _answered(step):
|
||||
"""What the user already answered on a panel step: [{question, answer}]."""
|
||||
blocks = {block["id"]: block for block in step["panel"]["blocks"]}
|
||||
return [{"question": blocks.get(block, {}).get("question", ""), "answer": "(left to the AI)" if given.get("skipped") else "、".join(given.get("labels") or [given.get("text", "")])}
|
||||
for block, given in step.get("answers", {}).items()]
|
||||
|
||||
|
||||
def _questions(step):
|
||||
"""The open questions of a panel step, as the card draws them (choices and picks as buttons, input as a line)."""
|
||||
panel = step["panel"]
|
||||
out = []
|
||||
for block in panel["blocks"]:
|
||||
if block["type"] not in ai_panels.INTERACTIVE or block["id"] in step.get("answers", {}) or panel.get("closed"):
|
||||
continue
|
||||
options = block.get("options") or [{"id": item["id"], "label": item.get("label") or item["id"]} for item in block.get("items", [])]
|
||||
out.append({"panel": panel["id"], "block": block["id"], "type": block["type"], "question": block.get("question", ""),
|
||||
"options": [{"id": option["id"], "label": option.get("label") or option["id"]} for option in options]})
|
||||
return out
|
||||
|
||||
|
||||
def task_view(ctx, panel=None, task=None):
|
||||
"""The card's view of a task: the panel's task, task `task`, or the calling app's active one."""
|
||||
with ai_tasks._lock:
|
||||
found = ai_panels._find(str(panel))[0] if panel else None
|
||||
task_id = found["id"] if found else task or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
|
||||
record = ai_tasks.get_task(task_id) if task_id else None
|
||||
if not record:
|
||||
raise ToolError("No task to show.")
|
||||
steps = [step for step in record["steps"] if step.get("kind") != "look"]
|
||||
pictures = [result for step in record["steps"] for result in step.get("results") or [] if result.get("type") == "image"]
|
||||
seen, shown = set(), []
|
||||
for result in reversed(pictures):
|
||||
key = (result.get("subfolder"), result.get("filename"))
|
||||
if key in seen or len(shown) >= MAX_PICTURES:
|
||||
continue
|
||||
seen.add(key)
|
||||
picture = _picture(result)
|
||||
if picture:
|
||||
shown.append(picture)
|
||||
return {
|
||||
"task": record["id"], "title": record.get("title") or "", "ended": bool(record.get("ended")),
|
||||
"steps": [{"tool": step["tool"], "kind": step["kind"], "state": step.get("state", "done"), "ok": step.get("ok", True),
|
||||
"stage": step.get("stage"), "text": _words(step)}
|
||||
for step in steps[-MAX_STEPS:]],
|
||||
"pictures": list(reversed(shown)),
|
||||
"questions": [question for step in record["steps"] if step.get("kind") == "panel" for question in _questions(step)],
|
||||
"answered": [item for step in record["steps"] if step.get("kind") == "panel" for item in _answered(step)][-3:],
|
||||
"asking": [step.get("gate", {}).get("gate") for step in record["steps"] if step.get("state") == "asking"],
|
||||
"open_url": f"{ctx.get('base_url', '')}/",
|
||||
}
|
||||
|
||||
|
||||
def answer(ctx, panel, block, choice=None, text=None):
|
||||
"""The user's answer from the card to one of the AI's questions (never a gate)."""
|
||||
task, step = None, None
|
||||
with ai_tasks._lock:
|
||||
task, step = ai_panels._find(str(panel))
|
||||
if not step:
|
||||
raise ToolError("No such panel.")
|
||||
try:
|
||||
ai_panels.answer(task["id"], step["id"], str(block), choice, text)
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
return {"answered": True}
|
||||
|
||||
|
||||
def _app_tool(name, title, description, run, properties, required=()):
|
||||
return name, {"run": run, "context": True, "spec": {
|
||||
"name": name, "title": title, "description": description, "inputSchema": _schema(properties, required),
|
||||
"annotations": {"title": title, "readOnlyHint": name == "app_task_view"},
|
||||
"_meta": {"ui": {"resourceUri": CARD_URI, "visibility": ["app"]}},
|
||||
}}
|
||||
|
||||
|
||||
APP_TOOLS = dict([
|
||||
_app_tool("app_task_view", "Card: the task", "Only for Anomalous's own card in the conversation; never call it.",
|
||||
task_view, {"panel": {"type": "string"}, "task": {"type": "string"}}),
|
||||
_app_tool("app_answer", "Card: answer", "Only for Anomalous's own card, when the user answers there; never call it.",
|
||||
answer, {"panel": {"type": "string"}, "block": {"type": "string"}, "choice": {"type": "array", "items": {"type": "string"}},
|
||||
"text": {"type": "string"}}, ["panel", "block"]),
|
||||
])
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Normalize an unambiguous argument name before approval, execution and task recording.
|
||||
|
||||
Only a known other name counts (ALIASES, names light models were seen to write in the tool exams,
|
||||
docs/architecture/tool-exam.md): a value of the right type under any other name may mean something
|
||||
else (set_prompt {style: "anime"} is not a prompt), and that call is answered with what the tool takes.
|
||||
"""
|
||||
|
||||
ALIASES = { # needed argument -> other names that mean it
|
||||
"workflow": {"name", "workflow_name"},
|
||||
"image": {"file", "filename", "image_name"},
|
||||
"prompt": {"text", "positive"},
|
||||
"model_id": {"model", "id"},
|
||||
"node_id": {"node", "id"},
|
||||
}
|
||||
JSON_TYPES = {"string": (str,), "integer": (int,), "number": (int, float), "boolean": (bool,), "array": (list,), "object": (dict,)}
|
||||
|
||||
|
||||
def prepare_arguments(spec, arguments):
|
||||
"""Return (arguments, (wrong, right) or None), without changing the caller's dictionary."""
|
||||
unknown = set(arguments) - set(spec["properties"])
|
||||
missing = [key for key in spec.get("required", []) if key not in arguments]
|
||||
if len(unknown) != 1 or len(missing) != 1:
|
||||
return arguments, None
|
||||
wrong, right = next(iter(unknown)), missing[0]
|
||||
if wrong not in ALIASES.get(right, ()):
|
||||
return arguments, None
|
||||
kinds = JSON_TYPES.get(spec["properties"][right].get("type"))
|
||||
value = arguments[wrong]
|
||||
if not kinds or not isinstance(value, kinds) or (isinstance(value, bool) and bool not in kinds):
|
||||
return arguments, None
|
||||
corrected = {key: value for key, value in arguments.items() if key != wrong}
|
||||
corrected[right] = value
|
||||
return corrected, (wrong, right)
|
||||
|
||||
|
||||
def renaming_note(name, renamed):
|
||||
return f"Took {renamed[0]} as {renamed[1]}: {name} calls it {renamed[1]}."
|
||||
@@ -0,0 +1,90 @@
|
||||
"""The way from an MCP tool to the open ComfyUI page, for what only the page can do (the
|
||||
canvas lives in the browser).
|
||||
|
||||
`ask_page` sends {id, action, args} as the "anomalous.mcp" event over ComfyUI's websocket
|
||||
to every open ComfyUI page and waits. One page claims it (the first claim wins, visible
|
||||
pages claim first: web/modules/mcp_bridge.js), does the action with Anomalous's own canvas
|
||||
code and posts the outcome back; `claim` and `resolve` take those posts
|
||||
(routes in mcp_server.py). With no page open, or none answering in time, the tool fails
|
||||
with words the AI can pass on. Actions on the canvas carry the task's own tab
|
||||
(ai_task_canvas.py), and the tab the page made or opened for it is kept.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
TIMEOUT_SECONDS = 30
|
||||
MAX_WAITING = 20
|
||||
|
||||
_lock = threading.Lock()
|
||||
_waiting = {} # id -> {"event": Event, "claimed": bool, "outcome": dict | None}
|
||||
|
||||
|
||||
class PageUnavailable(Exception):
|
||||
"""No ComfyUI page took or finished the action."""
|
||||
|
||||
|
||||
class PageRefused(Exception):
|
||||
"""The page tried and said why it could not (missing node, wrong model type…)."""
|
||||
|
||||
|
||||
def _server():
|
||||
from server import PromptServer # ComfyUI's; imported late so tests can run without it
|
||||
return PromptServer.instance
|
||||
|
||||
|
||||
def ask_page(action, args=None, timeout=TIMEOUT_SECONDS, required_capability=None):
|
||||
"""Runs `action` in the open ComfyUI page; its result value. Call from a worker thread."""
|
||||
server = _server()
|
||||
if not getattr(server, "sockets", None):
|
||||
raise PageUnavailable("ComfyUI is not open in a browser on this computer. Open it, then try again.")
|
||||
from . import ai_task_canvas
|
||||
|
||||
task_canvas = ai_task_canvas.spec(action)
|
||||
if task_canvas:
|
||||
args = {**(args or {}), "_task_canvas": task_canvas}
|
||||
request_id = uuid.uuid4().hex
|
||||
entry = {"event": threading.Event(), "claimed": False, "outcome": None, "required": required_capability}
|
||||
with _lock:
|
||||
if len(_waiting) >= MAX_WAITING:
|
||||
raise PageUnavailable("Too many canvas actions are waiting; try again in a moment.")
|
||||
_waiting[request_id] = entry
|
||||
try:
|
||||
server.send_sync("anomalous.mcp", {"id": request_id, "action": action, "args": args or {}})
|
||||
if not entry["event"].wait(timeout):
|
||||
raise PageUnavailable("The ComfyUI page did not answer in time. Is it open and not frozen? "
|
||||
"If the action was taken late, check the canvas before repeating it.")
|
||||
finally:
|
||||
with _lock:
|
||||
_waiting.pop(request_id, None)
|
||||
outcome = entry["outcome"] or {}
|
||||
if task_canvas and outcome.get("tab"):
|
||||
ai_task_canvas.remember(outcome["tab"])
|
||||
if not outcome.get("ok"):
|
||||
raise PageRefused(str(outcome.get("error") or "The page could not do that."))
|
||||
return outcome.get("value")
|
||||
|
||||
|
||||
def claim(request_id, capabilities=None):
|
||||
"""True for the first page that asks to do request `request_id`."""
|
||||
with _lock:
|
||||
entry = _waiting.get(request_id)
|
||||
if entry is None or entry["claimed"] or entry["outcome"] is not None:
|
||||
return False
|
||||
if entry.get("required") and (not isinstance(capabilities, list) or entry["required"] not in capabilities):
|
||||
entry["outcome"] = {"ok": False, "error": "Refresh the ComfyUI browser page before running from AI. Nothing was queued."}
|
||||
entry["event"].set()
|
||||
return False
|
||||
entry["claimed"] = True
|
||||
return True
|
||||
|
||||
|
||||
def resolve(request_id, outcome):
|
||||
"""The claiming page's outcome: {ok, value} or {ok: false, error}. False when nobody waits."""
|
||||
with _lock:
|
||||
entry = _waiting.get(request_id)
|
||||
if entry is None or not entry["claimed"] or entry["outcome"] is not None:
|
||||
return False
|
||||
entry["outcome"] = outcome if isinstance(outcome, dict) else {"ok": False, "error": "Malformed answer"}
|
||||
entry["event"].set()
|
||||
return True
|
||||
@@ -0,0 +1,314 @@
|
||||
"""The MCP tools that build a workflow on the canvas the user sees: add_node, connect_nodes,
|
||||
set_values, remove_nodes and new_workflow (a new tab, blank or built from a workflow in
|
||||
ComfyUI's API form) run in the open page like mcp_actions.py's canvas tools
|
||||
(web/modules/mcp_canvas_edit.js: each one Ctrl+Z step, in the activity log as the AI's, so the
|
||||
AI workbench shows what it changed), and find_nodes reads the node types this ComfyUI has
|
||||
from its own /object_info. Arguments are checked here for shape and size; the page checks
|
||||
them against the nodes themselves before anything changes."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import ai_tasks, ai_toolbox, mcp_fill
|
||||
from .mcp_actions import _action, _local, _page
|
||||
from .mcp_tools import ToolError
|
||||
|
||||
MAX_FOUND = 20
|
||||
MAX_SEARCH = 5
|
||||
MAX_CHOICES_NAMED = 60
|
||||
MAX_REMOVE = 30
|
||||
MAX_WORKFLOW_NODES = 200
|
||||
MAX_WORKFLOW_BYTES = 1_000_000
|
||||
INFO_SECONDS = 120
|
||||
|
||||
_info = {"at": 0.0, "data": None}
|
||||
_info_lock = threading.Lock()
|
||||
|
||||
|
||||
def object_info(ctx):
|
||||
"""ComfyUI's node definitions, as its own page reads them (kept a little while)."""
|
||||
with _info_lock:
|
||||
if _info["data"] is None or time.monotonic() - _info["at"] > INFO_SECONDS:
|
||||
status, data = _local(ctx, "GET", "/object_info", timeout=60)
|
||||
if status != 200 or not isinstance(data, dict):
|
||||
raise ToolError(f"ComfyUI's node list is unavailable ({status}).")
|
||||
_info.update(at=time.monotonic(), data=data)
|
||||
return _info["data"]
|
||||
|
||||
|
||||
def forget_object_info():
|
||||
"""After a model moved: the next look reads ComfyUI's lists anew."""
|
||||
with _info_lock:
|
||||
_info["data"] = None
|
||||
|
||||
|
||||
def _input(name, conf, required, choices):
|
||||
kind, options = (conf + [None, None])[:2] if isinstance(conf, list) else (conf, None)
|
||||
options = options if isinstance(options, dict) else {}
|
||||
out = {"name": name, "required": required}
|
||||
listed = kind if isinstance(kind, list) else options.get("options") if kind == "COMBO" else None
|
||||
if isinstance(listed, list):
|
||||
out["type"] = "choice"
|
||||
out["choices"] = [str(item) for item in listed[:choices]]
|
||||
if len(listed) > choices:
|
||||
out["choices_count"] = len(listed)
|
||||
else:
|
||||
out["type"] = str(kind)
|
||||
for key in ("default", "min", "max", "multiline"):
|
||||
if key in options and isinstance(options[key], (str, int, float, bool)):
|
||||
out[key] = options[key] if not isinstance(options[key], str) else options[key][:200]
|
||||
if options.get("forceInput"):
|
||||
out["socket_only"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _picked(ctx):
|
||||
"""What the user picked for this task (ai_toolbox.py): its node types and packs come first in every search."""
|
||||
return ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) if ctx.get("task") else None
|
||||
|
||||
|
||||
def _allowed(ctx, types):
|
||||
"""Refuses node types the user's "only" picks leave out, before the page is asked."""
|
||||
if not ctx.get("task"):
|
||||
return
|
||||
info = object_info(ctx)
|
||||
refused = ai_toolbox.refused(ctx["task"], types, info)
|
||||
if refused:
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) or {}
|
||||
allowed = [*(box.get("nodes") or []), *(f"every node of the pack {pack}" for pack in box.get("packs") or [])]
|
||||
raise ToolError(f"For this task the user allowed only these node types (and ComfyUI's built-in ones): "
|
||||
f"{', '.join(allowed) or 'none'}. Not allowed: {', '.join(refused)}. "
|
||||
"If you need one, ask the user in the AI workbench (show_panel) instead.")
|
||||
|
||||
|
||||
def _node(type_name, info, choices):
|
||||
inputs = []
|
||||
for section, required in (("required", True), ("optional", False)):
|
||||
for name, conf in ((info.get("input") or {}).get(section) or {}).items():
|
||||
inputs.append(_input(name, conf, required, choices))
|
||||
names = info.get("output_name") or info.get("output") or []
|
||||
outputs = [{"slot": slot, "name": str(name), "type": str(kind)}
|
||||
for slot, (name, kind) in enumerate(zip(names, info.get("output") or []))]
|
||||
source = ai_toolbox.source_of(info)
|
||||
return {"type": type_name, "display_name": info.get("display_name") or type_name, "category": info.get("category") or "",
|
||||
"source": source, "description": str(info.get("description") or "")[:300], "inputs": inputs, "outputs": outputs,
|
||||
**({"paid": True} if source == "api" else {}), **({"deprecated": True} if info.get("deprecated") else {})}
|
||||
|
||||
|
||||
def find_nodes(ctx, query="", types=None):
|
||||
info = object_info(ctx)
|
||||
if types:
|
||||
missing = [name for name in types if name not in info]
|
||||
found = [_node(name, info[name], MAX_CHOICES_NAMED) for name in types if name in info][:MAX_FOUND]
|
||||
return {"nodes": found, **({"not_found": missing} if missing else {})}
|
||||
words = str(query or "").lower().split()
|
||||
if not words:
|
||||
raise ToolError("Give query (words in a node's name, category or description) or types.")
|
||||
picked = _picked(ctx)
|
||||
scored = []
|
||||
for name, item in info.items():
|
||||
haystack = " ".join(str(part or "") for part in (name, item.get("display_name"), item.get("category"), item.get("description"))).lower()
|
||||
if all(word in haystack for word in words):
|
||||
label = f"{name} {item.get('display_name') or ''}".lower()
|
||||
scored.append((not ai_toolbox.chosen(picked, name, item), 0 if name.lower() == " ".join(words) else 1 if all(w in label for w in words) else 2,
|
||||
bool(item.get("deprecated")), ai_toolbox.source_of(item) == "api", len(name), name))
|
||||
scored.sort()
|
||||
# Searching identifies candidates; don't repeat hundreds of model choices/settings for each.
|
||||
nodes = []
|
||||
for *_rest, name in scored[:MAX_SEARCH]:
|
||||
detail = _node(name, info[name], 0)
|
||||
nodes.append({"type": name, "display_name": str(detail["display_name"])[:80],
|
||||
"source": str(detail["source"])[:80],
|
||||
"description": detail["description"][:96],
|
||||
"inputs": [f"{i['name'][:48]}:{i['type'][:48]}" for i in detail["inputs"][:8]],
|
||||
"outputs": [f"{i['name'][:48]}:{i['type'][:48]}" for i in detail["outputs"][:8]],
|
||||
**{k: detail[k] for k in ("paid", "deprecated") if k in detail},
|
||||
**({"more_inputs": len(detail["inputs"]) - 8} if len(detail["inputs"]) > 8 else {})})
|
||||
for node in nodes:
|
||||
if ai_toolbox.chosen(picked, node["type"], info[node["type"]]):
|
||||
node["picked_by_user"] = True
|
||||
while len(nodes) > 1 and len(json.dumps(nodes, ensure_ascii=False)) > 3000:
|
||||
nodes.pop()
|
||||
return {"nodes": nodes, "total_matches": len(scored),
|
||||
"note": "Brief candidates; types=[chosen type] gives settings/choices and output slots when needed. "
|
||||
"Narrow query for other matches. picked_by_user: preferred; paid: spends credits, use only when asked."}
|
||||
|
||||
|
||||
def _id(value, what):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, str)) or not str(value).strip() or len(str(value)) > 40:
|
||||
raise ToolError(f"{what} is a node id from describe_canvas or a tool's answer.")
|
||||
return value
|
||||
|
||||
|
||||
def _type(value):
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > 200:
|
||||
raise ToolError("type is a node type, as find_nodes gives it (e.g. KSampler).")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _next(answer):
|
||||
"""The checklist's next step after a change (the page answers with what is still unwired)."""
|
||||
if isinstance(answer, dict):
|
||||
answer["next"] = ("Connect the new branch to an output; it does not reach one yet."
|
||||
if answer.get("reaches_output") is False else
|
||||
"Wire unwired inputs with connect_nodes; unique types need only the two node IDs."
|
||||
if answer.get("unwired") else "No required input is unwired; run_workflow validates and runs the graph.")
|
||||
return answer
|
||||
|
||||
|
||||
def _ids(value, label):
|
||||
if value is None:
|
||||
return []
|
||||
items = value if isinstance(value, list) else [value]
|
||||
if not 1 <= len(items) <= 8:
|
||||
raise ToolError(f"{label} names 1 to 8 node IDs.")
|
||||
return [_id(item, label) for item in items]
|
||||
|
||||
|
||||
def add_node(ctx, type, values=None, title="", near_node_id=None, insert_after=None, insert_type=None,
|
||||
wire_from=None, wire_to=None, replace=False):
|
||||
if not isinstance(replace, bool):
|
||||
raise ToolError("replace must be true or false.")
|
||||
if insert_type is not None and (insert_after is None or not isinstance(insert_type, str) or not 0 < len(insert_type) <= 60):
|
||||
raise ToolError("insert_type names a through type (e.g. IMAGE) and needs insert_after.")
|
||||
wiring = {"wire_from": _ids(wire_from, "wire_from"), "wire_to": _ids(wire_to, "wire_to"), "replace": replace}
|
||||
if insert_after is not None:
|
||||
wiring.update(insert_after=_id(insert_after, "insert_after"), insert_type=insert_type)
|
||||
_allowed(ctx, [_type(type)])
|
||||
return _next(_page("add_node", {"type": _type(type), "values": mcp_fill.flat_values(values), "title": str(title or "")[:120],
|
||||
"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id"), **wiring}))
|
||||
|
||||
|
||||
def _api_nodes(nodes, canvas_refs=False):
|
||||
"""A workflow (or group) in ComfyUI's API form, checked for shape and size; the page checks the rest.
|
||||
With canvas_refs a link may name a canvas node as "#12", an output by slot or name, and a node wire_to."""
|
||||
if not isinstance(nodes, dict) or not 0 < len(nodes) <= MAX_WORKFLOW_NODES:
|
||||
raise ToolError(f"nodes is a workflow in ComfyUI's API form: {{id: {{class_type, inputs}}}}, up to {MAX_WORKFLOW_NODES} nodes.")
|
||||
if len(json.dumps(nodes, ensure_ascii=False)) > MAX_WORKFLOW_BYTES:
|
||||
raise ToolError("That workflow is too large.")
|
||||
for key, spec in nodes.items():
|
||||
if (len(key) > 40 or not isinstance(spec, dict) or not isinstance(spec.get("class_type"), str)
|
||||
or not isinstance(spec.get("inputs", {}), dict)):
|
||||
raise ToolError(f"Node {key[:40]}: each node is {{class_type, inputs: {{name: value or [node id, output slot]}}}}.")
|
||||
_type(spec["class_type"])
|
||||
mcp_fill.flat_values({name: value for name, value in spec.get("inputs", {}).items() if not isinstance(value, list)})
|
||||
if not canvas_refs:
|
||||
continue
|
||||
if key.startswith("#"):
|
||||
raise ToolError(f"Node {key[:40]}: a key of the group cannot start with #, which names canvas nodes.")
|
||||
for name, value in spec.get("inputs", {}).items():
|
||||
if isinstance(value, list) and (len(value) != 2 or not isinstance(value[0], (str, int)) or isinstance(value[1], bool)
|
||||
or not isinstance(value[1], (str, int)) or len(str(value[1])) > 100):
|
||||
raise ToolError(f"Node {key[:40]}, input {name[:100]}: a link is [a key of this group or \"#<canvas node id>\", output slot or name].")
|
||||
if isinstance(value, list) and isinstance(value[1], str) and value[1].strip().isdigit():
|
||||
value[1] = int(value[1])
|
||||
if spec.get("wire_to") is not None:
|
||||
spec["wire_to"] = _ids(spec["wire_to"], f"{key[:40]}.wire_to")
|
||||
return nodes
|
||||
|
||||
|
||||
def add_nodes(ctx, nodes, near_node_id=None, replace=False):
|
||||
if not isinstance(replace, bool):
|
||||
raise ToolError("replace must be true or false.")
|
||||
_api_nodes(nodes, canvas_refs=True)
|
||||
_allowed(ctx, [spec["class_type"] for spec in nodes.values()])
|
||||
return _next(_page("add_nodes", {"nodes": nodes, "replace": replace,
|
||||
"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id")}, timeout=60))
|
||||
|
||||
|
||||
def connect_nodes(ctx, from_node_id, to_node_id, input=None, output=None, replace=None):
|
||||
if output is not None and (isinstance(output, bool) or not isinstance(output, (int, str)) or len(str(output)) > 100):
|
||||
raise ToolError("output is the output's slot number or its name.")
|
||||
output = int(output) if isinstance(output, str) and output.strip().isdigit() else output
|
||||
if input is not None and (not isinstance(input, str) or not input or len(input) > 100):
|
||||
raise ToolError("input is the input's name, as add_node, find_nodes or describe_canvas give it.")
|
||||
if replace is not None and not isinstance(replace, bool):
|
||||
raise ToolError("replace must be true or false.")
|
||||
# Preserve the old explicit-input form's default output and replacement behaviour.
|
||||
if input is not None and output is None:
|
||||
output = 0
|
||||
return _next(_page("connect_nodes", {"from_node_id": _id(from_node_id, "from_node_id"), "output": output,
|
||||
"to_node_id": _id(to_node_id, "to_node_id"), "input": input, "replace": replace}))
|
||||
|
||||
|
||||
def set_values(ctx, node_id=None, values=None):
|
||||
if node_id is None:
|
||||
return _page("set_values", {"node_id": None, "values": mcp_fill.node_values(values)})
|
||||
if not values:
|
||||
raise ToolError("values is an object {setting name: value}.")
|
||||
return _page("set_values", {"node_id": _id(node_id, "node_id"), "values": mcp_fill.flat_values(values)})
|
||||
|
||||
|
||||
def remove_nodes(ctx, node_ids):
|
||||
if not isinstance(node_ids, list) or not 0 < len(node_ids) <= MAX_REMOVE:
|
||||
raise ToolError(f"node_ids lists 1 to {MAX_REMOVE} node ids.")
|
||||
return _next(_page("remove_nodes", {"node_ids": [_id(item, "node_ids") for item in node_ids]}))
|
||||
|
||||
|
||||
def new_workflow(ctx, name="", nodes=None):
|
||||
if nodes is not None:
|
||||
_api_nodes(nodes)
|
||||
_allowed(ctx, [spec["class_type"] for spec in nodes.values()])
|
||||
return _page("new_workflow", {"name": str(name or "")[:60], "nodes": nodes}, timeout=60)
|
||||
|
||||
|
||||
def open_workflow(ctx, workflow):
|
||||
filename, name = mcp_fill.saved_workflow(workflow)
|
||||
return _page("open_workflow", {"filename": filename, "name": name}, timeout=60)
|
||||
|
||||
|
||||
_NODE_ID = {"type": "integer", "description": "A node id."}
|
||||
_VALUES = {"type": "object", "description": "{setting: value}; a choice exactly as listed."}
|
||||
_NEIGHBOURS = {"oneOf": [_NODE_ID, {"type": "array", "items": _NODE_ID, "minItems": 1, "maxItems": 8}]}
|
||||
|
||||
CANVAS_TOOLS = dict([
|
||||
_action("find_nodes", "Look up node types",
|
||||
"Node types, built in and custom. query: up to 5 brief candidates; types=[...]: their inputs, "
|
||||
"choices, ranges and outputs.",
|
||||
find_nodes, {"query": {"type": "string"}, "types": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_FOUND}},
|
||||
read_only=True),
|
||||
_action("add_node", "Add a node",
|
||||
"Adds a node, wired in the same call: wire_from feeds it, wire_to takes its output (an input already "
|
||||
"fed is spliced, its old source now feeding the new node: a ControlNet or LoRA before a KSampler); "
|
||||
"insert_after=ID splices it behind a node. Same type or same socket name wires itself; anything "
|
||||
"ambiguous changes nothing and lists the choices; replace:true replaces what it cannot splice. One "
|
||||
"Ctrl+Z step. Several nodes wired to each other (an inpaint or upscale branch): add_nodes, in one call.",
|
||||
add_node, {"type": {"type": "string", "description": "A node type from find_nodes, e.g. KSampler."},
|
||||
"values": _VALUES, "title": {"type": "string"}, "near_node_id": _NODE_ID,
|
||||
"insert_after": _NODE_ID, "insert_type": {"type": "string", "description": "Through type, only if asked (e.g. IMAGE)."},
|
||||
"wire_from": _NEIGHBOURS, "wire_to": _NEIGHBOURS, "replace": {"type": "boolean"}}, ["type"]),
|
||||
_action("add_nodes", "Add a group of nodes",
|
||||
"Adds a group of nodes in one call, as one block, in ComfyUI's API form: {\"a\": {\"class_type\": "
|
||||
"\"VAEEncode\", \"inputs\": {\"pixels\": [\"#12\", 0], \"vae\": [\"#4\", \"VAE\"]}}, \"b\": {..., \"inputs\": "
|
||||
"{\"samples\": [\"a\", 0]}, \"wire_to\": 15}}. A link names a key of the group or a canvas node \"#id\", and "
|
||||
"an output slot or name; wire_to as in add_node. Checked first; one Ctrl+Z step; the answer maps keys "
|
||||
"to node ids.",
|
||||
add_nodes, {"nodes": {"type": "object"}, "near_node_id": _NODE_ID, "replace": {"type": "boolean"}}, ["nodes"]),
|
||||
_action("connect_nodes", "Wire two nodes",
|
||||
"Wires two nodes: every unique matching type, and same-type sockets of the same name (positive to "
|
||||
"positive). Anything ambiguous (a lone CONDITIONING into positive or negative, wildcards) changes "
|
||||
"nothing and lists the choices; then give output and input. replace:true lets it replace wires. One "
|
||||
"Ctrl+Z step.",
|
||||
connect_nodes, {"from_node_id": _NODE_ID, "output": {"type": "string", "description": "Optional output slot number or name."},
|
||||
"to_node_id": _NODE_ID, "input": {"type": "string"}, "replace": {"type": "boolean"}},
|
||||
["from_node_id", "to_node_id"]),
|
||||
_action("set_values", "Change a node's settings",
|
||||
"Changes settings of any node, a third party's too, checked against what each takes: {setting: value} "
|
||||
"goes to the one node that has it; name the node when several do: {\"12\": {\"denoise\": 0.5}}; or "
|
||||
"node_id with its settings. Prompts: set_prompt; models: set_model. One Ctrl+Z step.",
|
||||
set_values, {"node_id": _NODE_ID, "values": _VALUES}, ["values"], destructive=True),
|
||||
_action("remove_nodes", "Remove nodes",
|
||||
"Removes nodes from the open workflow, with their wires. One Ctrl+Z step.",
|
||||
remove_nodes, {"node_ids": {"type": "array", "items": _NODE_ID, "maxItems": MAX_REMOVE}}, ["node_ids"],
|
||||
destructive=True),
|
||||
_action("new_workflow", "Start a new workflow",
|
||||
"Opens a new workflow tab, the user's open one untouched: blank, or built from nodes in ComfyUI's API "
|
||||
"form ({\"1\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"...\", \"clip\": [\"2\", 1]}}}: a value "
|
||||
"or [node id, output slot]). Checked before it opens; the answer maps your ids to node ids.",
|
||||
new_workflow, {"name": {"type": "string"}, "nodes": {"type": "object"}}),
|
||||
_action("open_workflow", "Open a saved workflow",
|
||||
"Opens one of the user's saved workflows (list_workflows names them) in a new tab, leaving the open one "
|
||||
"as it was, to run it or build on it with add_node, connect_nodes and set_values.",
|
||||
open_workflow, {"workflow": {"type": "string", "description": "A workflow name from list_workflows."}}, ["workflow"]),
|
||||
])
|
||||
@@ -0,0 +1,114 @@
|
||||
"""A directory of the tools by what an AI wants to do, so it goes straight to the right one, as
|
||||
ARCHITECTURE.md does for the code: each tool under its stage of the work (action_catalog.TOOLS),
|
||||
with its title, in the catalog's order. It is built from the registry, so a new tool shows up by
|
||||
itself (tests/test_mcp_directory.py checks that every stage has a heading). mcp_server.py puts it
|
||||
into begin_task's answer, and into get_situation's while there is no task yet.
|
||||
|
||||
AI apps keep the tool list they fetched (Antigravity files it away and fetches it again only when
|
||||
it reconnects), so after Anomalous is updated an app can go on with the old one: new tools missing,
|
||||
gone ones still called. listed() keeps which list each app fetched last (a fingerprint, in
|
||||
anomalous/ai_tool_lists.json); a call from an app whose list is not today's tells the AI, and notes
|
||||
it on the task (task["stale_tools"]) so the workbench tells the user how to reconnect, until the app
|
||||
fetches the list again.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from . import action_catalog, ai_tasks
|
||||
from .mcp_tools import retext
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
SECTIONS = ( # stage -> heading, in the order work usually goes
|
||||
("run", "Run a workflow"), ("canvas", "The workflow on the canvas"), ("model", "Models"), ("prompt", "Prompts"),
|
||||
("combo", "Combos"), ("picture", "Pictures"), ("voice", "Voices"), ("library", "The library"),
|
||||
(None, "The task and the user"),
|
||||
)
|
||||
NOTE = "The tools by what you want to do: go straight to the one you need instead of looking around first."
|
||||
STALE_NOTE = ("Your app holds an older list of Anomalous's tools (Anomalous was updated since it fetched it): some may "
|
||||
"be missing or gone. The call went through, but tell the user in report to reconnect the app to Anomalous "
|
||||
"(Antigravity: restart it, or switch the anomalous MCP server off and on) and to go on in a new chat.")
|
||||
_lists = None # app -> fingerprint of the tool list it fetched last
|
||||
|
||||
|
||||
def directory(specs):
|
||||
"""{heading: "tool (title), ..."} for the tools of `specs` {name: spec}."""
|
||||
sections = {}
|
||||
for name, entry in action_catalog.TOOLS.items():
|
||||
if name in specs:
|
||||
sections.setdefault(entry.stage, []).append(f"{name} ({specs[name].get('title') or name})")
|
||||
return {"note": NOTE, **{heading: ", ".join(sections[stage]) for stage, heading in SECTIONS if stage in sections}}
|
||||
|
||||
|
||||
def attach(result, name, specs):
|
||||
"""Puts the directory into begin_task's answer, and into get_situation's while there is no task."""
|
||||
data = result.get("structuredContent") if isinstance(result, dict) and not result.get("isError") else None
|
||||
if not isinstance(data, dict) or not (name == "begin_task" or (name == "get_situation" and not data.get("task"))):
|
||||
return result
|
||||
retext(result, {**data, "directory": directory(specs)})
|
||||
return result
|
||||
|
||||
|
||||
def fingerprint(tools):
|
||||
return hashlib.sha256(json.dumps(tools, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_tool_lists.json")
|
||||
|
||||
|
||||
def _known():
|
||||
global _lists
|
||||
if _lists is None:
|
||||
try:
|
||||
with open(_path(), encoding="utf-8") as source:
|
||||
stored = json.load(source)
|
||||
except (OSError, ValueError):
|
||||
stored = {}
|
||||
_lists = stored if isinstance(stored, dict) else {}
|
||||
return _lists
|
||||
|
||||
|
||||
def listed(client, tools):
|
||||
"""The app `client` fetched `tools`: kept, and the notes on its tasks go."""
|
||||
with ai_tasks._lock:
|
||||
known = _known()
|
||||
if known.get(client) != fingerprint(tools):
|
||||
known[client] = fingerprint(tools)
|
||||
atomic_write_json(_path(), known)
|
||||
changed = [task for task in ai_tasks._load().values() if task.get("stale_tools") and task.get("client") == client]
|
||||
for task in changed:
|
||||
task.pop("stale_tools")
|
||||
ai_tasks._save(task)
|
||||
for task in changed:
|
||||
ai_tasks._notify(task["id"])
|
||||
|
||||
|
||||
def stale(client, tools):
|
||||
"""Whether the app `client` fetched another list than `tools` last (one never seen fetching: no)."""
|
||||
with ai_tasks._lock:
|
||||
known = _known().get(client)
|
||||
return bool(known) and known != fingerprint(tools)
|
||||
|
||||
|
||||
def tell_stale(result, task_id):
|
||||
"""Says so in the call's answer, and notes it on the task for the workbench."""
|
||||
if isinstance(result, dict):
|
||||
data = result.get("structuredContent")
|
||||
first = (result.get("content") or [None])[0]
|
||||
if isinstance(data, dict) and not result.get("isError"):
|
||||
retext(result, {**data, "tool_list": STALE_NOTE})
|
||||
elif isinstance(first, dict) and first.get("type") == "text":
|
||||
result["content"][0] = {**first, "text": f"{first.get('text', '')} {STALE_NOTE}"}
|
||||
if not task_id:
|
||||
return result
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if task is None or task.get("stale_tools"):
|
||||
return result
|
||||
task["stale_tools"] = time.time()
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return result
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
"""run_workflow's blanks: a daily run in one call, so a small model need not read and edit the canvas.
|
||||
|
||||
With `workflow` (a saved workflow's name) the run opens it in the task's tab the first time and
|
||||
reuses that tab after (the page marks a tab open_workflow opened with the workflow's file); what
|
||||
the AI fills in goes into it first (web/modules/mcp_canvas_edit.js fill_slots): the positive and
|
||||
negative prompt boxes, the seed of every sampler, the empty latent's width and height, the
|
||||
picture its LoadImage reads (from the input or the output folder, found here, never copied;
|
||||
image_node says which LoadImage when there are several), and `values`: any other setting by its
|
||||
name, a third-party node's as well, for the one node that has it ({node id: {...}} when several
|
||||
do; set_values takes the same). No fixed list of blanks grows here. The rest stays as saved. Without
|
||||
`workflow` the blanks go into the canvas the run would run. All of it is checked before anything
|
||||
changes, and it is one Ctrl+Z step logged as the AI's.
|
||||
"""
|
||||
|
||||
from . import ai_task_canvas, ai_tasks
|
||||
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
|
||||
from .mcp_tools import ToolError, _resolve_folder_image, list_workflows
|
||||
|
||||
SLOTS = ("prompt", "negative", "seed", "width", "height", "image", "image_node", "values")
|
||||
MAX_VALUES = 60
|
||||
MAX_TEXT = 20000
|
||||
|
||||
|
||||
def flat_values(values):
|
||||
"""{setting name: value}, checked for shape and size; the page checks what each setting takes."""
|
||||
if values is None:
|
||||
return {}
|
||||
if not isinstance(values, dict) or len(values) > MAX_VALUES:
|
||||
raise ToolError(f"values is an object {{setting name: value}} of up to {MAX_VALUES} settings.")
|
||||
for name, value in values.items():
|
||||
if len(name) > 100 or not (isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= MAX_TEXT)):
|
||||
raise ToolError(f"The value of {name[:100]} is a number, true/false or text of up to {MAX_TEXT} characters.")
|
||||
return values
|
||||
|
||||
|
||||
def node_values(values):
|
||||
"""Settings by name ({setting name: value}) or for one node ({node id: {setting name: value}})."""
|
||||
if not isinstance(values, dict) or not values:
|
||||
raise ToolError('values is {setting name: value}, or {"<node id>": {setting name: value}} for one node.')
|
||||
nested = {key: value for key, value in values.items() if isinstance(value, dict)}
|
||||
for key, settings in nested.items():
|
||||
if not str(key).isdigit():
|
||||
raise ToolError(f"values.{str(key)[:40]}: only a node id takes an object of settings.")
|
||||
flat_values(settings)
|
||||
flat_values({key: value for key, value in values.items() if key not in nested})
|
||||
if len(values) - len(nested) + sum(len(settings) for settings in nested.values()) > MAX_VALUES:
|
||||
raise ToolError(f"values sets up to {MAX_VALUES} settings at once.")
|
||||
return values
|
||||
|
||||
|
||||
def saved_workflow(name):
|
||||
"""(file name, name) of the user's saved workflow called `name`."""
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
|
||||
name = str(name or "").strip()
|
||||
if not name or not any(item["name"] == name for item in list_workflows(name, 50)["workflows"]):
|
||||
raise ToolError(f"No saved workflow called {name}; list_workflows names them.")
|
||||
filename = next((recipe["filename"] for recipe in _list_recipes(get_recipes_dir())
|
||||
if (recipe["data"].get("name") or recipe["filename"]) == name), None)
|
||||
return filename, name
|
||||
|
||||
|
||||
def _picture(image):
|
||||
"""The value a LoadImage takes for picture `image`: in the input folder as it is, from the
|
||||
output folder with ComfyUI's "[output]" mark (it reads it from there)."""
|
||||
for folder in ("input", "output"):
|
||||
try:
|
||||
rel, _path = _resolve_folder_image(image, folder)
|
||||
except ToolError:
|
||||
continue
|
||||
return rel if folder == "input" else f"{rel} [output]"
|
||||
raise ToolError(f"{image} is in neither ComfyUI's input nor its output folder; search_images lists the pictures in the output.")
|
||||
|
||||
|
||||
def _page(action, args, timeout=30):
|
||||
try:
|
||||
return ask_page(action, args, timeout)
|
||||
except (PageUnavailable, PageRefused) as error:
|
||||
raise ToolError(str(error))
|
||||
|
||||
|
||||
def fill(ctx, workflow=None, **slots):
|
||||
"""Opens `workflow` for the task (once) and fills `slots` in; what was set, or None when nothing was asked."""
|
||||
slots = {key: value for key, value in slots.items() if key in SLOTS and value not in (None, "")}
|
||||
if not workflow and not slots:
|
||||
return None
|
||||
if "image" in slots:
|
||||
slots["image"] = _picture(slots["image"])
|
||||
if "values" in slots:
|
||||
node_values(slots["values"])
|
||||
filename = name = None
|
||||
if workflow:
|
||||
filename, name = saved_workflow(workflow)
|
||||
task = ai_tasks.get_task(ctx.get("task")) if ctx.get("task") else None
|
||||
no_tab_yet = bool(task) and ai_task_canvas.mode_of(task) == "new" and not (task.get("canvas") or {}).get("tab")
|
||||
filled = None if workflow and no_tab_yet else _page("fill_slots", {"filename": filename, **slots})
|
||||
if workflow and (filled is None or filled.get("needs_open")):
|
||||
_page("open_workflow", {"filename": filename, "name": name}, timeout=60)
|
||||
filled = _page("fill_slots", {"filename": filename, **slots})
|
||||
if filled.get("needs_open"):
|
||||
raise ToolError(f"The saved workflow {name} did not open; nothing was run.")
|
||||
return {**({"workflow": name} if name else {}), **(filled.get("set") or {})}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""zoom_image, the AI's closer look: part of a picture (in ComfyUI's output, input or temp folder)
|
||||
cut out and shown larger, and the same part of a second picture beside it to compare a before
|
||||
and an after, attached to the answer. It reads only: the pictures stay as they are. With it an AI
|
||||
need not crop, enlarge or compare pictures with code of its own (each such command asks the user
|
||||
to allow it). The region is in fractions of the picture, as the user's marks give theirs."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .mcp_tools import ToolError, _resolve_folder_image, _tool
|
||||
|
||||
MIN_SIZE, MAX_SIZE, DEFAULT_SIZE = 256, 1024, 768
|
||||
MAX_ENLARGE = 4 # a tiny part is shown at most this many times its size
|
||||
MIN_PIXELS = 8
|
||||
|
||||
|
||||
def _box(region, width, height):
|
||||
"""Region {x, y, width, height} (fractions 0-1, default the whole picture) as a pixel box."""
|
||||
region = region if isinstance(region, dict) else {}
|
||||
try:
|
||||
x, y = (min(1.0, max(0.0, float(region.get(key, 0)))) for key in ("x", "y"))
|
||||
w, h = (min(1.0, max(0.0, float(region.get(key, 1)))) for key in ("width", "height"))
|
||||
except (TypeError, ValueError):
|
||||
raise ToolError("region is {x, y, width, height}, each a fraction 0-1 of the picture.")
|
||||
left, top = round(x * width), round(y * height)
|
||||
right, bottom = min(width, round((x + w) * width)), min(height, round((y + h) * height))
|
||||
if right - left < MIN_PIXELS or bottom - top < MIN_PIXELS:
|
||||
raise ToolError(f"The region is under {MIN_PIXELS} pixels across; give a larger one.")
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def _part(path, region, size):
|
||||
"""(facts, MCP image item) of `region` of the picture at `path`, its longer side `size` at most."""
|
||||
try:
|
||||
with Image.open(path) as picture:
|
||||
box = _box(region, picture.width, picture.height)
|
||||
part = picture.crop(box).convert("RGB")
|
||||
whole = f"{picture.width}x{picture.height}"
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
raise ToolError("The picture cannot be read.")
|
||||
scale = min(size / max(part.size), MAX_ENLARGE)
|
||||
shown = part.resize((max(1, round(part.width * scale)), max(1, round(part.height * scale))), Image.LANCZOS)
|
||||
data = io.BytesIO()
|
||||
shown.save(data, "JPEG", quality=88)
|
||||
facts = {"picture": whole, "region": f"x {box[0]}-{box[2]}, y {box[1]}-{box[3]} px", "shown": f"{shown.width}x{shown.height}"}
|
||||
return facts, {"type": "image", "data": base64.b64encode(data.getvalue()).decode("ascii"), "mimeType": "image/jpeg"}
|
||||
|
||||
|
||||
def zoom_image(image, region=None, folder="output", compare_with="", compare_folder="", size=DEFAULT_SIZE):
|
||||
try:
|
||||
size = min(MAX_SIZE, max(MIN_SIZE, int(size)))
|
||||
except (TypeError, ValueError):
|
||||
raise ToolError(f"size is {MIN_SIZE}-{MAX_SIZE} pixels.")
|
||||
rel, path = _resolve_folder_image(image, folder)
|
||||
facts, item = _part(path, region, size)
|
||||
answer = {"image": rel, "folder": folder, **facts}
|
||||
extra = [item]
|
||||
if compare_with:
|
||||
other_folder = compare_folder or folder
|
||||
other_rel, other_path = _resolve_folder_image(compare_with, other_folder)
|
||||
other_facts, other_item = _part(other_path, region, size)
|
||||
answer["compare_with"] = {"image": other_rel, "folder": other_folder, **other_facts}
|
||||
answer["note"] = "Two pictures are attached: first image, then compare_with, the same region of each."
|
||||
extra.append(other_item)
|
||||
return answer, extra
|
||||
|
||||
|
||||
_REGION = {"type": "object", "description": "Fractions 0-1, like the user's marks; default all.",
|
||||
"properties": {name: {"type": "number", "minimum": 0, "maximum": 1} for name in ("x", "y", "width", "height")}}
|
||||
_FOLDER = {"type": "string", "enum": ["output", "input", "temp"], "default": "output"}
|
||||
|
||||
LOOK_TOOLS = dict([
|
||||
_tool("zoom_image", "Look closer at a picture",
|
||||
"Part of a picture larger, at full detail (a hand, a face, a seam). folder: output, input or temp. "
|
||||
"compare_with: a second picture, the same region beside it. Use it instead of cropping with code; "
|
||||
"change pictures only by running a workflow, never by editing their files.",
|
||||
zoom_image, {"image": {"type": "string", "description": "As the other tools name it."},
|
||||
"region": _REGION, "folder": _FOLDER,
|
||||
"compare_with": {"type": "string", "description": "A second picture."},
|
||||
"compare_folder": {"type": "string", "enum": ["output", "input", "temp"],
|
||||
"description": "Default: folder."},
|
||||
"size": {"type": "integer", "minimum": MIN_SIZE, "maximum": MAX_SIZE, "default": DEFAULT_SIZE}},
|
||||
["image"]),
|
||||
])
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Where a model loads, for the AI: search_models and get_model (mcp_tools.py finds the files)
|
||||
say which node types list each file among their choices (ComfyUI's /object_info, kept a little
|
||||
while by mcp_canvas_edit.py), and, when a file's header says it is another kind than the folder
|
||||
it is in (model_placement.py, the models page's Tidy view), where it belongs and that nothing
|
||||
loads it where it is. So the AI picks the right node at once, or has the model moved, instead of
|
||||
searching ComfyUI itself. move_model moves one model with its covers and notes as Tidy does,
|
||||
behind the "files" gate (the user says yes first unless they let it go ahead)."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import mcp_canvas_edit, mcp_tools, model_advice, model_placement
|
||||
from .mcp_actions import _action
|
||||
from .mcp_tools import _LIMIT, _QUERY, ToolError
|
||||
from .model_constants import MODEL_EXTENSIONS
|
||||
from .model_import import Refused, import_types
|
||||
|
||||
MAX_LOADERS = 6
|
||||
|
||||
_lock = threading.Lock()
|
||||
_index = {"of": None, "inputs": [], "by_choice": {}}
|
||||
|
||||
|
||||
def _plain(name):
|
||||
return str(name).replace("\\", "/")
|
||||
|
||||
|
||||
def _choices(conf):
|
||||
if not isinstance(conf, list) or not conf:
|
||||
return None
|
||||
if isinstance(conf[0], list):
|
||||
return conf[0]
|
||||
options = conf[1] if len(conf) > 1 and isinstance(conf[1], dict) else {}
|
||||
return options.get("options") if conf[0] == "COMBO" and isinstance(options.get("options"), list) else None
|
||||
|
||||
|
||||
def _built(info):
|
||||
"""The drop-downs of model files in node definitions `info`: [(node type, choices)] and choice -> their places."""
|
||||
with _lock:
|
||||
if _index["of"] is info:
|
||||
return _index
|
||||
inputs, by_choice = [], {}
|
||||
for node_type, item in info.items():
|
||||
for section in ("required", "optional"):
|
||||
for conf in ((item.get("input") or {}).get(section) or {}).values():
|
||||
listed = [_plain(choice) for choice in _choices(conf) or [] if isinstance(choice, str)]
|
||||
if not any(choice.lower().endswith(MODEL_EXTENSIONS) for choice in listed):
|
||||
continue
|
||||
for choice in listed:
|
||||
by_choice.setdefault(choice, []).append(len(inputs))
|
||||
inputs.append((node_type, frozenset(listed)))
|
||||
_index.update(of=info, inputs=inputs, by_choice=by_choice)
|
||||
return _index
|
||||
|
||||
|
||||
def _folder(folder_type):
|
||||
return frozenset(_plain(name) for name in folder_paths.get_filename_list(folder_type))
|
||||
|
||||
|
||||
def _readers(ctx, folder_type, rel=None):
|
||||
"""Node types with a drop-down of the whole folder `folder_type` (one that lists `rel`, when given)."""
|
||||
index = _built(mcp_canvas_edit.object_info(ctx))
|
||||
folder = _folder(folder_type)
|
||||
if not folder:
|
||||
return []
|
||||
places = index["by_choice"].get(_plain(rel), []) if rel else range(len(index["inputs"]))
|
||||
found = []
|
||||
for at in places:
|
||||
node_type, choices = index["inputs"][at]
|
||||
if folder <= choices and node_type not in found:
|
||||
found.append(node_type)
|
||||
return found[:MAX_LOADERS]
|
||||
|
||||
|
||||
def _about(ctx, model):
|
||||
"""`model` (from mcp_tools) with what loads it: loaded_by, or belongs_in and placement when it is misfiled."""
|
||||
folder_type, root, base_dir, path = mcp_tools._resolve_model(model["model_id"])
|
||||
rel = os.path.relpath(path, base_dir).replace(os.sep, "/")
|
||||
home = model_placement.belongs(model_placement.one(folder_type, root, rel, path))
|
||||
if not home or home["works"]:
|
||||
model["loaded_by"] = _readers(ctx, folder_type, rel)
|
||||
return model
|
||||
kind, to = home["kind"], home["to"]
|
||||
readers = _readers(ctx, kind)
|
||||
words = (f"Its header says it is a {kind} file, but it is in {folder_type}: the nodes listing it there cannot load it"
|
||||
+ (f"; {', '.join(readers)} load {kind} files." if readers else "."))
|
||||
there = os.path.join(folder_paths.get_folder_paths(kind)[to["root"]], *to["rel"].split("/"))
|
||||
if os.path.isfile(there):
|
||||
words += (f" A file of the same name is already in {kind} (model_id {kind}:{to['root']}:{to['rel']}): use that one. "
|
||||
"If it is the same file, this copy is extra; the user can recycle it in Anomalous's models page (Tidy).")
|
||||
else:
|
||||
words += f" move_model with to_type {kind} moves it there (the user confirms first)."
|
||||
model.update(belongs_in=kind, placement=words)
|
||||
return model
|
||||
|
||||
|
||||
def search_models(ctx, query="", type="", base_model="", source="", limit=20):
|
||||
found = mcp_tools.search_models(query, type, base_model, source, limit)
|
||||
for model in found["models"]:
|
||||
_about(ctx, model)
|
||||
return found
|
||||
|
||||
|
||||
def get_model(ctx, model_id, include_cover=False):
|
||||
detail, extra = mcp_tools.get_model(model_id, include_cover)
|
||||
folder_type, _idx, _base, path = mcp_tools._resolve_model(model_id)
|
||||
examples = model_advice.author_examples(path, folder_type, detail.get("sha256", ""))
|
||||
if examples:
|
||||
detail["author_examples"] = examples
|
||||
return _about(ctx, detail), extra
|
||||
|
||||
|
||||
def move_model(ctx, model_id, to_type):
|
||||
folder_type, root, base_dir, path = mcp_tools._resolve_model(model_id)
|
||||
rel = os.path.relpath(path, base_dir).replace(os.sep, "/")
|
||||
item = model_placement.one(folder_type, root, rel, path)
|
||||
home = model_placement.belongs(item)
|
||||
to_type = str(to_type or "")
|
||||
if to_type == folder_type:
|
||||
raise ToolError(f"It is in {to_type} already.")
|
||||
if to_type not in import_types():
|
||||
raise ToolError(f"to_type is one of {', '.join(import_types())}.")
|
||||
to = home["to"] if home and home["kind"] == to_type else model_placement.home_for(item, to_type)
|
||||
if not to:
|
||||
raise ToolError(f"This ComfyUI has no {to_type} folder.")
|
||||
if os.path.exists(os.path.join(folder_paths.get_folder_paths(to_type)[to["root"]], *to["rel"].split("/"))):
|
||||
raise ToolError(f"A file of the same name is already in {to_type} (model_id {to_type}:{to['root']}:{to['rel']}); "
|
||||
"nothing was moved. Use that one; the user can recycle an extra copy in Anomalous's models page (Tidy).")
|
||||
try:
|
||||
moved = model_placement.move({"type": folder_type, "root": root, "rel": rel,
|
||||
"to_type": to_type, "to_root": to["root"], "to_rel": to["rel"]})
|
||||
except Refused as error:
|
||||
raise ToolError(f"Not moved ({error.code}).")
|
||||
mcp_canvas_edit.forget_object_info()
|
||||
answer = {"moved": {"from": f"{folder_type}/{rel}", "to": f"{moved['type']}/{moved['rel']}"},
|
||||
"model_id": f"{moved['type']}:{moved['root']}:{moved['rel']}",
|
||||
"loaded_by": _readers(ctx, moved["type"], moved["rel"])}
|
||||
if moved["left"]:
|
||||
answer["left_behind"] = moved["left"] # covers or notes that could not go along
|
||||
return answer
|
||||
|
||||
|
||||
MODEL_TOOLS = dict([
|
||||
_action("search_models", "Search models",
|
||||
"Model files by name, trigger word or base model: model_id, and loaded_by (the node types that list "
|
||||
"the file). A misfiled one comes with belongs_in: nothing loads it where it is.",
|
||||
search_models, {
|
||||
"query": _QUERY,
|
||||
"type": {"type": "string", "description": "A models folder: checkpoints, loras, vae…"},
|
||||
"base_model": {"type": "string", "description": "e.g. SDXL, Pony, Flux."},
|
||||
"source": {"type": "string", "enum": ["civitai", "inferred_from_file", "not_scanned"],
|
||||
"description": "Where the model's information came from."},
|
||||
"limit": _LIMIT}, read_only=True),
|
||||
_action("get_model", "Model details",
|
||||
"Everything known about one model: Civitai name, version and link, trigger words, base model, description, "
|
||||
"the user's own name and notes, author_examples (the sampler, steps, CFG, size and, for a LoRA, the weight "
|
||||
"its author's example pictures used: a good start), and loaded_by (or belongs_in and placement) as in "
|
||||
"search_models. include_cover attaches its cover picture.",
|
||||
get_model, {"model_id": {"type": "string", "description": "From search_models."},
|
||||
"include_cover": {"type": "boolean", "default": False}}, ["model_id"], read_only=True),
|
||||
_action("move_model", "Move a model to its folder",
|
||||
"Moves a model the user has in the wrong models folder (search_models says belongs_in) to where it loads, "
|
||||
"with its covers and notes, as Anomalous's Tidy view does; never over a file. The answer gives its new "
|
||||
"model_id and loaded_by; the canvas lists it from then on.",
|
||||
move_model, {"model_id": {"type": "string", "description": "From search_models."},
|
||||
"to_type": {"type": "string", "description": "The models folder to move it to: belongs_in from search_models."}},
|
||||
["model_id", "to_type"], destructive=True),
|
||||
])
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Ready-made requests the user can start from their AI app (MCP prompts; Claude shows them as
|
||||
slash commands such as /anomalous:pick_best): each is a few lines telling the AI how to do a
|
||||
common job with Anomalous's tools so the user can watch, choose and step in on the AI workbench.
|
||||
They only shape the AI's first message; they change nothing themselves."""
|
||||
|
||||
PROMPTS = {
|
||||
"workbench_task": {
|
||||
"title": "Start a task on the AI workbench",
|
||||
"description": "Do something in ComfyUI the way the user can follow on Anomalous's AI workbench.",
|
||||
"arguments": [{"name": "what", "description": "What to do", "required": True}],
|
||||
"text": ("Use Anomalous for this: {what}\n"
|
||||
"Call begin_task with a short title in my language first. Show your plan, then results, with "
|
||||
"show_panel in the order they happen; when a choice is mine, ask it with ask_user (or a picks panel, "
|
||||
"waiting with get_answers), never in this chat. Honour any user_messages that come with a tool result. End with report(done=true)."),
|
||||
},
|
||||
"pick_best": {
|
||||
"title": "Make a few, let me pick",
|
||||
"description": "Make several variations with the open workflow and let the user pick one.",
|
||||
"arguments": [{"name": "what", "description": "What to vary (seed, LoRA weight, prompt…)", "required": True},
|
||||
{"name": "count", "description": "How many (default 3)", "required": False}],
|
||||
"text": ("Make {count} variations of the open ComfyUI workflow, changing: {what}.\n"
|
||||
"Call begin_task first. Read the canvas (describe_canvas), change one thing per run, run the workflow "
|
||||
"each time and find the pictures with search_images. Then show them with show_panel as a picks block, "
|
||||
"each with what you changed as its reason, and wait for my pick with get_answers. Put the chosen "
|
||||
"settings back on the canvas and report(done=true)."),
|
||||
},
|
||||
"fix_missing_models": {
|
||||
"title": "Fix the missing models",
|
||||
"description": "Find what the open workflow lacks and put it back, asking before downloads.",
|
||||
"arguments": [],
|
||||
"text": ("Check the open workflow's models with check_workflow_models (call begin_task first). Put back the ones "
|
||||
"Anomalous found with certainty (fix_workflow_models). Show what is still missing with show_panel as a "
|
||||
"table, and ask me with choices before downloading anything. report(done=true) when finished."),
|
||||
},
|
||||
"explain_canvas": {
|
||||
"title": "Explain the open workflow",
|
||||
"description": "A picture of the open workflow's main chain with a short explanation.",
|
||||
"arguments": [],
|
||||
"text": ("Explain the workflow open in ComfyUI. Call begin_task, read it with describe_canvas, then show_panel "
|
||||
"with a diagram block of its main chain (loaders, prompts, sampler, decoding, saving; node_id on each "
|
||||
"box) and a few lines of text on what each part does and which settings matter most. Change nothing."),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_prompts():
|
||||
return {"prompts": [{"name": name, "title": item["title"], "description": item["description"], "arguments": item["arguments"]}
|
||||
for name, item in PROMPTS.items()]}
|
||||
|
||||
|
||||
def get_prompt(name, arguments=None):
|
||||
"""prompts/get, or None for an unknown name or a missing required argument."""
|
||||
item = PROMPTS.get(name)
|
||||
arguments = arguments if isinstance(arguments, dict) else {}
|
||||
if not item or any(arg["required"] and not str(arguments.get(arg["name"], "")).strip() for arg in item["arguments"]):
|
||||
return None
|
||||
values = {"what": "", "count": "3", **{key: str(value) for key, value in arguments.items()}}
|
||||
return {"description": item["description"],
|
||||
"messages": [{"role": "user", "content": {"type": "text", "text": item["text"].format(**values)}}]}
|
||||
@@ -0,0 +1,315 @@
|
||||
"""MCP endpoint: AI apps (Claude, Cursor, Cherry Studio…) use Anomalous's library as tools.
|
||||
|
||||
POST /anomalous/mcp speaks the Model Context Protocol's Streamable HTTP transport, both eras:
|
||||
- modern (2026-07-28): stateless; every request carries its protocol version in `_meta` and
|
||||
the MCP-Protocol-Version / Mcp-Method / Mcp-Name headers, which must match the body;
|
||||
- legacy (2025-03-26 to 2025-11-25): an `initialize` handshake first. No session is kept
|
||||
(sessions are optional there), so both eras are served the same stateless way.
|
||||
Only this computer may connect: the peer must be loopback, no proxy header may be present,
|
||||
and Host and Origin (when sent) must name a loopback host, which stops DNS rebinding.
|
||||
Answers are always one JSON object; there are no server-initiated messages. The tools are
|
||||
in mcp_tools.py (reading), mcp_actions.py (acting) and mcp_task_tools.py (the AI workbench's
|
||||
begin_task and report); every call is kept as a step of a task (ai_tasks.py) for the AI
|
||||
workbench page, starting in state "running" and updated on completion, with unread user
|
||||
messages and panel answers attached to the next successful tool response, and pictures changed
|
||||
outside Anomalous since the AI's previous call shown and told (ai_outside.py). POST
|
||||
/anomalous/mcp/bridge is where the open ComfyUI page claims and answers canvas actions
|
||||
(mcp_bridge.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import ai_callers, ai_gates, ai_outside, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, mcp_apps, mcp_bridge, mcp_directory, mcp_prompts
|
||||
from .local_guard import is_local as _is_local, loopback_host as _loopback_host
|
||||
from .mcp_actions import ACTIONS
|
||||
from .mcp_canvas_edit import CANVAS_TOOLS
|
||||
from .mcp_model_use import MODEL_TOOLS
|
||||
from .mcp_look import LOOK_TOOLS
|
||||
from .mcp_situation import SITUATION_TOOLS
|
||||
from .mcp_task_tools import TASK_TOOLS
|
||||
from .mcp_tools import TOOLS as READ_TOOLS, call_tool, retext
|
||||
from .mcp_arguments import prepare_arguments, renaming_note
|
||||
|
||||
TOOLS = {**READ_TOOLS, **MODEL_TOOLS, **LOOK_TOOLS, **ACTIONS, **CANVAS_TOOLS, **TASK_TOOLS, **SITUATION_TOOLS, **mcp_apps.APP_TOOLS}
|
||||
CAPABILITIES = {
|
||||
"tools": {"listChanged": False}, "prompts": {"listChanged": False}, "resources": {"listChanged": False},
|
||||
"extensions": {mcp_apps.EXTENSION: {"mimeTypes": [mcp_apps.MIME]}}, # the card in the conversation (mcp_apps.py)
|
||||
}
|
||||
|
||||
MODERN_VERSIONS = ("2026-07-28",)
|
||||
LEGACY_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
|
||||
SERVER_INFO = {"name": "anomalous-model-browser", "title": "Anomalous Model Browser", "version": "1.0.0"}
|
||||
INSTRUCTIONS = (
|
||||
"Tools over the user's ComfyUI library as Anomalous Model Browser keeps it: model files with "
|
||||
"their Civitai information, output images with their generation settings, combos (a model, "
|
||||
"LoRAs and a prompt), workflow recipes, saved prompts, generated audio, the scan status and "
|
||||
"the activity log; and tools that act on the workflow open in ComfyUI (get_situation gives flow chains; "
|
||||
"describe_canvas only for details) or build one where the user watches: find_nodes searches brief types; "
|
||||
"add_node wires in the same call (wire_from/wire_to; wire_to=ID splices it in front of that node, "
|
||||
"insert_after=ID behind it); add_nodes adds a whole group in ComfyUI's API form in one call; "
|
||||
"connect_nodes needs only two IDs unless sockets are ambiguous. Reuse receipts instead of rereading "
|
||||
"the graph. new_workflow opens a tab only when needed. Tools also scan models or have an "
|
||||
"Anomalous TTS voice speak. Canvas changes "
|
||||
"are single Ctrl+Z steps and are logged as the AI's; nothing deletes files. Ids returned by "
|
||||
"one tool (model_id, image, node ids) are what the others take. For what no tool does, tell "
|
||||
"the user where in Anomalous to do it (the rail on the left: Models, Gallery, Workflows, "
|
||||
"Combos, Prompts, Voices). begin_task's answer carries a directory of the tools by what you want to do: "
|
||||
"look there and go straight to the tool. In a new chat on a task, or when unsure where things stand, call get_situation "
|
||||
"first: the task, the canvas in brief (what is still unwired) and the next step in one answer. "
|
||||
"The user watches your work in Anomalous's AI workbench: call begin_task "
|
||||
"with a short title when you start what they asked for (it continues the waiting or active task; "
|
||||
"new_task=true is only for a separate user request), and report to explain a choice, hand in "
|
||||
"pictures made another way, or end the task (done=true). The user can write to you or answer "
|
||||
"panel questions there: call get_answers to wait for or read them, and any unread user_messages "
|
||||
"or user_answers attached to a tool result must also be honoured. While you work on an Anomalous task, ask "
|
||||
"the user every question (a choice, a preference, yes or no) with ask_user, never in your own chat or its "
|
||||
"question form: the user watches and answers in Anomalous. Until they say the task is done, end every round "
|
||||
"with ask_user (what next, or whether to go ahead), not with a note or panel alone; only a run, download or "
|
||||
"scan going on lets you end your turn without asking. Likewise write your explanations and replies "
|
||||
"there with report (reply_to answers their message or marks), not only in your chat. Show plans, workflow explanations "
|
||||
"and results in chronological order with show_panel; update_panel appends new content below what "
|
||||
"the user already saw. Use run_workflow for execution: it returns real state/results after a bounded wait. "
|
||||
"If you submit through /prompt or another tool, immediately call track_run with YOUR returned prompt_ids "
|
||||
"and task ID, or the workbench cannot observe it. For running results use run_status(wait_seconds=55); "
|
||||
"do not inspect output directories or resubmit to find out whether it finished. Change pictures only by "
|
||||
"running workflows, never with code of your own; if you do use another tool (a command, a script, files), "
|
||||
"say what you ran with log_outside."
|
||||
)
|
||||
LIST_TTL_MS = 300000
|
||||
# Requests run tools in threads; a few at a time is plenty for one person's AI app.
|
||||
_slots = asyncio.Semaphore(4)
|
||||
|
||||
|
||||
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS = -32700, -32600, -32601, -32602
|
||||
HEADER_MISMATCH, UNSUPPORTED_VERSION = -32020, -32022
|
||||
|
||||
|
||||
def _origin_ok(request):
|
||||
origin = request.headers.get("Origin")
|
||||
return origin is None or _loopback_host(urllib.parse.urlsplit(origin).hostname)
|
||||
|
||||
|
||||
def _decode_header(value):
|
||||
"""Mcp-Name values outside plain ASCII come as =?base64?…?=."""
|
||||
if value and value.startswith("=?base64?") and value.endswith("?="):
|
||||
try:
|
||||
return base64.b64decode(value[9:-2], validate=True).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _error(id_, code, message, status=200, data=None):
|
||||
error = {"code": code, "message": message}
|
||||
if data is not None:
|
||||
error["data"] = data
|
||||
body = {"jsonrpc": "2.0", "error": error}
|
||||
if id_ is not None:
|
||||
body["id"] = id_
|
||||
return web.json_response(body, status=status)
|
||||
|
||||
|
||||
def _result(id_, result, modern):
|
||||
if modern:
|
||||
result = {"resultType": "complete", **result}
|
||||
return web.json_response({"jsonrpc": "2.0", "id": id_, "result": result})
|
||||
|
||||
|
||||
def _tools():
|
||||
"""The tool list AI apps are offered, as tools/list gives it."""
|
||||
return [mcp_apps.with_card(tool["spec"]) for tool in TOOLS.values()]
|
||||
|
||||
|
||||
def _tool_list(modern):
|
||||
result = {"tools": _tools()}
|
||||
if modern:
|
||||
result.update(ttlMs=LIST_TTL_MS, cacheScope="public")
|
||||
return result
|
||||
|
||||
|
||||
def _initialize(params):
|
||||
requested = params.get("protocolVersion")
|
||||
return {
|
||||
"protocolVersion": requested if requested in LEGACY_VERSIONS else LEGACY_VERSIONS[0],
|
||||
"capabilities": CAPABILITIES,
|
||||
"serverInfo": SERVER_INFO,
|
||||
"instructions": INSTRUCTIONS,
|
||||
}
|
||||
|
||||
|
||||
def _discover():
|
||||
return {
|
||||
"supportedVersions": list(MODERN_VERSIONS + LEGACY_VERSIONS),
|
||||
"capabilities": CAPABILITIES,
|
||||
"_meta": {"io.modelcontextprotocol/serverInfo": SERVER_INFO},
|
||||
"instructions": INSTRUCTIONS,
|
||||
"ttlMs": LIST_TTL_MS,
|
||||
"cacheScope": "public",
|
||||
}
|
||||
|
||||
|
||||
def _modern_header_problem(request, method, params, version):
|
||||
"""Why the mirrored headers do not match the body (2026-07-28), or None."""
|
||||
if request.headers.get("MCP-Protocol-Version") != version:
|
||||
return "MCP-Protocol-Version header missing or not the body's protocol version"
|
||||
if request.headers.get("Mcp-Method") != method:
|
||||
return "Mcp-Method header missing or not the body's method"
|
||||
if method in ("tools/call", "prompts/get", "resources/read"):
|
||||
name = params.get("uri") if method == "resources/read" else params.get("name")
|
||||
if _decode_header(request.headers.get("Mcp-Name")) != name:
|
||||
return "Mcp-Name header missing or not the body's name"
|
||||
return None
|
||||
|
||||
|
||||
async def _call(params, request):
|
||||
name = params.get("name")
|
||||
raw_arguments = params.get("arguments") or {}
|
||||
if not isinstance(name, str) or name not in TOOLS or not isinstance(raw_arguments, dict):
|
||||
return None
|
||||
arguments = dict(raw_arguments)
|
||||
client = ai_callers.client_of(request.headers, params)
|
||||
session = ai_callers.session_of(request.headers, params)
|
||||
task_id = ai_callers.task_of(request.headers, params, arguments)
|
||||
if "task" in arguments and "task" not in TOOLS[name]["spec"]["inputSchema"].get("properties", {}):
|
||||
arguments.pop("task", None)
|
||||
arguments, renamed = prepare_arguments(TOOLS[name]["spec"]["inputSchema"], arguments)
|
||||
ctx = {"base_url": f"{request.scheme}://{request.host}", "client": client, "session": session, "task": task_id} # loopback, checked in api_mcp
|
||||
started = time.time()
|
||||
read_only = bool(TOOLS[name]["spec"].get("annotations", {}).get("readOnlyHint", True))
|
||||
step_ref = await asyncio.to_thread(
|
||||
ai_tasks.begin_step, client, name, arguments, started, read_only, session=session, task_id=task_id,
|
||||
)
|
||||
active_task = step_ref[0] if step_ref else task_id
|
||||
if active_task:
|
||||
ctx["task"] = active_task
|
||||
outside = None
|
||||
if name not in mcp_apps.APP_TOOLS: # the card's own calls do not reach the AI
|
||||
outside = await asyncio.to_thread(ai_outside.check, active_task or ai_tasks.active_task_id(client, session))
|
||||
canvas_call = ai_task_canvas.begin_call(active_task) # the page works on the task's own tab
|
||||
try:
|
||||
# The user's gate first (ai_gates.py): it may wait for their OK, or answer for them.
|
||||
result = await asyncio.to_thread(ai_gates.check, name, arguments, step_ref)
|
||||
if result is None:
|
||||
async with _slots:
|
||||
result = await asyncio.to_thread(call_tool, TOOLS, name, arguments, ctx)
|
||||
except BaseException as error:
|
||||
failed_result = {"content": [{"type": "text", "text": f"{name} failed: {error}"}], "isError": True}
|
||||
await asyncio.to_thread(
|
||||
ai_tasks.finish_step, client, name, arguments, failed_result, started, read_only,
|
||||
step_ref=step_ref, session=session, task_id=active_task,
|
||||
)
|
||||
raise
|
||||
if renamed and not result.get("isError") and isinstance(result.get("structuredContent"), dict):
|
||||
retext(result, {**result["structuredContent"], "arguments_note": renaming_note(name, renamed)})
|
||||
step = await asyncio.to_thread(
|
||||
ai_tasks.finish_step, client, name, arguments, result, started, read_only,
|
||||
step_ref=step_ref, session=session, task_id=active_task,
|
||||
)
|
||||
ai_task_canvas.tell(result, canvas_call)
|
||||
if name == "run_workflow" and step and step.get("state") == "running":
|
||||
ai_task_runs.watch(step["id"], result.get("structuredContent") or {})
|
||||
await asyncio.to_thread(ai_run_tracking.wait_for_receipt, result, client, session, active_task,
|
||||
ai_run_tracking.checked_wait(arguments.get("wait_seconds", 55)), step["id"])
|
||||
# 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", "stop_run") and resolved_task:
|
||||
await asyncio.to_thread(ai_task_runs.tell, result, resolved_task)
|
||||
await asyncio.to_thread(ai_task_runs.show_pictures, 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))
|
||||
mcp_directory.attach(result, name, {key: tool["spec"] for key, tool in TOOLS.items() if key not in mcp_apps.APP_TOOLS})
|
||||
if name not in mcp_apps.APP_TOOLS and mcp_directory.stale(client, _tools()):
|
||||
await asyncio.to_thread(mcp_directory.tell_stale, result, active_task)
|
||||
return ai_outside.tell(result, outside)
|
||||
|
||||
|
||||
async def api_mcp(request):
|
||||
"""POST /anomalous/mcp - one JSON-RPC request or notification per POST."""
|
||||
if request.method != "POST":
|
||||
return web.Response(status=405, headers={"Allow": "POST"})
|
||||
if not _is_local(request) or not _origin_ok(request):
|
||||
return _error(None, INVALID_REQUEST, "Only this computer may use the Anomalous MCP endpoint.", status=403)
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return _error(None, PARSE_ERROR, "Body is not JSON", status=400)
|
||||
if not isinstance(body, dict) or body.get("jsonrpc") != "2.0" or not isinstance(body.get("method"), str):
|
||||
return _error(None, INVALID_REQUEST, "Expected one JSON-RPC 2.0 request (batches are not supported)", status=400)
|
||||
if "id" not in body:
|
||||
return web.Response(status=202) # notifications (initialized, cancelled) need no answer
|
||||
id_, method = body["id"], body["method"]
|
||||
params = body.get("params") if isinstance(body.get("params"), dict) else {}
|
||||
|
||||
if method == "initialize":
|
||||
return _result(id_, _initialize(params), modern=False)
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
version = meta.get("io.modelcontextprotocol/protocolVersion")
|
||||
modern = version is not None
|
||||
if modern:
|
||||
if version not in MODERN_VERSIONS:
|
||||
return _error(id_, UNSUPPORTED_VERSION, "Unsupported protocol version", status=400,
|
||||
data={"supported": list(MODERN_VERSIONS + LEGACY_VERSIONS), "requested": version})
|
||||
problem = _modern_header_problem(request, method, params, version)
|
||||
if problem:
|
||||
return _error(id_, HEADER_MISMATCH, f"Header mismatch: {problem}", status=400)
|
||||
else:
|
||||
header = request.headers.get("MCP-Protocol-Version")
|
||||
if header is not None and header not in LEGACY_VERSIONS:
|
||||
return _error(id_, INVALID_REQUEST, f"Unsupported protocol version {header}", status=400,
|
||||
data={"supported": list(MODERN_VERSIONS + LEGACY_VERSIONS), "requested": header})
|
||||
|
||||
if method == "server/discover" and modern:
|
||||
return _result(id_, _discover(), modern)
|
||||
if method == "ping":
|
||||
return _result(id_, {}, modern)
|
||||
if method == "tools/list":
|
||||
listing = _tool_list(modern)
|
||||
await asyncio.to_thread(mcp_directory.listed, ai_callers.client_of(request.headers, params), listing["tools"])
|
||||
return _result(id_, listing, modern)
|
||||
if method == "prompts/list":
|
||||
return _result(id_, mcp_prompts.list_prompts(), modern)
|
||||
if method == "prompts/get":
|
||||
prompt = mcp_prompts.get_prompt(params.get("name"), params.get("arguments"))
|
||||
if prompt is None:
|
||||
return _error(id_, INVALID_PARAMS, f"Unknown prompt or missing arguments: {params.get('name')}")
|
||||
return _result(id_, prompt, modern)
|
||||
if method == "resources/list":
|
||||
return _result(id_, {"resources": mcp_apps.resources()}, modern)
|
||||
if method == "resources/read":
|
||||
resource = await asyncio.to_thread(mcp_apps.read, params.get("uri"))
|
||||
if resource is None:
|
||||
return _error(id_, INVALID_PARAMS, f"Unknown resource: {params.get('uri')}")
|
||||
return _result(id_, resource, modern)
|
||||
if method == "tools/call":
|
||||
result = await _call(params, request)
|
||||
if result is None:
|
||||
return _error(id_, INVALID_PARAMS, f"Unknown tool or bad arguments: {params.get('name')}")
|
||||
return _result(id_, result, modern)
|
||||
return _error(id_, METHOD_NOT_FOUND, f"Method not found: {method}", status=404 if modern else 200)
|
||||
|
||||
|
||||
async def api_mcp_bridge(request):
|
||||
"""POST /anomalous/mcp/bridge - the open page claims a canvas action ({id, claim: true} ->
|
||||
{granted}) and then answers it ({id, outcome: {ok, value} | {ok: false, error}})."""
|
||||
if not _is_local(request) or not _origin_ok(request):
|
||||
return web.json_response({"error": "This computer only"}, status=403)
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
body = None
|
||||
if not isinstance(body, dict) or not isinstance(body.get("id"), str):
|
||||
return web.json_response({"error": "Expected {id, claim} or {id, outcome}"}, status=400)
|
||||
if body.get("claim") is True:
|
||||
return web.json_response({"granted": mcp_bridge.claim(body["id"], body.get("capabilities"))})
|
||||
return web.json_response({"accepted": mcp_bridge.resolve(body["id"], body.get("outcome"))})
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_route("*", "/anomalous/mcp", api_mcp)
|
||||
app.router.add_post("/anomalous/mcp/bridge", api_mcp_bridge)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""get_situation: where an AI's task stands, in one answer small enough to stay in its chat.
|
||||
|
||||
The task (what the user asked last, questions and gates still waiting for them, decisions so
|
||||
far, pictures made, steps done outside Anomalous), the canvas in brief (web/modules/
|
||||
mcp_canvas_brief.js: flow chains, brief node lines, missing inputs and outputs that fit,
|
||||
ComfyUI's errors), the latest run, and the one thing to do next. For an AI starting a new chat
|
||||
on a task or unsure where it is: a small model reads this instead of describe_canvas, find_nodes
|
||||
and the task's history. Unread messages of the user's come with it as with every answer
|
||||
(ai_panels.attach_pending).
|
||||
"""
|
||||
|
||||
from . import ai_decisions, ai_task_runs, ai_tasks
|
||||
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
|
||||
from .mcp_tools import _schema, tool_text
|
||||
|
||||
BUDGET = 3600 # characters: Antigravity files away an answer past about 4,000 and has the AI open it
|
||||
MAX_TEXT = 200
|
||||
|
||||
|
||||
def _last_asked(task):
|
||||
"""The user's latest words or marks in the task, in short."""
|
||||
for step in reversed(task.get("steps") or []):
|
||||
if step.get("kind") == "user" and step.get("tool") in ("user_message", "user_marks", "user_pictures"):
|
||||
return {"what": step["tool"].removeprefix("user_"), "text": str(step.get("text") or "")[:MAX_TEXT], "id": step["id"]}
|
||||
return None
|
||||
|
||||
|
||||
def _canvas():
|
||||
try:
|
||||
return ask_page("describe_canvas", {"brief": True}, timeout=15)
|
||||
except PageUnavailable as error:
|
||||
return {"unavailable": str(error)}
|
||||
except PageRefused as error: # no workflow open, or the task's tab cannot be opened
|
||||
return {"unavailable": str(error)}
|
||||
|
||||
|
||||
def _next(summary, run, canvas):
|
||||
if summary["asks"]:
|
||||
return f"{summary['asks']} question(s) or gate(s) wait for the user: get_answers waits for their answer."
|
||||
if run.get("state") in ("queued", "running", "asking", "allowed"):
|
||||
return "A run is going: run_status(wait_seconds=55) waits for it; do not run again."
|
||||
if any("not in list" in str(item.get("error")) for item in canvas.get("errors") or ()):
|
||||
return ("A model or file the workflow names is not on this computer: check_workflow_models says which and what "
|
||||
"can stand in (fix_workflow_models, download_missing_models); do not guess other choices.")
|
||||
if canvas.get("errors"):
|
||||
return "ComfyUI refused the last run: fix the nodes in canvas.errors (set_values, connect_nodes), then run_workflow."
|
||||
if canvas.get("unwired"):
|
||||
return "Wire canvas.unwired with connect_nodes (fits lists outputs that fit each), then run_workflow."
|
||||
if run.get("state") == "done" and run.get("pictures"):
|
||||
return "Look at the run's pictures (zoom_image) and tell the user with report what came out."
|
||||
return "Go on with what the user asked (task.asked); run_workflow runs the canvas, report tells the user."
|
||||
|
||||
|
||||
def get_situation(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"))
|
||||
record = ai_tasks.get_task(task_id) if task_id else None
|
||||
run = {"state": "none"}
|
||||
answer = {}
|
||||
if record:
|
||||
summary = ai_tasks.task_summary(record)
|
||||
pictures = [item["filename"] for step in record.get("steps") or [] for item in step.get("results") or [] if item.get("type") == "image"]
|
||||
answer["task"] = {"id": record["id"], "title": record.get("title") or "", "steps": summary["steps"],
|
||||
"asked": _last_asked(record), "waiting_for_user": summary["asks"],
|
||||
"decisions": ai_decisions.lines(record, limit=4), "pictures": pictures[-3:],
|
||||
"outside_anomalous": summary["kinds"].get("outside", 0)}
|
||||
status = ai_task_runs.status(client, session, task_id)
|
||||
run = {key: status[key] for key in ("state", "summary", "pictures") if status.get(key)}
|
||||
else:
|
||||
summary = {"asks": 0}
|
||||
answer["task"] = None
|
||||
answer["task_note"] = "No task yet: begin_task names what the user asked for."
|
||||
canvas = _canvas()
|
||||
answer.update(run=run, canvas=canvas)
|
||||
answer["next"] = _next(summary, run, canvas)
|
||||
# Trim detail before chains and decisions; preserve actionable missing inputs and errors.
|
||||
while len(tool_text(answer)) > BUDGET and canvas.get("lines"):
|
||||
canvas["lines"].pop()
|
||||
canvas["more_nodes"] = canvas.get("more_nodes", 0) + 1
|
||||
while len(tool_text(answer)) > BUDGET and canvas.get("chains"):
|
||||
canvas["chains"].pop()
|
||||
canvas["more_chains"] = canvas.get("more_chains", 0) + 1
|
||||
while len(tool_text(answer)) > BUDGET and record and answer["task"]["decisions"]:
|
||||
answer["task"]["decisions"].pop(0)
|
||||
return answer
|
||||
|
||||
|
||||
SITUATION_TOOLS = {
|
||||
"get_situation": {"run": get_situation, "context": True, "spec": {
|
||||
"name": "get_situation", "title": "Where things stand",
|
||||
"description": "The whole picture in one call: the task (what the user asked last, what waits for them, decisions, "
|
||||
"pictures), the canvas in brief (flow chains, inputs nothing feeds and what fits them, errors), the "
|
||||
"latest run, and next: the one thing to do now. Call it first in a new chat, and whenever unsure.",
|
||||
"inputSchema": _schema({"task": {"type": "string", "description": "begin_task's id (optional)."}}),
|
||||
"annotations": {"title": "Where things stand", "readOnlyHint": True, "destructiveHint": False,
|
||||
"idempotentHint": True, "openWorldHint": False},
|
||||
}},
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
"""MCP tools that shape the AI workbench's record (ai_tasks.py): begin_task names a piece of
|
||||
work, report leaves a note for the user (with pictures made outside Anomalous's tools),
|
||||
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, log_outside keeps what the AI did with other tools
|
||||
(ai_outside.py)."""
|
||||
|
||||
from . import ai_answer_wait, ai_decisions, ai_marks, ai_message_refs, ai_outside, ai_panels, ai_repaint, ai_run_control, ai_run_tracking, ai_task_links, ai_task_runs, ai_tasks, ai_toolbox
|
||||
from .mcp_tools import MAX_LIMIT, ToolError, _schema
|
||||
|
||||
PACK_TYPES = 150 # node types listed per picked pack
|
||||
ASK_WAIT_MIN = 30 # ask_user waits this long even when told not to: the user, reading the work, often answers at once
|
||||
ASK_NEXT = ("Before you end your turn, ask_user what to do next or whether to go ahead: a note or a panel alone leaves "
|
||||
"the user waiting. Only a run, a download or a scan going on lets you end it without asking.")
|
||||
|
||||
|
||||
def begin_task(ctx, title, new_task=False):
|
||||
title = str(title or "").strip()
|
||||
if not title:
|
||||
raise ToolError("title is empty: a few words on what the user asked for.")
|
||||
task = ai_tasks.begin_task(
|
||||
ctx.get("client") or "AI", title, new_task=bool(new_task),
|
||||
session=ctx.get("session") or "", task_id=ctx.get("task"),
|
||||
)
|
||||
answer = {"task": task["id"], "title": task["title"], "note": (
|
||||
"Continue this task; do not begin another for its steps. Every AMB call is a step the user sees, opens and "
|
||||
"undoes in Anomalous's AI workbench. Ask them with ask_user (they answer there, not in your chat); report "
|
||||
"explains a choice or hands in pictures, done=true at the end. Run with run_workflow; a run queued another "
|
||||
"way needs track_run(prompt_ids) with your own IDs. In the task's own tab, do not switch tabs yourself. "
|
||||
"Everything is in AMB's tools (models, pictures, node types, the canvas, runs and their previews): do not "
|
||||
"read ComfyUI's folders or source, run preprocessors or call its routes yourself. Change pictures only by "
|
||||
"running workflows, never by editing files with code. If you use another tool anyway, log_outside right "
|
||||
"after. Unsure where things stand: get_situation. The directory below lists the tools by what you want to do.")}
|
||||
answer["next"] = ("get_situation shows where things stand; a daily run is one call: run_workflow(workflow: a saved "
|
||||
"one, with the prompt, seed or size to change).")
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(task["id"]))
|
||||
if box:
|
||||
answer["toolbox"] = ai_toolbox.words(box)
|
||||
goes_on = ai_task_links.carried(task)
|
||||
if goes_on:
|
||||
answer["continues"] = goes_on # the task this one goes on from, and what was decided there
|
||||
decided = ai_decisions.lines(ai_tasks.get_task(task["id"]))
|
||||
if decided:
|
||||
answer["decisions"] = decided # what was settled so far, the user's own choices in Anomalous too
|
||||
if task.get("about"):
|
||||
# Handed over from a model page, an image or a combo in Anomalous: the ids the tools take.
|
||||
answer["about"] = task["about"]
|
||||
answer["about_note"] = ("The user started this task from these in Anomalous; work with them: model_id goes to "
|
||||
"set_model / add_lora / get_model, image to get_image_info, a combo's name to place_combo, "
|
||||
"a workflow's name to open_workflow.")
|
||||
return answer
|
||||
|
||||
|
||||
def report(ctx, text="", images=None, done=False, reply_to=None, task=None):
|
||||
try:
|
||||
step, missing = ai_tasks.report(
|
||||
ctx.get("client") or "AI", text, images or [], bool(done),
|
||||
session=ctx.get("session") or "", task_id=task or ctx.get("task"), reply_to=reply_to,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise ToolError(str(error))
|
||||
answer = {"noted": True, "pictures": len(step["results"])}
|
||||
if not done:
|
||||
answer["next"] = ASK_NEXT
|
||||
if any(mark in str(text or "") for mark in ("?", "?")):
|
||||
answer["next"] = "Your note asks the user something: ask it with ask_user (options to press) instead. " + ASK_NEXT
|
||||
if missing:
|
||||
answer["not_found"] = missing
|
||||
answer["note"] = "Pictures are paths inside ComfyUI's output folder (subfolder/name.png), as search_images gives them."
|
||||
return answer
|
||||
|
||||
|
||||
def log_outside(ctx, what, why="", files=None, task=None):
|
||||
try:
|
||||
step = ai_outside.log(ctx.get("client") or "AI", what, why, files, session=ctx.get("session") or "",
|
||||
task_id=task or ctx.get("task"))
|
||||
except ValueError as error:
|
||||
raise ToolError(str(error))
|
||||
return {"logged": True, "step": step["id"]}
|
||||
|
||||
|
||||
def log_decision(ctx, title, decision, reason="", rejected=None, by="user", related=None, task=None):
|
||||
try:
|
||||
step = ai_decisions.log(ctx.get("client") or "AI", title, decision, reason, rejected, by, related,
|
||||
session=ctx.get("session") or "", task_id=task or ctx.get("task"))
|
||||
except ValueError as error:
|
||||
raise ToolError(str(error))
|
||||
return {"recorded": True, "step": step["id"], "related": step["decision"]["related"]}
|
||||
|
||||
|
||||
def show_panel(ctx, title, blocks, task=None):
|
||||
try:
|
||||
panel = ai_panels.show(
|
||||
ctx.get("client") or "AI", title, blocks,
|
||||
session=ctx.get("session") or "", task_id=task or ctx.get("task"),
|
||||
)
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
asks = [block["id"] for block in panel["blocks"] if block["type"] in ai_panels.INTERACTIVE]
|
||||
answer = {"panel": panel["id"], "shown": True,
|
||||
"blocks": [{"id": block["id"], "type": block["type"]} for block in panel["blocks"]]}
|
||||
if asks:
|
||||
answer["questions"] = asks
|
||||
answer["note"] = ("The user answers in Anomalous when they get to it; call get_answers with this panel id "
|
||||
"(wait_seconds up to 55 waits for it) before going on with what depends on it.")
|
||||
else:
|
||||
answer["next"] = ASK_NEXT
|
||||
return answer
|
||||
|
||||
|
||||
def ask_user(ctx, question, options=None, multiple=False, other=True, wait_seconds=ai_panels.MAX_WAIT, task=None):
|
||||
"""A question in the AI workbench, where the user watches the work: options to press (one may be
|
||||
recommended), a line of their own; waits for the answer."""
|
||||
block = ({"type": "choices", "id": "answer", "options": options, "multiple": bool(multiple), "other": bool(other)}
|
||||
if options else {"type": "input", "id": "answer", "question": str(question or "")[:200]})
|
||||
try:
|
||||
panel = ai_panels.show(ctx.get("client") or "AI", question, [block], session=ctx.get("session") or "",
|
||||
task_id=task or ctx.get("task"), tool="ask_user")
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
return _answer_of(ctx, panel["id"], wait_seconds)
|
||||
|
||||
|
||||
def propose_repaint_area(ctx, title, marks, areas=None, grow=12, feather=8, note="", wait_seconds=ai_panels.MAX_WAIT):
|
||||
"""The area to repaint for the user's marks, theirs beside the AI's for them to pick (ai_repaint.py); waits like ask_user."""
|
||||
try:
|
||||
panel = ai_repaint.propose(ctx.get("client") or "AI", title, str(marks or ""), areas, grow, feather,
|
||||
str(note or "")[:400], session=ctx.get("session") or "")
|
||||
except ai_repaint.RepaintError as error:
|
||||
raise ToolError(str(error))
|
||||
return _answer_of(ctx, panel["id"], wait_seconds)
|
||||
|
||||
|
||||
def _answer_of(ctx, panel_id, wait_seconds):
|
||||
"""The answer to the question of panel `panel_id` once the user gives it, waiting at least ASK_WAIT_MIN."""
|
||||
client = ctx.get("client") or "AI"
|
||||
try:
|
||||
answer = ai_answer_wait.collect(client, panel_id, max(ASK_WAIT_MIN, float(wait_seconds or 0)))
|
||||
given = next((item for item in answer["answers"] if item["question"] == "answer"), None)
|
||||
if not given:
|
||||
if answer.get("messages") and answer.get("still_waiting"):
|
||||
# Read the user's new instruction before considering an automatic choice.
|
||||
answer.update(ai_answer_wait.wait_on(panel_id, allow_recommended=False))
|
||||
return {"answered": False, "panel": panel_id, **answer}
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
out = {"answered": True, "panel": panel_id, "next": "Go on with the user's answer; log_decision it when it settles a choice."}
|
||||
if answer.get("messages"):
|
||||
out["messages"] = answer["messages"]
|
||||
if given.get("inpaint_image"):
|
||||
out.update(inpaint_image=given["inpaint_image"], inpaint_note=given["note"])
|
||||
elif given.get("note"):
|
||||
out["next"] = given["note"]
|
||||
if given.get("skipped"):
|
||||
out.update(skipped=True, note="The user left this to you: decide it yourself, log_decision what you chose and "
|
||||
"go on. Do not ask the same again.")
|
||||
elif given.get("text"):
|
||||
out["text"] = given["text"]
|
||||
if given.get("choice") == ["other"]:
|
||||
out["note"] = "None of your options: the user wrote their own answer."
|
||||
else:
|
||||
out.update(choice=given.get("choice"), labels=given.get("labels"))
|
||||
if given.get("recommended_on_timeout"):
|
||||
out.update(recommended_on_timeout=True, next=ai_answer_wait.TOOK)
|
||||
return out
|
||||
|
||||
|
||||
def update_panel(ctx, panel, title=None, blocks=None, closed=None, task=None):
|
||||
del ctx, task
|
||||
try:
|
||||
updated = ai_panels.update(panel, title, blocks, closed)
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
return {"updated": True, "blocks": [{"id": block.get("id"), "type": block["type"]}
|
||||
for block in updated["blocks"]]}
|
||||
|
||||
|
||||
def run_status(ctx, wait_seconds=0, task=None):
|
||||
try:
|
||||
return ai_task_runs.status(ctx.get("client") or "AI", ctx.get("session") or "", task or ctx.get("task"), wait_seconds)
|
||||
except ValueError as error:
|
||||
raise ToolError(str(error))
|
||||
|
||||
|
||||
def show_run_map(ctx, title, run=None, 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"))
|
||||
runs = [step for step in (ai_tasks.get_task(task_id) or {}).get("steps", []) if step.get("tool") == "run_workflow"] if task_id else []
|
||||
found = next((step for step in reversed(runs) if step.get("map") and (not run or step["id"] == run)), None)
|
||||
if not found:
|
||||
raise ToolError("No run of this task has its shape yet: it is read once ComfyUI has queued the run (run_workflow).")
|
||||
try:
|
||||
panel = ai_panels.show(client, title, [{"type": "run_map", "step": found["id"]}], session=session, task_id=task_id,
|
||||
tool="show_run_map")
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
boxes = found["map"]["boxes"]
|
||||
return {"panel": panel["id"], "shown": True, "run": found["id"], "changes": found.get("changes") or [],
|
||||
"boxes": [{"node_id": box["id"], "title": box["title"], "role": box["role"]} for box in boxes]}
|
||||
|
||||
|
||||
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 .mcp_canvas_edit import find_nodes
|
||||
|
||||
task_id = task or ctx.get("task") or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(task_id)) if task_id else None
|
||||
if not box:
|
||||
return {"picked": False, "note": "The user picked nothing for this task; use what fits."}
|
||||
answer = {"picked": True, "mode": box["mode"], "note": ai_toolbox.words(box)}
|
||||
if box.get("nodes"):
|
||||
found = find_nodes(ctx, types=box["nodes"])
|
||||
answer["nodes"] = found["nodes"]
|
||||
if found.get("not_found"):
|
||||
answer["page_only_nodes"] = found["not_found"] # helpers that live only in the page: add_node takes them
|
||||
if box.get("packs"):
|
||||
from .mcp_canvas_edit import object_info
|
||||
|
||||
info = object_info(ctx)
|
||||
answer["packs"] = []
|
||||
for pack in box["packs"]:
|
||||
types = sorted(name for name, item in info.items() if ai_toolbox.source_of(item) == pack)
|
||||
answer["packs"].append({"pack": pack, "count": len(types), "node_types": [
|
||||
{"type": name, "display_name": info[name].get("display_name") or name, "category": info[name].get("category") or ""}
|
||||
for name in types[:PACK_TYPES]]})
|
||||
answer["packs_note"] = "Every node type of a pack is the user's pick; find_nodes(types=[...]) gives one in full."
|
||||
for kind in ("combos", "workflows", "prompts"):
|
||||
if box.get(kind):
|
||||
answer[kind] = [found for name in box[kind] if (found := ai_message_refs.lookup(kind, name)) is not None]
|
||||
if box.get("models"):
|
||||
answer["models"] = [ai_message_refs.lookup("models", model_id) or {"model_id": model_id, "missing": True} for model_id in box["models"]]
|
||||
return answer
|
||||
|
||||
|
||||
def get_answers(ctx, panel=None, wait_seconds=0, task=None):
|
||||
try:
|
||||
client = ctx.get("client") or "AI"
|
||||
task_id = task or ctx.get("task") or None
|
||||
if not task_id and ctx.get("session"):
|
||||
task_id = ai_tasks.active_task_id(client, ctx["session"]) or ""
|
||||
answer = ai_answer_wait.collect(client, panel, wait_seconds, task_id=task_id)
|
||||
return answer, ai_marks.image_items(answer.get("messages") or []) # marked pictures, as pictures
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
|
||||
|
||||
BLOCKS_HELP = (
|
||||
"blocks, top to bottom, each {type, ...}: heading {text}; text {text, tone: normal|muted|tip|warning}; "
|
||||
"images {items: [{image: 'subfolder/name.png' from search_images, caption, marks: [{x, y, r | w, h, label, "
|
||||
"color: red|green|blue|yellow|purple}]}], layout: row|grid|compare} (marks: circles or boxes with a label, x/y/w/h/r "
|
||||
"as fractions 0-1 of the picture, x/y the centre; a click on the label zooms there); "
|
||||
"slider {before: {image}, after: {image}, before_label, after_label} (one picture over the other, a line to drag); "
|
||||
"picks {id, question, items: [{id, image, label, reason, recommended}] (2-12), multiple} (pictures to choose from, "
|
||||
"each with 'take this one'; the user can also put a picture's workflow back on the canvas); "
|
||||
"diagram {nodes: [{id, label, detail, node_id, color}], edges: [{from, to, label}], direction: right|down} (boxes and "
|
||||
"arrows Anomalous lays out; node_id from describe_canvas makes a box find that node); "
|
||||
"models {items: [{model_id from search_models, note}]} (cards with their covers, a click opens them); "
|
||||
"table {columns: [...], rows: [[...]]}; list {items: [...], ordered}; params {items: [{label, value}]}; "
|
||||
"choices {id, question, options: [{id, label, detail}] (2-8), multiple} (buttons the user presses); "
|
||||
"input {id, question, placeholder} (a line the user types); progress {label, value, max} (your own "
|
||||
"numbers: they never move by themselves, so keep them current with update_panel; a run's real progress shows "
|
||||
"in the workbench already, draw none for it); divider {}. "
|
||||
"Each block may have a stable id; the tool returns block ids in display order. "
|
||||
"Plain text only (no markup, no links); up to 30 blocks, 12 pictures or models a block."
|
||||
)
|
||||
|
||||
_TASK_PROP = {"type": "string", "description": "begin_task's id (optional)."}
|
||||
|
||||
|
||||
def _task_tool(name, title, description, run, properties, required=()):
|
||||
return name, {"run": run, "context": True, "spec": {
|
||||
"name": name, "title": title, "description": description,
|
||||
"inputSchema": _schema(properties, required),
|
||||
"annotations": {"title": title, "readOnlyHint": name in ("run_status", "get_toolbox"), "destructiveHint": False,
|
||||
"idempotentHint": False, "openWorldHint": False},
|
||||
}}
|
||||
|
||||
|
||||
def _question_with_images(run):
|
||||
"""The question receipt also carries pictures the user sent while it was waiting."""
|
||||
def call(ctx, **arguments):
|
||||
result = run(ctx, **arguments)
|
||||
return result, ai_marks.image_items(result.get("messages") or [])
|
||||
return call
|
||||
|
||||
|
||||
TASK_TOOLS = dict([
|
||||
_task_tool("track_run", "Track an externally submitted run",
|
||||
"Immediately after YOU submit to this ComfyUI server via /prompt or another tool, attach the exact "
|
||||
"prompt_ids returned by your submissions to your task. This does NOT submit or rerun anything. "
|
||||
"The workbench then shows real queue/node progress and results. Never use another user's IDs or "
|
||||
"guess from queue differences. Repeating the same IDs is safe. Waits up to wait_seconds and returns "
|
||||
"state, summary, pictures and errors; if still running, keep waiting with run_status(wait_seconds=55).",
|
||||
ai_run_tracking.track_run, {"prompt_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 8},
|
||||
"task": _TASK_PROP, "wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 15}}, ["prompt_ids"]),
|
||||
_task_tool("begin_task", "Begin a task",
|
||||
"Call first: names what the user asked for in Anomalous's AI workbench, where they see, open and undo "
|
||||
"each of your steps. Continues the waiting or active task; new_task=true only for a separate request.",
|
||||
begin_task, {"title": {"type": "string", "description": "A few words, in the user's language."},
|
||||
"new_task": {"type": "boolean", "default": False}},
|
||||
["title"]),
|
||||
_task_tool("report", "Tell the user",
|
||||
"A note in the AI workbench, where the user reads your work: your choices, what to look at, what is "
|
||||
"left, your answers. Markdown headings, lists, bold and code are laid out. reply_to: the id of the "
|
||||
"user's message you answer. images: output pictures (subfolder/name.png) to keep with the task. "
|
||||
"done=true ends it.",
|
||||
report, {"text": {"type": "string"},
|
||||
"images": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT},
|
||||
"done": {"type": "boolean", "default": False},
|
||||
"reply_to": {"type": "string", "description": "The message id you answer."},
|
||||
"task": _TASK_PROP}),
|
||||
_task_tool("log_outside", "Say what you did outside Anomalous",
|
||||
"Right after using a tool that is not Anomalous's (a command, a script, files, another server): what "
|
||||
"you ran, why, and the files it touched, for the task's timeline. Prefer Anomalous's tools.",
|
||||
log_outside, {"what": {"type": "string"}, "why": {"type": "string"},
|
||||
"files": {"type": "array", "items": {"type": "string"}, "maxItems": ai_outside.MAX_FILES},
|
||||
"task": _TASK_PROP},
|
||||
["what"]),
|
||||
_task_tool("log_decision", "Record a decision",
|
||||
"Records a settled decision of the task (a style, a model, a fix, what to drop) where the user looks "
|
||||
"back on it: title, decision, reason, rejected options, by (user, ai or together), related step ids. "
|
||||
"The user's own choices in Anomalous are kept already.",
|
||||
log_decision, {"title": {"type": "string"}, "decision": {"type": "string"}, "reason": {"type": "string"},
|
||||
"rejected": {"type": "array", "items": {"type": "string"}, "maxItems": ai_decisions.MAX_REJECTED},
|
||||
"by": {"type": "string", "enum": list(ai_decisions.BY), "default": "user"},
|
||||
"related": {"type": "array", "items": {"type": "string"}, "maxItems": ai_decisions.MAX_RELATED},
|
||||
"task": _TASK_PROP},
|
||||
["title", "decision"]),
|
||||
_task_tool("show_panel", "Show the user a panel",
|
||||
"Draws a panel in the AI workbench, in Anomalous's own style, from blocks: to compare pictures side by "
|
||||
"side, present models with their covers, lay out settings, a table, and to ask (buttons or a line of "
|
||||
"text). Use a new panel for each new phase (plan, workflow, then results), in the order they happen. "
|
||||
"Use it when a choice is the user's, or to show results better than words do. " + BLOCKS_HELP,
|
||||
show_panel, {"title": {"type": "string"},
|
||||
"blocks": {"type": "array", "items": {"type": "object"}, "maxItems": ai_panels.MAX_BLOCKS},
|
||||
"task": _TASK_PROP},
|
||||
["title", "blocks"]),
|
||||
_task_tool("ask_user", "Ask the user",
|
||||
"Asks the user in the AI workbench and waits: a question with 2-8 options ({label, detail, "
|
||||
"recommended}; mark your suggestion recommended=true), and a line for their own answer unless "
|
||||
"other=false; no options: a free answer. Use it for every choice that is the user's, not your chat's "
|
||||
f"question form. Waits at least {ASK_WAIT_MIN} s, up to wait_seconds; unanswered and keep_waiting: wait on "
|
||||
"with get_answers. End every round of work with it.",
|
||||
_question_with_images(ask_user), {"question": {"type": "string"},
|
||||
"options": {"type": "array", "maxItems": 8, "items": {"type": "object", "properties": {
|
||||
"label": {"type": "string"}, "detail": {"type": "string"}, "recommended": {"type": "boolean"}}}},
|
||||
"multiple": {"type": "boolean", "default": False}, "other": {"type": "boolean", "default": True},
|
||||
"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_panels.MAX_WAIT, "default": ai_panels.MAX_WAIT},
|
||||
"task": _TASK_PROP},
|
||||
["question"]),
|
||||
_task_tool("propose_repaint_area", "Agree on the area to repaint",
|
||||
"Before repainting only the marked areas: the user's marks are rough, so show them the area you would "
|
||||
"repaint. marks: the marks' message id. areas: boxes or ellipses {shape, x, y, w, h} as fractions of the "
|
||||
"picture (x, y its top left), or none to keep the marks; grow: pixels added around; feather: pixels of "
|
||||
"soft edge. The user sees theirs and yours side by side and picks one, or marks again; with an area "
|
||||
"picked the answer has inpaint_image (the picture with that area as its alpha, in ComfyUI's input "
|
||||
"folder) and inpaint_note. Build the repaint yourself. Waits like ask_user.",
|
||||
_question_with_images(propose_repaint_area), {"title": {"type": "string", "description": "A few words, in the user's language."},
|
||||
"marks": {"type": "string"},
|
||||
"areas": {"type": "array", "maxItems": ai_repaint.MAX_AREAS, "items": {"type": "object", "properties": {
|
||||
"shape": {"type": "string", "enum": list(ai_repaint.SHAPES)}, "x": {"type": "number"},
|
||||
"y": {"type": "number"}, "w": {"type": "number"}, "h": {"type": "number"}}}},
|
||||
"grow": {"type": "integer", "minimum": 0, "maximum": ai_repaint.MAX_GROW, "default": 12},
|
||||
"feather": {"type": "integer", "minimum": 0, "maximum": ai_repaint.MAX_FEATHER, "default": 8},
|
||||
"note": {"type": "string", "description": "Why you moved it, in a line."},
|
||||
"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_panels.MAX_WAIT, "default": ai_panels.MAX_WAIT}},
|
||||
["title", "marks"]),
|
||||
_task_tool("update_panel", "Change a panel",
|
||||
"Adds new output at the BOTTOM of an existing panel, preserving earlier content and its order. "
|
||||
"Send only the new blocks; repeated unchanged blocks are kept once. To deliberately correct an "
|
||||
"existing block or update a progress bar/question in place, include its returned block id and "
|
||||
"the same type. Omitted blocks are retained, not deleted. Never reuse an old block id for a new "
|
||||
"result: append it or use show_panel for the next phase. Up to 30 total blocks per panel. "
|
||||
"Can also change title, or set closed=true to stop answers; existing answers stay.",
|
||||
update_panel, {"panel": {"type": "string"}, "title": {"type": "string"},
|
||||
"blocks": {"type": "array", "items": {"type": "object"}, "maxItems": ai_panels.MAX_BLOCKS},
|
||||
"closed": {"type": "boolean"}, "task": _TASK_PROP}, ["panel"]),
|
||||
_task_tool("run_status", "How the run is going",
|
||||
"Your task's latest run: none, queued (runs ahead), running (node, steps, percent), done (its "
|
||||
"pictures; the first answer telling you it ended attaches up to 4) or failed; summary says it in a "
|
||||
"sentence. wait_seconds (up to 55) waits for the end. Call it instead of guessing or looking in "
|
||||
"folders.",
|
||||
run_status, {"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_task_runs.MAX_STATUS_WAIT, "default": 0},
|
||||
"task": _TASK_PROP}),
|
||||
_task_tool("show_run_map", "Show what the run really was",
|
||||
"Puts the shape of your task's latest run (or `run`, a run step id) on the user's stage: the nodes it "
|
||||
"really ran as boxes (models, LoRAs with weights, prompts, size, sampler settings, saving), linked, "
|
||||
"each one locates its node on the canvas, with what changed since the run before. Anomalous reads it "
|
||||
"from the prompt ComfyUI queued, so use this instead of drawing the workflow yourself; add your "
|
||||
"explanation with report. title: a few words in the user's language.",
|
||||
show_run_map, {"title": {"type": "string"}, "run": {"type": "string"}, "task": _TASK_PROP}, ["title"]),
|
||||
_task_tool("stop_run", "Stop the run",
|
||||
"Stops your task's run: its queued prompts leave the queue, the running one is interrupted (never "
|
||||
"anyone else's). When run_status warns it is far too big and the user agrees, or when the user asks.",
|
||||
stop_run, {"task": _TASK_PROP}),
|
||||
_task_tool("get_toolbox", "What the user picked for you",
|
||||
"The node types, whole custom node packs, combos, saved workflows, prompts and models the user picked in "
|
||||
"the AI workbench for this task, in full (inputs and outputs of the nodes, each pack's node types, the "
|
||||
"combos' models and prompts…), and how strictly: prefer (use them first) or only (add_node and "
|
||||
"new_workflow accept only those node types and the packs' besides ComfyUI's built-in ones). "
|
||||
"begin_task says when there are any.",
|
||||
get_toolbox, {"task": _TASK_PROP}),
|
||||
_task_tool("get_answers", "Read the user's answers and messages",
|
||||
"What the user picked or typed on your panels and wrote to you in the AI workbench since you last "
|
||||
"asked, each once (kind=change: something they changed themselves; keep it). panel: one panel. "
|
||||
"wait_seconds (up to 55) waits when nothing is there. still_waiting: questions not answered, and "
|
||||
"keep_waiting=true: call it again; skipped=true: they left it to you, decide yourself.",
|
||||
get_answers, {"panel": {"type": "string"},
|
||||
"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_panels.MAX_WAIT, "default": 0},
|
||||
"task": _TASK_PROP}),
|
||||
])
|
||||
@@ -0,0 +1,577 @@
|
||||
"""The MCP endpoint's (mcp_server.py) reading tools: views of the library (mcp_actions.py acts).
|
||||
|
||||
`call_tool` runs any tool of a registry, these and mcp_actions.py's. search_models and get_model
|
||||
are offered through mcp_model_use.py, which adds what loads each model.
|
||||
|
||||
Each tool is a plain function over the same stores the browser's pages use (model catalog
|
||||
and sidecars, output gallery, combos, workflow recipes, saved prompts, generated audio, scan
|
||||
results, activity log). They never write. Results are small, already-summarised JSON (an AI
|
||||
reads them), with ids the detail tools take back: model_id "type:path_idx:relative/path" and
|
||||
image "subfolder/file.png" under ComfyUI's output folder. A tool's mistake (bad id, missing
|
||||
file) comes back as a tool error the AI can read and correct, never as an exception.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import folder_paths
|
||||
from PIL import Image
|
||||
|
||||
from .activity_log import list_entries
|
||||
from .audio_catalog import _gallery_page as _audio_page
|
||||
from .gallery_routes import _gallery_listing
|
||||
from .image_search import read_png_text
|
||||
from .kept_images import _kept_images
|
||||
from .material_store import _list_materials, _read_material, get_materials_dir
|
||||
from .media_routes import _build_card_thumbnail
|
||||
from .metadata import get_metadata
|
||||
from .mcp_arguments import prepare_arguments, renaming_note
|
||||
from .model_catalog import _iter_search_models
|
||||
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
from .notebooks import _list_notebooks
|
||||
from .path_utils import model_file_within, resolve_folder_subdir, resolve_within
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
from .scan_report import last_scan_path
|
||||
from .scan_summary import _summarize
|
||||
|
||||
MAX_LIMIT = 50
|
||||
MAX_SEARCH_PICTURES = 6 # search_images(with_pictures): the first few attached
|
||||
MAX_TEXT = 1500
|
||||
MAX_IMAGE_BYTES = 1_500_000
|
||||
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
|
||||
SOURCES = {"civitai": "civitai", "local": "inferred_from_file", "": "not_scanned"}
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
"""A problem the AI can fix (bad id, nothing there); shown to it as the tool's result."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
def _text(value, limit=MAX_TEXT):
|
||||
"""Plain text from HTML-ish Civitai descriptions, shortened."""
|
||||
value = html.unescape(re.sub(r"<[^>]+>", " ", str(value or "")))
|
||||
value = re.sub(r"\s+", " ", value).strip()
|
||||
return value if len(value) <= limit else value[:limit] + "…"
|
||||
|
||||
|
||||
def _when(seconds):
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(float(seconds)).isoformat(timespec="seconds")
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _limit(value, default=20):
|
||||
try:
|
||||
return max(1, min(MAX_LIMIT, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _terms(query):
|
||||
return [term for term in str(query or "").lower().split() if term]
|
||||
|
||||
|
||||
def _matches(terms, *texts):
|
||||
haystack = " ".join(str(text or "") for text in texts).lower()
|
||||
return all(term in haystack for term in terms)
|
||||
|
||||
|
||||
def _model_id(folder_type, path_idx, base_dir, file_path):
|
||||
return f"{folder_type}:{path_idx}:{os.path.relpath(file_path, base_dir).replace(os.sep, '/')}"
|
||||
|
||||
|
||||
def _resolve_model(model_id):
|
||||
"""(type, path_idx, base_dir, file_path) for a model_id from search_models."""
|
||||
parts = str(model_id or "").split(":", 2)
|
||||
if len(parts) != 3:
|
||||
raise ToolError("model_id looks like 'loras:0:folder/file.safetensors'; take it from search_models.")
|
||||
folder_type, path_idx, rel = parts
|
||||
rel = rel.replace("\\", "/").strip("/")
|
||||
subfolder, filename = os.path.split(rel)
|
||||
try:
|
||||
path_idx = int(path_idx)
|
||||
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, "/" + subfolder)
|
||||
file_path = model_file_within(target_dir, filename)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise ToolError(f"No models folder for {model_id}.")
|
||||
if not filename.lower().endswith(MODEL_EXTENSIONS) or not os.path.isfile(file_path):
|
||||
raise ToolError(f"No model file at {model_id}; it may have been moved or renamed.")
|
||||
return folder_type, path_idx, base_dir, file_path
|
||||
|
||||
|
||||
def _resolve_image(image):
|
||||
"""The output image at `image` ('subfolder/file.png'), checked to stay inside the output folder."""
|
||||
rel = str(image or "").replace("\\", "/").strip("/")
|
||||
try:
|
||||
path = resolve_within(folder_paths.get_output_directory(), rel)
|
||||
except ValueError:
|
||||
raise ToolError("image is a path under ComfyUI's output folder, as search_images returns it.")
|
||||
if os.path.splitext(path)[1].lower() not in IMAGE_TYPES or not os.path.isfile(path):
|
||||
raise ToolError(f"No output image at {image}: it is not (or no longer) in ComfyUI's output folder; "
|
||||
"the user may have deleted it. search_images lists the pictures there.")
|
||||
return rel, path
|
||||
|
||||
|
||||
def _resolve_folder_image(image, folder):
|
||||
"""(rel, path) of picture `image` ('subfolder/file.png') in ComfyUI's output, input (where
|
||||
LoadImage reads) or temp folder (where PreviewImage writes), checked to stay inside it."""
|
||||
if folder == "output":
|
||||
return _resolve_image(image)
|
||||
if folder not in ("input", "temp"):
|
||||
raise ToolError("folder is output, input or temp.")
|
||||
rel = str(image or "").replace("\\", "/").strip("/")
|
||||
try:
|
||||
path = resolve_within(getattr(folder_paths, f"get_{folder}_directory")(), rel)
|
||||
except ValueError:
|
||||
raise ToolError(f"image is a path under ComfyUI's {folder} folder.")
|
||||
if os.path.splitext(path)[1].lower() not in IMAGE_TYPES or not os.path.isfile(path):
|
||||
raise ToolError(f"No picture at {image} in ComfyUI's {folder} folder.")
|
||||
return rel, path
|
||||
|
||||
|
||||
def _input_facts(path):
|
||||
"""Size of an input or preview picture and, when it has an alpha, the area LoadImage gives as MASK (1 - alpha)."""
|
||||
try:
|
||||
with Image.open(path) as picture:
|
||||
facts = {"width": picture.width, "height": picture.height}
|
||||
if "A" not in picture.getbands():
|
||||
facts["mask"] = "none: no alpha, so LoadImage's MASK is empty"
|
||||
return facts
|
||||
area = picture.getchannel("A").point(lambda value: 255 if value < 128 else 0)
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
return {"note": "The picture cannot be read."}
|
||||
box = area.getbbox()
|
||||
if not box:
|
||||
facts["mask"] = "none: fully opaque, so LoadImage's MASK is empty"
|
||||
return facts
|
||||
share = area.histogram()[255] / (facts["width"] * facts["height"])
|
||||
left, top, right, bottom = (round(100 * value / size) for value, size in zip(box, (facts["width"], facts["height"]) * 2))
|
||||
facts["mask"] = {"share": f"{round(100 * share, 1)}%", "area": f"x {left}-{right}%, y {top}-{bottom}% of the picture",
|
||||
"meaning": "LoadImage gives this area as MASK: what to repaint"}
|
||||
return facts
|
||||
|
||||
|
||||
def _image_content(path):
|
||||
"""An MCP image item: the 512 px thumbnail the gallery uses, or None when too big or unreadable."""
|
||||
try:
|
||||
thumb = _build_card_thumbnail(path)
|
||||
with open(thumb, "rb") as source:
|
||||
data = source.read(MAX_IMAGE_BYTES + 1)
|
||||
except OSError:
|
||||
return None
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
return None
|
||||
mime = IMAGE_TYPES.get(os.path.splitext(thumb)[1].lower(), "image/webp")
|
||||
return {"type": "image", "data": base64.b64encode(data).decode("ascii"), "mimeType": mime}
|
||||
|
||||
|
||||
def _kept_lookup():
|
||||
return {(item["subfolder"], item["filename"]): {kind: (value or {}).get("name") for kind, value in item.items()
|
||||
if kind in ("recipe", "combo", "prompt") and value}
|
||||
for item in _kept_images()}
|
||||
|
||||
|
||||
def _model_brief(folder_type, path_idx, base_dir, file_path, meta):
|
||||
rel_dir = os.path.relpath(os.path.dirname(file_path), base_dir).replace(os.sep, "/")
|
||||
try:
|
||||
size_mb = round(os.path.getsize(file_path) / 1048576, 1)
|
||||
except OSError:
|
||||
size_mb = None
|
||||
return {
|
||||
"model_id": _model_id(folder_type, path_idx, base_dir, file_path),
|
||||
"name": meta.get("custom_name") or meta.get("name") or os.path.basename(file_path),
|
||||
"file": os.path.basename(file_path),
|
||||
"type": folder_type,
|
||||
"folder": "" if rel_dir == "." else rel_dir,
|
||||
"base_model": meta.get("baseModel") or "",
|
||||
"source": SOURCES.get(meta.get("info_source", ""), "not_scanned"),
|
||||
"trigger_words": list(meta.get("trainedWords") or [])[:8],
|
||||
"size_mb": size_mb,
|
||||
}
|
||||
|
||||
|
||||
def _each_model():
|
||||
"""Every model file once (folders registered twice, like unet and diffusion_models, count once).
|
||||
ComfyUI also registers custom_nodes, whose node packs carry files of their own: not the library."""
|
||||
seen = set()
|
||||
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
|
||||
if folder_type == "custom_nodes" or not file_path.lower().endswith(MODEL_EXTENSIONS):
|
||||
continue
|
||||
real = os.path.normcase(os.path.realpath(file_path))
|
||||
if real in seen:
|
||||
continue
|
||||
seen.add(real)
|
||||
yield folder_type, path_idx, base_dir, file_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- models and scans
|
||||
|
||||
def search_models(query="", type="", base_model="", source="", limit=20):
|
||||
terms, wanted_base, found = _terms(query), str(base_model or "").lower(), []
|
||||
total = 0
|
||||
type = str(type or "").lower()
|
||||
for folder_type, path_idx, base_dir, file_path in _each_model():
|
||||
if type and type not in (folder_type, folder_type.rstrip("s")): # "checkpoint" finds the checkpoints too
|
||||
continue
|
||||
meta = get_metadata(file_path)
|
||||
if wanted_base and wanted_base not in str(meta.get("baseModel") or "").lower():
|
||||
continue
|
||||
if source and SOURCES.get(meta.get("info_source", ""), "not_scanned") != source:
|
||||
continue
|
||||
if not _matches(terms, os.path.relpath(file_path, base_dir), meta.get("name"), meta.get("custom_name"),
|
||||
meta.get("baseModel"), " ".join(meta.get("trainedWords") or [])):
|
||||
continue
|
||||
total += 1
|
||||
if len(found) < _limit(limit):
|
||||
found.append(_model_brief(folder_type, path_idx, base_dir, file_path, meta))
|
||||
return {"total_matches": total, "models": found}
|
||||
|
||||
|
||||
def get_model(model_id, include_cover=False):
|
||||
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
|
||||
meta = get_metadata(file_path)
|
||||
detail = _model_brief(folder_type, path_idx, base_dir, file_path, meta)
|
||||
detail.update({
|
||||
"trigger_words": list(meta.get("trainedWords") or []),
|
||||
"civitai_name": meta.get("name") or "",
|
||||
"version": meta.get("version_name") or "",
|
||||
"civitai_url": meta.get("civitai_url") or "",
|
||||
"download_link": meta.get("source_url") or "",
|
||||
"user_notes": meta.get("custom_notes") or "",
|
||||
"fields_set_by_user": list(meta.get("user_fields") or []),
|
||||
"description": _text(meta.get("description")),
|
||||
"sha256": meta.get("hash") or "",
|
||||
})
|
||||
if meta.get("info_source") == "local":
|
||||
detail["why_not_on_civitai"] = meta.get("unmatched_reason") or "unknown"
|
||||
extra = []
|
||||
if include_cover:
|
||||
base = os.path.splitext(file_path)[0]
|
||||
cover = next((base + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
|
||||
if os.path.splitext(suffix)[1].lower() in IMAGE_TYPES and os.path.isfile(base + suffix)), None)
|
||||
item = _image_content(cover) if cover else None
|
||||
detail["cover"] = "attached" if item else "none"
|
||||
extra = [item] if item else []
|
||||
return detail, extra
|
||||
|
||||
|
||||
def library_overview():
|
||||
by_type, by_base = {}, {}
|
||||
for folder_type, _idx, _base, file_path in _each_model():
|
||||
by_type[folder_type] = by_type.get(folder_type, 0) + 1
|
||||
base = get_metadata(file_path).get("baseModel") or "unknown"
|
||||
by_base[base] = by_base.get(base, 0) + 1
|
||||
summary = _summarize()
|
||||
return {
|
||||
"models_by_type": dict(sorted(by_type.items(), key=lambda item: -item[1])),
|
||||
"models_by_base_model": dict(sorted(by_base.items(), key=lambda item: -item[1])[:20]),
|
||||
"scan": {key: summary[key] for key in ("total", "matched", "unmatched", "new", "pending")},
|
||||
"combos": len(_list_notebooks()),
|
||||
"workflow_recipes": len(_list_recipes(get_recipes_dir())),
|
||||
"saved_prompts": sum(1 for item in _list_materials(get_materials_dir()) if item.get("kind") == "prompt_plan"),
|
||||
}
|
||||
|
||||
|
||||
def scan_report():
|
||||
summary = _summarize()
|
||||
try:
|
||||
with open(last_scan_path(), encoding="utf-8") as source:
|
||||
last = json.load(source)
|
||||
except (OSError, ValueError):
|
||||
last = None
|
||||
report = {
|
||||
"counts": {key: summary[key] for key in ("total", "matched", "unmatched", "new", "pending")},
|
||||
"meaning": "matched: found on Civitai; unmatched: base model inferred from the file (reason says why); "
|
||||
"new: never scanned; pending: unmatched only because Civitai was not asked (offline or no connection).",
|
||||
"unmatched_models": summary["unmatched_models"][:MAX_LIMIT],
|
||||
"not_scanned_models": summary["new_models"][:MAX_LIMIT],
|
||||
}
|
||||
if isinstance(last, dict) and last.get("counts"):
|
||||
report["last_scan"] = {"finished": _when(last.get("finished")), "kind": last.get("kind"),
|
||||
"counts": last.get("counts"), "civitai_unreachable": bool(last.get("civitai_down")),
|
||||
"models": (last.get("files") or [])[:30]}
|
||||
return report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- outputs
|
||||
|
||||
def search_images(query="", limit=20, page=1, with_pictures=False):
|
||||
images = _gallery_listing(folder_paths.get_output_directory(), False, str(query or "").strip())
|
||||
limit = _limit(limit)
|
||||
try:
|
||||
page = max(1, int(page))
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
kept = _kept_lookup()
|
||||
chosen = images[(page - 1) * limit:page * limit]
|
||||
found = {
|
||||
"total_matches": len(images), "page": page,
|
||||
"images": [{"image": "/".join(part for part in (item["subfolder"], item["filename"]) if part),
|
||||
"created": _when(item.get("mtime")),
|
||||
**({"kept_as": kept[(item["subfolder"], item["filename"])]}
|
||||
if (item["subfolder"], item["filename"]) in kept else {})}
|
||||
for item in chosen],
|
||||
}
|
||||
if not with_pictures:
|
||||
return found
|
||||
pictures = [] # the first few themselves, in the order listed, so an AI that sees pictures need not ask for each
|
||||
for entry in found["images"][:MAX_SEARCH_PICTURES]:
|
||||
item = _image_content(_resolve_image(entry["image"])[1])
|
||||
entry["attached"] = bool(item)
|
||||
if item:
|
||||
pictures.append(item)
|
||||
return found, pictures
|
||||
|
||||
|
||||
def get_image_info(image, include_image=False, folder="output"):
|
||||
if folder != "output":
|
||||
rel, path = _resolve_folder_image(image, folder)
|
||||
info = {"image": rel, "folder": folder, "created": _when(os.path.getmtime(path)), **_input_facts(path)}
|
||||
extra = [item for item in [_image_content(path)] if include_image and item]
|
||||
if include_image:
|
||||
info["image_attached"] = bool(extra)
|
||||
return info, extra
|
||||
rel, path = _resolve_image(image)
|
||||
info = {"image": rel, "created": _when(os.path.getmtime(path))}
|
||||
chunks = read_png_text(path) if path.lower().endswith(".png") else {}
|
||||
try:
|
||||
prompt = json.loads(chunks.get("prompt") or "null")
|
||||
except ValueError:
|
||||
prompt = None
|
||||
if isinstance(prompt, dict):
|
||||
nodes, models = [], set()
|
||||
for node_id, node in list(prompt.items())[:80]:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
inputs = {}
|
||||
for key, value in (node.get("inputs") or {}).items():
|
||||
if isinstance(value, str):
|
||||
inputs[key] = _text(value, 2000) if len(value) > 2000 else value
|
||||
if value.lower().endswith(MODEL_EXTENSIONS):
|
||||
models.add(value.replace("\\", "/"))
|
||||
elif isinstance(value, (int, float, bool)):
|
||||
inputs[key] = value
|
||||
nodes.append({"id": node_id, "type": node.get("class_type"), "inputs": inputs})
|
||||
info.update(models_used=sorted(models), nodes=nodes)
|
||||
elif chunks.get("parameters"):
|
||||
info["parameters"] = _text(chunks["parameters"], 4000)
|
||||
else:
|
||||
info["note"] = "No generation settings are stored in this image."
|
||||
kept = _kept_lookup().get(tuple(rel.rsplit("/", 1)) if "/" in rel else ("", rel))
|
||||
if kept:
|
||||
info["kept_as"] = kept
|
||||
extra = []
|
||||
if include_image:
|
||||
item = _image_content(path)
|
||||
info["image_attached"] = bool(item)
|
||||
extra = [item] if item else []
|
||||
return info, extra
|
||||
|
||||
|
||||
def search_audio(query="", limit=20):
|
||||
items, total, _more = _audio_page(folder_paths.get_output_directory(), 1, _limit(limit), str(query or "").lower().strip())
|
||||
return {"total_matches": total, "audio": [{
|
||||
"file": "/".join(part for part in (item["subfolder"], item["filename"]) if part),
|
||||
"created": _when(item.get("mtime")),
|
||||
"speech": (item.get("generation") or {}).get("speech") or "",
|
||||
"voice_sample": (item.get("generation") or {}).get("sample") or "",
|
||||
"seed": (item.get("generation") or {}).get("seed"),
|
||||
} for item in items]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- what the user kept
|
||||
|
||||
def _model_name(model):
|
||||
if isinstance(model, dict):
|
||||
return model.get("filename") or model.get("name") or ""
|
||||
return str(model or "")
|
||||
|
||||
|
||||
def combo_view(note):
|
||||
"""A combo (one of _list_notebooks) as the AI and the materials picker see it, with its LoRAs and texts."""
|
||||
data = note.get("data") or {}
|
||||
if data.get("kind") == "nodes": # its own node structure: model and text slots by their names
|
||||
structure = data.get("structure") or {}
|
||||
values = data.get("values") or {}
|
||||
slots = structure.get("slots") or []
|
||||
models = [{"slot": s.get("label") or s.get("widget"), "folder": s.get("folder") or "",
|
||||
"file": os.path.basename(str(values.get(s["id"]) or "").replace("\\", "/"))}
|
||||
for s in slots if s.get("kind") == "model"]
|
||||
texts = [{"slot": s.get("label") or s.get("widget"), "text": _text(values.get(s["id"]), 400)}
|
||||
for s in slots if s.get("kind") == "text"]
|
||||
combo = {"name": note.get("name"), "kind": "node structure", "models": models, "texts": texts,
|
||||
"nodes": [n.get("name") or n.get("type") for n in structure.get("nodes") or []],
|
||||
"main_model": models[0]["file"] if models else "", "prompt": texts[0]["text"] if texts else ""}
|
||||
return combo, [model["file"] for model in models[1:]] + [text["text"] for text in texts[1:]]
|
||||
loras = [_model_name(lora) for lora in data.get("loras") or []]
|
||||
return {"name": note.get("name"), "base_model": data.get("baseModel") or "", "main_model": _model_name(data.get("mainModel")),
|
||||
"loras": loras, "prompt": _text(data.get("promptEn"), 800)}, loras
|
||||
|
||||
|
||||
def list_combos(query="", limit=20, *, exact_name=False):
|
||||
terms, found = _terms(query), []
|
||||
for note in _list_notebooks():
|
||||
combo, loras = combo_view(note)
|
||||
if exact_name and combo["name"] != query:
|
||||
continue
|
||||
if not exact_name and not _matches(terms, combo["name"], combo["main_model"], combo["prompt"], " ".join(loras)):
|
||||
continue
|
||||
found.append(combo)
|
||||
return {"total_matches": len(found), "combos": found[:_limit(limit)]}
|
||||
|
||||
|
||||
def list_workflows(query="", limit=20, *, exact_name=False):
|
||||
terms, found = _terms(query), []
|
||||
for recipe in _list_recipes(get_recipes_dir()):
|
||||
data = recipe.get("data") or {}
|
||||
params = json.dumps(data.get("params") or {}, ensure_ascii=False)
|
||||
if exact_name and (data.get("name") or recipe["filename"]) != query:
|
||||
continue
|
||||
if not exact_name and not _matches(terms, data.get("name"), " ".join(data.get("tags") or []), data.get("notes"), params):
|
||||
continue
|
||||
found.append({"name": data.get("name") or recipe["filename"], "tags": data.get("tags") or [],
|
||||
"notes": _text(data.get("notes"), 500), "saved": _when((data.get("timestamp") or 0) / 1000),
|
||||
"settings": params if len(params) <= MAX_TEXT else params[:MAX_TEXT] + "…"})
|
||||
return {"total_matches": len(found), "workflows": found[:_limit(limit)]}
|
||||
|
||||
|
||||
def saved_prompts():
|
||||
"""Every saved prompt plan: {name, tags, positive, negative}."""
|
||||
directory = get_materials_dir()
|
||||
for summary in _list_materials(directory):
|
||||
if summary.get("kind") != "prompt_plan" or summary.get("moved_to_recipe"):
|
||||
continue
|
||||
try:
|
||||
plan = _read_material(os.path.join(directory, summary["filename"])).get("plan") or {}
|
||||
except (OSError, ValueError, KeyError):
|
||||
continue
|
||||
yield {"name": summary.get("name"), "tags": summary.get("tags") or [],
|
||||
"positive": _text(plan.get("positive"), 1200), "negative": _text(plan.get("negative"), 600)}
|
||||
|
||||
|
||||
def list_saved_prompts(query="", limit=20):
|
||||
terms = _terms(query)
|
||||
found = [prompt for prompt in saved_prompts()
|
||||
if _matches(terms, prompt["name"], " ".join(prompt["tags"]), prompt["positive"], prompt["negative"])]
|
||||
return {"total_matches": len(found), "prompts": found[:_limit(limit)]}
|
||||
|
||||
|
||||
def recent_activity(limit=10, source=""):
|
||||
entries = list_entries(_limit(limit), source if source in ("canvas", "file") else None)
|
||||
return {"entries": [{"time": _when(entry.get("time")), "source": entry.get("source"), "action": entry.get("action"),
|
||||
"target": entry.get("target") or "", "detail": entry.get("detail") or {}} for entry in entries]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- registry
|
||||
|
||||
def _schema(properties=None, required=()):
|
||||
schema = {"type": "object", "properties": properties or {}, "additionalProperties": False}
|
||||
if required:
|
||||
schema["required"] = list(required)
|
||||
return schema
|
||||
|
||||
|
||||
_QUERY = {"type": "string", "description": "Words that must all appear; empty lists all."}
|
||||
_LIMIT = {"type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "default": 20}
|
||||
|
||||
|
||||
def _tool(name, title, description, run, properties=None, required=()):
|
||||
return name, {"run": run, "spec": {
|
||||
"name": name, "title": title, "description": description,
|
||||
"inputSchema": _schema(properties, required),
|
||||
"annotations": {"title": title, "readOnlyHint": True, "destructiveHint": False,
|
||||
"idempotentHint": True, "openWorldHint": False},
|
||||
}}
|
||||
|
||||
|
||||
TOOLS = dict([
|
||||
_tool("library_overview", "Library overview",
|
||||
"How many models of each type and base model the user has, how many are scanned, and how many "
|
||||
"combos, workflow recipes and saved prompts they kept. A good first call.", library_overview),
|
||||
_tool("scan_report", "Scan status",
|
||||
"Which models are matched on Civitai, which are not and why, which were never scanned, and what the last scan did.",
|
||||
scan_report),
|
||||
_tool("search_images", "Search output images",
|
||||
"Output images, newest first, by words in their prompt, model or settings (a LoRA name, 'seed:123'). "
|
||||
"with_pictures attaches the first 6 (512 px).",
|
||||
search_images, {"query": _QUERY, "limit": _LIMIT, "page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"with_pictures": {"type": "boolean", "default": False}}),
|
||||
_tool("get_image_info", "Image generation settings",
|
||||
"How an output image was made (prompts, models, seed, sampler…) and whether the user kept it. "
|
||||
"folder=input: a picture LoadImage reads; folder=temp: a run's preview; both with their size and MASK "
|
||||
"area. include_image attaches a 512 px copy; zoom_image shows a part larger.",
|
||||
get_image_info, {"image": {"type": "string", "description": "From search_images; for folder=input, the name LoadImage takes."},
|
||||
"include_image": {"type": "boolean", "default": False},
|
||||
"folder": {"type": "string", "enum": ["output", "input", "temp"], "default": "output"}}, ["image"]),
|
||||
_tool("list_combos", "Combos",
|
||||
"The user's combos (搭配): a main model, LoRAs and a prompt kept together, or (kind \"node "
|
||||
"structure\") model nodes and text nodes saved from the canvas with their links, listed by slot.", list_combos,
|
||||
{"query": _QUERY, "limit": _LIMIT}),
|
||||
_tool("list_workflows", "Workflow recipes",
|
||||
"The user's saved workflow recipes with tags, notes and their main settings.", list_workflows,
|
||||
{"query": _QUERY, "limit": _LIMIT}),
|
||||
_tool("list_saved_prompts", "Saved prompts",
|
||||
"Prompts the user saved in the Prompt Studio, with positive and negative text.", list_saved_prompts,
|
||||
{"query": _QUERY, "limit": _LIMIT}),
|
||||
_tool("search_audio", "Generated audio",
|
||||
"Speech the user generated (Anomalous TTS), newest first, with its text, voice and seed.", search_audio,
|
||||
{"query": _QUERY, "limit": _LIMIT}),
|
||||
_tool("recent_activity", "Activity log",
|
||||
"What Anomalous changed recently, newest first: canvas edits it made and files it saved, scanned or deleted.",
|
||||
recent_activity, {"limit": {**_LIMIT, "default": 10}, "source": {"type": "string", "enum": ["canvas", "file"]}}),
|
||||
])
|
||||
|
||||
|
||||
def tool_text(data):
|
||||
"""A result as the AI reads it: JSON without spaces. Apps keep a long one out of the chat and
|
||||
have the AI open it as a file (Antigravity past about 4,000 characters), so every character counts."""
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def call_tool(tools, name, arguments, ctx=None):
|
||||
"""An MCP tools/call result for tool `name` of `tools` (run in a worker thread). Tools marked
|
||||
`context` get `ctx` ({base_url} of this server) first."""
|
||||
tool = tools[name]
|
||||
spec = tool["spec"]["inputSchema"]
|
||||
arguments, renamed = prepare_arguments(spec, arguments)
|
||||
unknown = set(arguments) - set(spec["properties"])
|
||||
missing = [key for key in spec.get("required", []) if key not in arguments]
|
||||
if unknown or missing:
|
||||
wrong = "; ".join(part for part in (unknown and f"Unknown arguments {sorted(unknown)}", missing and f"missing {missing}") if part)
|
||||
takes = ", ".join(key + (" (needed)" if key in spec.get("required", []) else "") for key in spec["properties"] if key != "task")
|
||||
return _failure(f"{wrong}. {name} takes: {takes or 'nothing'}.")
|
||||
try:
|
||||
value = tool["run"](ctx, **arguments) if tool.get("context") else tool["run"](**arguments)
|
||||
except ToolError as error:
|
||||
return _failure(str(error))
|
||||
except Exception as error: # noqa: BLE001 - an AI app gets a readable failure, never a dropped request
|
||||
logging.getLogger(__name__).warning("Anomalous MCP tool %s failed", name, exc_info=True)
|
||||
return _failure(f"{name} failed: {error}")
|
||||
data, extra = value if isinstance(value, tuple) else (value, [])
|
||||
if renamed and isinstance(data, dict):
|
||||
data = {**data, "arguments_note": renaming_note(name, renamed)}
|
||||
return {"content": [{"type": "text", "text": tool_text(data)}, *extra],
|
||||
"structuredContent": data, "isError": False}
|
||||
|
||||
|
||||
def retext(result, data):
|
||||
"""A tool result with `data` as its structured content and as its text (the rest of its content kept)."""
|
||||
result["structuredContent"] = data
|
||||
content = list(result.get("content") or [])
|
||||
text = {"type": "text", "text": tool_text(data)}
|
||||
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
|
||||
content[0] = text
|
||||
else:
|
||||
content.insert(0, text)
|
||||
result["content"] = content
|
||||
|
||||
|
||||
def _failure(message):
|
||||
return {"content": [{"type": "text", "text": message}], "isError": True}
|
||||
+3
-2
@@ -26,7 +26,7 @@ from .path_utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
CARD_THUMBNAIL_EDGE = 512
|
||||
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
|
||||
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov'}
|
||||
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.avi', '.mkv'}
|
||||
CARD_WARM_QUEUE_LIMIT = 512
|
||||
_thumbnail_cleanup_lock = threading.Lock()
|
||||
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
|
||||
@@ -302,7 +302,8 @@ async def api_serve_image(request):
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo'
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mkv': 'video/x-matroska'
|
||||
}
|
||||
content_type = content_types.get(ext)
|
||||
if content_type is None:
|
||||
|
||||
+37
-8
@@ -12,8 +12,11 @@ import folder_paths
|
||||
import struct
|
||||
try:
|
||||
from ..model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
|
||||
from ..model_sidecars import find
|
||||
except ImportError:
|
||||
from model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
|
||||
from model_sidecars import find
|
||||
from .model_heads import inspect as inspect_file # the header, read once per file version
|
||||
|
||||
# Fields the model editor sets: <model>.anomalous.json key -> metadata key.
|
||||
USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url"}
|
||||
@@ -85,8 +88,18 @@ def _positive_civitai_id(value):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
def _info_base(file_path, base_path):
|
||||
"""Where the model's info files are read: beside the model as named; for a link with none
|
||||
beside it, beside the file it links to, where another tool (A1111's Civitai Helper) may
|
||||
have left them. Only read there: covers, edits and scans stay beside the link."""
|
||||
if os.path.islink(file_path) and not (find(base_path, ".info") or os.path.exists(base_path + ".civitai.info")):
|
||||
return os.path.splitext(os.path.realpath(file_path))[0]
|
||||
return base_path
|
||||
|
||||
|
||||
def _read_metadata(file_path):
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
info_base = _info_base(file_path, base_path)
|
||||
metadata = {
|
||||
"name": os.path.basename(base_path),
|
||||
"description": "",
|
||||
@@ -101,13 +114,17 @@ def _read_metadata(file_path):
|
||||
"hash_source": "",
|
||||
"custom_name": "",
|
||||
"custom_notes": "",
|
||||
"version_name": "", # the Civitai version ("v2.0", "Illustrious"…), told apart on the card
|
||||
"base_guess": "", # no base model known yet (not scanned): what the file's header tells
|
||||
# "civitai" (matched), "local" (only what the file tells: no match, or offline) or "" (not scanned)
|
||||
"info_source": "",
|
||||
"info_source": "",
|
||||
"unmatched_reason": "", # for "local": not_found | network | offline | "" (scanned before this was kept)
|
||||
"user_fields": [],
|
||||
"scan_choice": "", # the user's "skip" / "scan" (model_identity.scan_choice)
|
||||
"not_drawing": False, # not scanned yet, and its header looks like no drawing model
|
||||
}
|
||||
|
||||
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
|
||||
info_files = [path for path in (find(info_base, ".info"), f"{info_base}.civitai.info") if path]
|
||||
for info_file in info_files:
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
@@ -156,6 +173,8 @@ def _read_metadata(file_path):
|
||||
if civitai_url: metadata["civitai_url"] = civitai_url
|
||||
if model_id is not None: metadata["model_id"] = model_id
|
||||
if version_id is not None: metadata["version_id"] = version_id
|
||||
if version_id is not None and isinstance(data.get("model"), dict) and isinstance(data.get("name"), str):
|
||||
metadata["version_name"] = data["name"].strip()
|
||||
if hash_val: metadata["hash"] = hash_val
|
||||
if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"]
|
||||
if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"]
|
||||
@@ -163,12 +182,21 @@ def _read_metadata(file_path):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
scan = sidecar_info(base_path)
|
||||
scan = sidecar_info(info_base)
|
||||
if scan is not None:
|
||||
metadata["info_source"] = "local" if is_unmatched(scan) else "civitai"
|
||||
metadata["info_source"] = "local" if is_unmatched(scan) else "civitai"
|
||||
metadata["unmatched_reason"] = unmatched_reason(scan)
|
||||
if scan is None:
|
||||
found = inspect_file(file_path)
|
||||
if not metadata["baseModel"]:
|
||||
metadata["base_guess"] = found.get("base") or ""
|
||||
# A header that reads but names no drawing model kind (a voice model, say).
|
||||
metadata["not_drawing"] = found["format"] == "safetensors" and not found["kind"]
|
||||
# The user's own layer comes last and wins, also when a field was cleared on purpose ("").
|
||||
user = read_json(base_path + USER_INFO_SUFFIX) or {}
|
||||
user_file = find(base_path, USER_INFO_SUFFIX)
|
||||
user = (read_json(user_file) if user_file else None) or {}
|
||||
if user.get("scan") in ("skip", "scan"):
|
||||
metadata["scan_choice"] = user["scan"]
|
||||
for key, field in USER_FIELDS.items():
|
||||
if isinstance(user.get(key), str):
|
||||
metadata[field] = user[key]
|
||||
@@ -188,9 +216,9 @@ def _metadata_signature(file_path):
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
return (
|
||||
_file_signature(file_path),
|
||||
_file_signature(f"{base_path}.info"),
|
||||
_file_signature(find(base_path, ".info") or f"{base_path}.info"),
|
||||
_file_signature(f"{base_path}.civitai.info"),
|
||||
_file_signature(base_path + USER_INFO_SUFFIX),
|
||||
_file_signature(find(base_path, USER_INFO_SUFFIX) or base_path + USER_INFO_SUFFIX),
|
||||
)
|
||||
|
||||
|
||||
@@ -204,7 +232,8 @@ def _get_metadata_cached(file_path, signature):
|
||||
|
||||
|
||||
def get_metadata(file_path):
|
||||
normalized_path = os.path.realpath(file_path)
|
||||
# As named, not resolved: a model that is a link has its covers and info beside the link.
|
||||
normalized_path = os.path.abspath(file_path)
|
||||
signature = _metadata_signature(normalized_path)
|
||||
# Metadata contains mutable lists. Never expose the cached object directly.
|
||||
return copy.deepcopy(_get_metadata_cached(normalized_path, signature))
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""What Anomalous knows about using a model well, told to the AI so it need not guess:
|
||||
|
||||
- the settings its author's example pictures were made with (the Civitai record a scan keeps
|
||||
beside the file: images[].meta), summed up for get_model: how many pictures, the sampler and
|
||||
scheduler most used, the range of steps and CFG, the most used size and, for a LoRA, the
|
||||
weight it was used at: by its file hash, name or Civitai version, else the LoRA most of the
|
||||
pictures name (their names are the author's own files, and often an older version's);
|
||||
- whether a LoRA suits the main model it is put on: add_lora and set_model compare their base
|
||||
models (the scan's, else what the file's header tells) and say so when the families differ
|
||||
(an SD 1.5 LoRA on an SDXL checkpoint does nothing useful) or, more softly, when two bases of one
|
||||
family differ (a Pony LoRA on an Illustrious checkpoint). A note only: the call is made.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections import Counter
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import metadata
|
||||
|
||||
# Base model names (Civitai's, or what model_kind.py infers) by the architecture their files share.
|
||||
FAMILIES = (
|
||||
("SDXL", ("sdxl", "pony", "illustrious", "noob")), ("SD 1.x", ("sd 1",)), ("SD 2.x", ("sd 2",)),
|
||||
("SD 3", ("sd 3", "sd3")), ("Flux.1", ("flux.1", "flux 1", "chroma")), ("Flux.2", ("flux.2", "flux 2")),
|
||||
)
|
||||
# Bases trained far from their family's: a LoRA of one on another of the family loads but may look off.
|
||||
OWN_WAYS = ("pony", "illustrious", "noob", "chroma")
|
||||
MAIN_TYPES = ("checkpoints", "diffusion_models", "unet")
|
||||
MAX_EXAMPLES = 20
|
||||
|
||||
|
||||
def family(base):
|
||||
"""The architecture family of a base model name ("" when none is known)."""
|
||||
lowered = str(base or "").strip().lower()
|
||||
if not lowered or lowered in ("other", "unknown"):
|
||||
return ""
|
||||
for name, marks in FAMILIES:
|
||||
if lowered.startswith(marks):
|
||||
return name
|
||||
return lowered
|
||||
|
||||
|
||||
def base_of(file_path):
|
||||
"""A model file's base model: the scan's, else what its header tells."""
|
||||
meta = metadata.get_metadata(file_path)
|
||||
return meta.get("baseModel") or meta.get("base_guess") or ""
|
||||
|
||||
|
||||
def _main_base(value):
|
||||
"""The base model of a main loader's choice (its drop-down value), or ""."""
|
||||
for folder_type in MAIN_TYPES:
|
||||
try:
|
||||
path = folder_paths.get_full_path(folder_type, value)
|
||||
except (KeyError, TypeError):
|
||||
path = None
|
||||
if path:
|
||||
return base_of(path)
|
||||
return ""
|
||||
|
||||
|
||||
def lora_paths(values):
|
||||
"""The files of LoRA drop-down values that ComfyUI can find."""
|
||||
paths = []
|
||||
for value in values or []:
|
||||
try:
|
||||
path = folder_paths.get_full_path("loras", value)
|
||||
except (KeyError, TypeError):
|
||||
path = None
|
||||
if path:
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
def lora_note(lora_path, line):
|
||||
"""A note when the LoRA at `lora_path` does not suit the main model of `line` (the page's
|
||||
{"model": the main loader's choice}), else None."""
|
||||
main = _main_base((line or {}).get("model") or "")
|
||||
lora = base_of(lora_path)
|
||||
if not main or not lora or main.lower() == lora.lower():
|
||||
return None
|
||||
if family(main) != family(lora):
|
||||
return (f"Base model clash: this LoRA is for {lora}, the main model ({line['model']}) is {main}; it will not "
|
||||
f"work as meant. Pick one with search_models(type='loras', base_model='{main}'), or remove it.")
|
||||
if lora.lower().startswith(OWN_WAYS) or main.lower().startswith(OWN_WAYS):
|
||||
return (f"This LoRA was made for {lora}, the main model is {main}: same family, so it loads, but it may "
|
||||
f"look weaker or off. search_models(type='loras', base_model='{main}') finds ones made for it.")
|
||||
return None
|
||||
|
||||
|
||||
def _number(value):
|
||||
try:
|
||||
number = float(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number == number and abs(number) < 1e6 else None
|
||||
|
||||
|
||||
def _span(numbers):
|
||||
"""'25' or '20–30' for a list of numbers, '' for none."""
|
||||
if not numbers:
|
||||
return ""
|
||||
low, high = min(numbers), max(numbers)
|
||||
say = lambda number: f"{number:g}"
|
||||
return say(low) if low == high else f"{say(low)}–{say(high)}"
|
||||
|
||||
|
||||
def _common(values, total):
|
||||
"""'euler (6 of 10)': the most used value and in how many of the `total` pictures."""
|
||||
counted = Counter(value for value in values if value)
|
||||
if not counted:
|
||||
return ""
|
||||
value, times = counted.most_common(1)[0]
|
||||
return str(value) if times == total else f"{value} ({times} of {total})"
|
||||
|
||||
|
||||
def _loras(meta):
|
||||
"""[(who, weight)] of the LoRAs one picture's settings name: who is a hash, a file name or "v<version>"."""
|
||||
found = []
|
||||
for item in meta.get("resources") or []:
|
||||
if isinstance(item, dict) and str(item.get("type", "")).lower() == "lora":
|
||||
found.append((str(item.get("hash") or item.get("name") or "").lower(), _number(item.get("weight"))))
|
||||
for item in meta.get("additionalResources") or []:
|
||||
if isinstance(item, dict) and str(item.get("type", "lora")).lower() == "lora":
|
||||
found.append((os.path.splitext(os.path.basename(str(item.get("name", ""))))[0].lower(), _number(item.get("strength"))))
|
||||
for item in meta.get("civitaiResources") or []:
|
||||
if isinstance(item, dict) and str(item.get("type", "")).lower() == "lora" and item.get("modelVersionId") is not None:
|
||||
found.append((f"v{item['modelVersionId']}", _number(item.get("weight"))))
|
||||
return [(who, weight) for who, weight in found if who and weight is not None]
|
||||
|
||||
|
||||
def _lora_weights(metas, own):
|
||||
"""The weights this LoRA was used at in its pictures: those naming it (`own`: its hash, file
|
||||
name, version), else the one LoRA most of them name; none when two are named as often."""
|
||||
per = [_loras(meta) for meta in metas]
|
||||
named = Counter(who for found in per for who in {who for who, _weight in found})
|
||||
mine = {who for who in named if who in own}
|
||||
if not mine:
|
||||
common = [(who, times) for who, times in named.most_common(2) if times * 5 >= len(metas) * 3]
|
||||
if common and (len(common) == 1 or common[0][1] > common[1][1]):
|
||||
mine = {common[0][0]}
|
||||
return [weight for found in per for who, weight in found if who in mine]
|
||||
|
||||
|
||||
def _record(file_path):
|
||||
"""The Civitai record kept beside the model (its .info), or None."""
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
info_base = metadata._info_base(file_path, base_path)
|
||||
for path in (metadata.find(info_base, ".info"), f"{info_base}.civitai.info"):
|
||||
if path and os.path.isfile(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if isinstance(data, dict) and isinstance(data.get("images"), list):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def author_examples(file_path, folder_type, sha256=""):
|
||||
"""What the author's example pictures were made with, summed up; {} when nothing is kept."""
|
||||
record = _record(file_path)
|
||||
metas = [image.get("meta") for image in (record or {}).get("images") or []
|
||||
if isinstance(image, dict) and isinstance(image.get("meta"), dict)][:MAX_EXAMPLES]
|
||||
if not metas:
|
||||
return {}
|
||||
sizes = [meta.get("Size") or (f"{meta['width']}x{meta['height']}" if meta.get("width") and meta.get("height") else "")
|
||||
for meta in metas]
|
||||
found = {
|
||||
"pictures": len(metas),
|
||||
"sampler": _common([str(meta.get("sampler") or "") for meta in metas if str(meta.get("sampler")).lower() != "undefined"], len(metas)),
|
||||
"scheduler": _common([str(meta.get("scheduler") or meta.get("Schedule type") or "") for meta in metas], len(metas)),
|
||||
"steps": _span([n for n in (_number(meta.get("steps")) for meta in metas) if n is not None]),
|
||||
"cfg": _span([n for n in (_number(meta.get("cfgScale")) for meta in metas) if n is not None]),
|
||||
"size": _common([str(size) for size in sizes], len(metas)),
|
||||
"clip_skip": _span([n for n in (_number(meta.get("clipSkip") or meta.get("Clip skip")) for meta in metas) if n is not None]),
|
||||
}
|
||||
if folder_type == "loras":
|
||||
own = {(sha256 or "")[:10].lower(), os.path.splitext(os.path.basename(file_path))[0].lower(), f"v{record.get('id')}"} - {""}
|
||||
found["lora_weight"] = _span(_lora_weights(metas, own))
|
||||
return {key: value for key, value in found.items() if value}
|
||||
+25
-123
@@ -1,7 +1,6 @@
|
||||
"""Model folder discovery, listing, search, and presentation lookup."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
@@ -9,13 +8,31 @@ from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .metadata import get_metadata
|
||||
try:
|
||||
from ..model_sidecars import HIDDEN_DIR
|
||||
except ImportError:
|
||||
from model_sidecars import HIDDEN_DIR
|
||||
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, is_model_file
|
||||
from .model_media import _cache_token, _preview_url_for_model
|
||||
from .folder_types import (
|
||||
get_active_folder_types, get_active_model_roots,
|
||||
get_active_physical_basenames, get_folder_view_mode,
|
||||
)
|
||||
from .path_utils import resolve_folder_subdir
|
||||
from .path_utils import model_reference_within, resolve_folder_subdir
|
||||
|
||||
def _folder_tree(base_dir):
|
||||
"""{'/'-relative folder: {path, name, has_models, model_count}} under a model root; the
|
||||
.amb folders hold side files only (model_sidecars)."""
|
||||
tree = {}
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
dirs[:] = [name for name in dirs if name != HIDDEN_DIR]
|
||||
count = sum(1 for f in files if is_model_file(f))
|
||||
rel = os.path.relpath(root, base_dir)
|
||||
rel = '/' if rel == '.' else '/' + rel.replace('\\', '/')
|
||||
tree[rel] = {"path": rel, "name": os.path.basename(root) if rel != '/' else '[Root]',
|
||||
"has_models": count > 0, "model_count": count}
|
||||
return tree
|
||||
|
||||
|
||||
def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, page, limit):
|
||||
"""Collect one folder in a worker thread with a single directory listing."""
|
||||
@@ -121,20 +138,7 @@ def _collect_folders():
|
||||
|
||||
for item in matched:
|
||||
base_dir = item["base_dir"]
|
||||
tree = {}
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
has_models = any(is_model_file(f) for f in files)
|
||||
rel = os.path.relpath(root, base_dir)
|
||||
if rel == '.':
|
||||
rel = '/'
|
||||
else:
|
||||
rel = '/' + rel.replace('\\', '/')
|
||||
tree[rel] = {
|
||||
"path": rel,
|
||||
"name": os.path.basename(root) if rel != '/' else '[Root]',
|
||||
"has_models": has_models,
|
||||
"model_count": sum(1 for f in files if is_model_file(f))
|
||||
}
|
||||
tree = _folder_tree(base_dir)
|
||||
|
||||
label = item["bn"]
|
||||
if len(matched) > 1:
|
||||
@@ -164,20 +168,7 @@ def _collect_folders():
|
||||
continue
|
||||
seen_dirs.add(real_path)
|
||||
|
||||
tree = {}
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
has_models = any(is_model_file(f) for f in files)
|
||||
rel = os.path.relpath(root, base_dir)
|
||||
if rel == '.':
|
||||
rel = '/'
|
||||
else:
|
||||
rel = '/' + rel.replace('\\', '/')
|
||||
tree[rel] = {
|
||||
"path": rel,
|
||||
"name": os.path.basename(root) if rel != '/' else '[Root]',
|
||||
"has_models": has_models,
|
||||
"model_count": sum(1 for f in files if is_model_file(f))
|
||||
}
|
||||
tree = _folder_tree(base_dir)
|
||||
|
||||
try:
|
||||
folder_basename = os.path.basename(os.path.normpath(base_dir))
|
||||
@@ -448,15 +439,14 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False
|
||||
real_base_dir = os.path.realpath(base_dir)
|
||||
roots.append((folder_type, path_idx, real_base_dir))
|
||||
for original, _, relative_path in requested:
|
||||
relative = relative_path.replace('/', os.sep)
|
||||
candidate = os.path.realpath(os.path.join(real_base_dir, relative))
|
||||
try:
|
||||
if os.path.commonpath((real_base_dir, candidate)) != real_base_dir:
|
||||
continue
|
||||
candidate = model_reference_within(real_base_dir, relative_path.replace('/', os.sep))
|
||||
except ValueError:
|
||||
continue
|
||||
if os.path.isfile(candidate) and is_model_file(candidate):
|
||||
exact_results[original] = _model_info_for_path(folder_type, path_idx, base_dir, candidate)
|
||||
# The candidate lies under the real folder: a folder that is a link (a junction
|
||||
# to another drive, a short 8.3 name) would give a subfolder of "../.." or fail.
|
||||
exact_results[original] = _model_info_for_path(folder_type, path_idx, real_base_dir, candidate)
|
||||
|
||||
unresolved = [(original, normalized) for original, normalized, _ in requested if original not in exact_results]
|
||||
if exact_only or not unresolved:
|
||||
@@ -601,91 +591,3 @@ async def api_get_all_scan_models(request):
|
||||
limit = int(request.query.get('limit', 0))
|
||||
payload = await asyncio.to_thread(_collect_all_scan_models, page, limit)
|
||||
return web.json_response(payload)
|
||||
|
||||
|
||||
async def api_batch_select(request):
|
||||
folder_key = request.query.get('folderKey', 'ALL')
|
||||
action = request.query.get('action', 'all')
|
||||
|
||||
def matches_condition(file_path, root, base_name):
|
||||
if action == 'all':
|
||||
return True
|
||||
elif action == 'no_preview':
|
||||
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
return False
|
||||
return True
|
||||
elif action == 'no_desc':
|
||||
info_file = file_path + '.info'
|
||||
civitai_info = os.path.join(root, base_name + '.civitai.info')
|
||||
if os.path.exists(civitai_info):
|
||||
try:
|
||||
with open(civitai_info, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('description', '').strip():
|
||||
return False
|
||||
except: pass
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('description', '').strip():
|
||||
return False
|
||||
except: pass
|
||||
return True
|
||||
return False
|
||||
|
||||
results = {}
|
||||
|
||||
if folder_key == 'ALL':
|
||||
target_types = get_active_folder_types()
|
||||
seen_dirs = set()
|
||||
for t in target_types:
|
||||
try: paths = folder_paths.get_folder_paths(t)
|
||||
except Exception: continue
|
||||
if not paths: continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if base_dir in seen_dirs: continue
|
||||
seen_dirs.add(base_dir)
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if is_model_file(f):
|
||||
file_path = os.path.join(root, f)
|
||||
base_name = os.path.splitext(f)[0]
|
||||
if matches_condition(file_path, root, base_name):
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.': rel_subfolder = ''
|
||||
else: rel_subfolder = rel_subfolder.replace('\\', '/')
|
||||
fkey = f"{t}|{path_idx}|{rel_subfolder}"
|
||||
if fkey not in results: results[fkey] = []
|
||||
results[fkey].append(f)
|
||||
else:
|
||||
parts = folder_key.split('|')
|
||||
if len(parts) >= 3:
|
||||
t = parts[0]
|
||||
path_idx = int(parts[1])
|
||||
subfolder = parts[2]
|
||||
|
||||
try: paths = folder_paths.get_folder_paths(t)
|
||||
except Exception: paths = []
|
||||
|
||||
if paths and path_idx < len(paths):
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(t, path_idx, subfolder)
|
||||
except ValueError:
|
||||
return web.json_response({"selected": {}})
|
||||
|
||||
if os.path.exists(target_dir):
|
||||
try: entries = os.listdir(target_dir)
|
||||
except: entries = []
|
||||
for f in entries:
|
||||
if is_model_file(f):
|
||||
file_path = os.path.join(target_dir, f)
|
||||
if os.path.isfile(file_path):
|
||||
base_name = os.path.splitext(f)[0]
|
||||
if matches_condition(file_path, target_dir, base_name):
|
||||
if folder_key not in results: results[folder_key] = []
|
||||
results[folder_key].append(f)
|
||||
|
||||
return web.json_response({"selected": results})
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Downloading the models the open workflow is missing (Model Check's "Download").
|
||||
|
||||
POST /anomalous/download/lookup {items: [{key, hash, url, value, types}]} -> where each can come
|
||||
from (download_sources.find_source) and the model folders of its type: [{index, path, free,
|
||||
subfolders}].
|
||||
POST /anomalous/download/start {download_url, type, root, rel, sha256, size, ...} -> {id}
|
||||
GET /anomalous/download/status -> {jobs}
|
||||
POST /anomalous/download/cancel {id}
|
||||
GET/POST /anomalous/download/settings -> where downloads go ({place, folder}), kept in
|
||||
user/anomalous/download_settings.json; imported models (model_import.py) follow the same folder.
|
||||
|
||||
One download runs at a time, into "<file>.part" next to where the file goes; an interrupted one
|
||||
continues from there. The finished file is checked against the SHA-256 the workflow or the site
|
||||
gives, and only then gets its name; a file that is already there is never replaced. The Civitai
|
||||
key goes only to Civitai (not along a redirect to its file storage).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from . import hf_card, manager_catalog
|
||||
from .activity_log import add_entry, log_path
|
||||
from .download_sources import (
|
||||
SHA256, USER_AGENT, allowed_download_url, find_source, host_of, is_civitai, same_file, _load_api_key,
|
||||
)
|
||||
from .path_utils import atomic_write_json, resolve_within
|
||||
|
||||
try:
|
||||
from ..net_proxy import urlopen
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from net_proxy import urlopen
|
||||
|
||||
MODEL_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".sft", ".gguf")
|
||||
CHUNK = 1024 * 1024
|
||||
SPACE_MARGIN = 200 * 1024 * 1024
|
||||
MAX_SUBFOLDERS = 300
|
||||
KEEP_FINISHED = 50
|
||||
# hf_mirror None: not chosen yet (the page takes the mirror for a Chinese interface).
|
||||
DEFAULT_SETTINGS = {"place": "workflow", "folder": "{base}", "hf_mirror": None}
|
||||
|
||||
_lock = threading.Lock()
|
||||
_jobs = {} # id -> job dict, in the order started
|
||||
_queue = []
|
||||
_worker = None
|
||||
|
||||
|
||||
class DownloadError(Exception):
|
||||
def __init__(self, code, message=""):
|
||||
super().__init__(message or code)
|
||||
self.code = code
|
||||
|
||||
|
||||
async def _body(request):
|
||||
"""The request's JSON object, or {} when it is not one (each route then refuses it)."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
# --- settings ---------------------------------------------------------------------------
|
||||
|
||||
def settings_path():
|
||||
return os.path.join(os.path.dirname(log_path()), "download_settings.json")
|
||||
|
||||
|
||||
def read_settings():
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
try:
|
||||
with open(settings_path(), encoding="utf-8") as source:
|
||||
saved = json.load(source)
|
||||
if saved.get("place") in ("workflow", "folder"):
|
||||
settings["place"] = saved["place"]
|
||||
if isinstance(saved.get("folder"), str):
|
||||
settings["folder"] = saved["folder"]
|
||||
if isinstance(saved.get("hf_mirror"), bool):
|
||||
settings["hf_mirror"] = saved["hf_mirror"]
|
||||
except (OSError, ValueError, AttributeError):
|
||||
pass
|
||||
return settings
|
||||
|
||||
|
||||
def clean_folder(folder):
|
||||
"""A relative folder ('' = the type's own folder), '/' separated; ValueError if it climbs out."""
|
||||
parts = [part.strip() for part in str(folder or "").replace("\\", "/").split("/") if part.strip()]
|
||||
for part in parts:
|
||||
if part in (".", "..") or ":" in part or any(char in part for char in '<>"|?*'):
|
||||
raise ValueError("Invalid folder")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
async def api_settings(request):
|
||||
if request.method == "POST":
|
||||
data = await _body(request)
|
||||
settings = read_settings()
|
||||
if data.get("place") in ("workflow", "folder"):
|
||||
settings["place"] = data["place"]
|
||||
if isinstance(data.get("hf_mirror"), bool):
|
||||
settings["hf_mirror"] = data["hf_mirror"]
|
||||
if "folder" in data:
|
||||
try:
|
||||
# {base} is filled with the model's base model when a download starts.
|
||||
settings["folder"] = clean_folder(data.get("folder"))
|
||||
except ValueError:
|
||||
return web.json_response({"error": "Invalid folder"}, status=400)
|
||||
atomic_write_json(settings_path(), settings)
|
||||
return web.json_response(settings)
|
||||
return web.json_response(read_settings())
|
||||
|
||||
|
||||
# --- lookup -----------------------------------------------------------------------------
|
||||
|
||||
def folder_type_for(types):
|
||||
known = getattr(folder_paths, "folder_names_and_paths", {})
|
||||
for name in types or []:
|
||||
if isinstance(name, str) and name in known:
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
def _subfolders(root):
|
||||
found = []
|
||||
for current, dirs, _files in os.walk(root):
|
||||
dirs[:] = sorted(name for name in dirs if not name.startswith("."))
|
||||
depth = os.path.relpath(current, root).count(os.sep)
|
||||
for name in dirs:
|
||||
found.append(os.path.relpath(os.path.join(current, name), root).replace(os.sep, "/"))
|
||||
if len(found) >= MAX_SUBFOLDERS:
|
||||
return found
|
||||
if depth >= 1:
|
||||
dirs[:] = []
|
||||
return found
|
||||
|
||||
|
||||
def roots_of(folder_type):
|
||||
"""The model folders of a type: [{index, path, exists, free, subfolders}]."""
|
||||
roots = []
|
||||
for index, path in enumerate(folder_paths.get_folder_paths(folder_type) or []):
|
||||
exists = os.path.isdir(path)
|
||||
probe = path if exists else os.path.dirname(path)
|
||||
try:
|
||||
free = shutil.disk_usage(probe).free if os.path.isdir(probe) else 0
|
||||
except OSError:
|
||||
free = 0
|
||||
roots.append({"index": index, "path": path, "exists": exists, "free": free,
|
||||
"subfolders": _subfolders(path) if exists else []})
|
||||
return roots
|
||||
|
||||
|
||||
def lookup_item(item, hf_mirror=False, down=None):
|
||||
"""Where one item can come from; `down` (kept across one lookup) names the sites that
|
||||
stopped answering, so the next items skip only those."""
|
||||
file_hash = str(item.get("hash") or "").strip()
|
||||
name = os.path.basename(str(item.get("value") or "").replace("\\", "/"))
|
||||
found = find_source(file_hash, str(item.get("url") or "").strip(), name, hf_mirror,
|
||||
folder_type_for(item.get("types")), down)
|
||||
if found.get("found") and file_hash and found.get("sha256") and len(file_hash) >= 10 \
|
||||
and all(c in "0123456789abcdefABCDEF" for c in file_hash) and not same_file(file_hash, found["sha256"]):
|
||||
# The link names another file than the one the workflow was saved with.
|
||||
return {"found": False, "reason": "different_file", "page": found.get("page", "")}
|
||||
return found
|
||||
|
||||
|
||||
async def api_lookup(request):
|
||||
"""Items already looked up (`known`) only get their type and folders. Once a site does not
|
||||
answer, the rest do not ask it (offline: no wait per model); the other sites still are."""
|
||||
data = await _body(request)
|
||||
items, hf_mirror = data.get("items"), data.get("hf_mirror") is True
|
||||
if not isinstance(items, list):
|
||||
return web.json_response({"error": "items must be a list"}, status=400)
|
||||
results, types, down = [], {}, set()
|
||||
for item in items[:100]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
folder_type = folder_type_for(item.get("types"))
|
||||
if item.get("known"):
|
||||
result = {"found": False, "reason": "known"}
|
||||
else:
|
||||
result = await asyncio.to_thread(lookup_item, item, hf_mirror, down)
|
||||
result.update(key=item.get("key"), type=folder_type)
|
||||
if folder_type and folder_type not in types:
|
||||
types[folder_type] = await asyncio.to_thread(roots_of, folder_type)
|
||||
results.append(result)
|
||||
# Whether ComfyUI-Manager's model list is here: without it, Model Check says it is one more source.
|
||||
manager_list = bool(await asyncio.to_thread(manager_catalog.list_path))
|
||||
return web.json_response({"results": results, "roots": types, "settings": read_settings(), "manager_list": manager_list})
|
||||
|
||||
|
||||
# --- downloads --------------------------------------------------------------------------
|
||||
|
||||
def _public(job):
|
||||
public = {key: job[key] for key in ("id", "key", "state", "error", "received", "total", "rel", "type",
|
||||
"root", "value", "file", "source", "verified")}
|
||||
public["host"] = host_of(job["url"])
|
||||
return public
|
||||
|
||||
|
||||
def plan_job(data):
|
||||
"""A queued job from a start request, or ValueError / DownloadError saying why not."""
|
||||
url = str(data.get("download_url") or "")
|
||||
if not allowed_download_url(url):
|
||||
raise DownloadError("bad_source")
|
||||
folder_type = folder_type_for([data.get("type")])
|
||||
if not folder_type:
|
||||
raise DownloadError("bad_type")
|
||||
paths = folder_paths.get_folder_paths(folder_type) or []
|
||||
root_index = data.get("root", 0)
|
||||
if not isinstance(root_index, int) or not 0 <= root_index < len(paths):
|
||||
raise DownloadError("bad_root")
|
||||
rel = clean_folder(data.get("rel"))
|
||||
if not rel.lower().endswith(MODEL_EXTENSIONS):
|
||||
raise DownloadError("bad_name")
|
||||
root = os.path.realpath(paths[root_index])
|
||||
dest = resolve_within(root, *rel.split("/"))
|
||||
if os.path.exists(dest):
|
||||
raise DownloadError("exists")
|
||||
size = int(data.get("size") or 0)
|
||||
probe = root
|
||||
while probe and not os.path.isdir(probe):
|
||||
probe = os.path.dirname(probe)
|
||||
if size and probe and shutil.disk_usage(probe).free < size + SPACE_MARGIN:
|
||||
raise DownloadError("no_space")
|
||||
sha256 = str(data.get("sha256") or "").lower()
|
||||
with _lock:
|
||||
if any(job["dest"] == dest and job["state"] in ("queued", "running", "verifying") for job in _jobs.values()):
|
||||
raise DownloadError("already")
|
||||
return {
|
||||
"id": uuid.uuid4().hex[:12], "key": str(data.get("key") or ""), "state": "queued", "error": "",
|
||||
"received": 0, "total": size, "rel": rel, "type": folder_type, "root": root_index,
|
||||
"value": str(data.get("value") or ""), "file": os.path.basename(dest), "dest": dest, "url": url,
|
||||
"sha256": sha256 if SHA256.match(sha256) else "", "expected": str(data.get("hash") or "").lower(),
|
||||
"source": str(data.get("source") or ""), "page": str(data.get("page") or ""),
|
||||
"verified": False, "cancel": False, "finished": 0,
|
||||
}
|
||||
|
||||
|
||||
def _open(job, start):
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
if start:
|
||||
headers["Range"] = f"bytes={start}-"
|
||||
request = urllib.request.Request(job["url"], headers=headers)
|
||||
key = _load_api_key() if is_civitai(job["url"]) else None
|
||||
if key: # not carried along the redirect to the file storage
|
||||
request.add_unredirected_header("Authorization", f"Bearer {key}")
|
||||
try:
|
||||
return urlopen(request, timeout=60)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code in (401, 403):
|
||||
raise DownloadError("needs_key" if is_civitai(job["url"]) and not key else "forbidden") from error
|
||||
if error.code == 404:
|
||||
raise DownloadError("gone") from error
|
||||
if error.code == 416 and start:
|
||||
raise DownloadError("restart") from error
|
||||
raise DownloadError("network", f"HTTP {error.code}") from error
|
||||
except (urllib.error.URLError, OSError) as error:
|
||||
raise DownloadError("network", str(error)) from error
|
||||
|
||||
|
||||
def _hash_existing(path, digest):
|
||||
with open(path, "rb") as source:
|
||||
for chunk in iter(lambda: source.read(CHUNK), b""):
|
||||
digest.update(chunk)
|
||||
|
||||
|
||||
def _fetch(job):
|
||||
part = job["dest"] + ".part"
|
||||
os.makedirs(os.path.dirname(job["dest"]), exist_ok=True)
|
||||
digest = hashlib.sha256()
|
||||
start = os.path.getsize(part) if os.path.isfile(part) else 0
|
||||
try:
|
||||
response = _open(job, start)
|
||||
except DownloadError as error:
|
||||
if error.code != "restart":
|
||||
raise
|
||||
start, response = 0, _open(job, 0)
|
||||
with response:
|
||||
if start and response.status != 206:
|
||||
start = 0 # the site sends the whole file again
|
||||
if "text/html" in str(response.headers.get("Content-Type", "")).lower():
|
||||
raise DownloadError("not_a_file") # a login or error page, not the model
|
||||
if start:
|
||||
_hash_existing(part, digest)
|
||||
length = int(response.headers.get("Content-Length") or 0)
|
||||
job["total"] = start + length if length else job["total"]
|
||||
job["received"] = start
|
||||
with open(part, "ab" if start else "wb") as target:
|
||||
while True:
|
||||
if job["cancel"]:
|
||||
raise DownloadError("cancelled")
|
||||
chunk = response.read(CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
target.write(chunk)
|
||||
digest.update(chunk)
|
||||
job["received"] += len(chunk)
|
||||
return part, digest.hexdigest()
|
||||
|
||||
|
||||
def _finish(job, part, sha256):
|
||||
job["state"] = "verifying"
|
||||
if job["total"] and job["received"] != job["total"]:
|
||||
raise DownloadError("network", "incomplete")
|
||||
expected = job["sha256"] or (job["expected"] if len(job["expected"]) >= 10 else "")
|
||||
if expected and not same_file(expected, sha256):
|
||||
os.remove(part) # our own unfinished download, not a file of the user's
|
||||
raise DownloadError("hash_mismatch")
|
||||
job["verified"] = bool(expected)
|
||||
if os.path.exists(job["dest"]):
|
||||
raise DownloadError("exists")
|
||||
os.replace(part, job["dest"])
|
||||
if host_of(job["url"]) in ("huggingface.co", "hf-mirror.com"):
|
||||
try: # the model card's example image as cover, its words as notes (never over the user's)
|
||||
job["card"] = hf_card.enrich(job["dest"], job["url"])
|
||||
except Exception as error:
|
||||
print(f"[Anomalous Browser] Download: the Hugging Face model card was not read: {error}")
|
||||
forget_file_lists()
|
||||
|
||||
|
||||
def forget_file_lists():
|
||||
"""ComfyUI's file lists and the browser's model metadata see a file that just arrived
|
||||
(a download, an import)."""
|
||||
for cache in (getattr(folder_paths, "filename_list_cache", None), getattr(folder_paths, "cache_helper", None)):
|
||||
try:
|
||||
cache.clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from .metadata import clear_metadata_cache
|
||||
clear_metadata_cache()
|
||||
except Exception as error:
|
||||
print(f"[Anomalous Browser] Could not clear the model cache: {error}")
|
||||
|
||||
|
||||
def _record(job, sha256):
|
||||
add_entry("file", "model_download", job["rel"], {"download": {
|
||||
"type": job["type"], "path_idx": job["root"], "rel": job["rel"], "file": job["file"],
|
||||
"size": job["received"], "sha256": sha256, "verified": job["verified"],
|
||||
"source": job["source"], "page": job["page"], "value": job["value"], "card": job.get("card"),
|
||||
}})
|
||||
|
||||
|
||||
def run_job(job):
|
||||
job["state"] = "running"
|
||||
try:
|
||||
part, sha256 = _fetch(job)
|
||||
_finish(job, part, sha256)
|
||||
job["state"] = "done"
|
||||
try:
|
||||
_record(job, sha256)
|
||||
except Exception as error:
|
||||
print(f"[Anomalous Browser] Download: could not record it in the activity log: {error}")
|
||||
except DownloadError as error:
|
||||
job["state"] = "cancelled" if error.code == "cancelled" else "failed"
|
||||
job["error"] = error.code
|
||||
if error.code == "cancelled" and os.path.isfile(job["dest"] + ".part"):
|
||||
os.remove(job["dest"] + ".part") # our own unfinished download
|
||||
if error.code not in ("cancelled",):
|
||||
print(f"[Anomalous Browser] Download of {job['rel']} failed: {error}")
|
||||
except Exception as error:
|
||||
job["state"], job["error"] = "failed", "unknown"
|
||||
print(f"[Anomalous Browser] Download of {job['rel']} failed: {error}")
|
||||
job["finished"] = time.time()
|
||||
|
||||
|
||||
def _work():
|
||||
global _worker
|
||||
while True:
|
||||
with _lock:
|
||||
job = next((item for item in _queue if item["state"] == "queued"), None)
|
||||
if job is None:
|
||||
_queue.clear()
|
||||
_worker = None
|
||||
return
|
||||
_queue.remove(job)
|
||||
run_job(job)
|
||||
|
||||
|
||||
def enqueue(job):
|
||||
global _worker
|
||||
with _lock:
|
||||
_jobs[job["id"]] = job
|
||||
_queue.append(job)
|
||||
finished = [item for item in _jobs.values() if item["finished"]]
|
||||
for old in finished[:-KEEP_FINISHED]:
|
||||
_jobs.pop(old["id"], None)
|
||||
if _worker is None:
|
||||
_worker = threading.Thread(target=_work, daemon=True, name="anomalous-download")
|
||||
_worker.start()
|
||||
|
||||
|
||||
async def api_start(request):
|
||||
data = await _body(request)
|
||||
if not data:
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
try:
|
||||
job = await asyncio.to_thread(plan_job, data)
|
||||
except DownloadError as error:
|
||||
return web.json_response({"error": error.code}, status=409 if error.code in ("exists", "already") else 400)
|
||||
except (ValueError, TypeError) as error:
|
||||
return web.json_response({"error": "bad_path", "message": str(error)}, status=400)
|
||||
enqueue(job)
|
||||
return web.json_response(_public(job))
|
||||
|
||||
|
||||
async def api_status(request):
|
||||
with _lock:
|
||||
jobs = [_public(job) for job in _jobs.values()]
|
||||
return web.json_response({"jobs": jobs})
|
||||
|
||||
|
||||
async def api_cancel(request):
|
||||
data = await _body(request)
|
||||
with _lock:
|
||||
job = _jobs.get(str(data.get("id") or ""))
|
||||
if not job:
|
||||
return web.json_response({"error": "unknown"}, status=404)
|
||||
if job["state"] == "queued":
|
||||
job["state"], job["error"], job["finished"] = "cancelled", "cancelled", time.time()
|
||||
if job in _queue:
|
||||
_queue.remove(job)
|
||||
else:
|
||||
job["cancel"] = True
|
||||
return web.json_response(_public(job))
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_post("/anomalous/download/lookup", api_lookup)
|
||||
app.router.add_post("/anomalous/download/start", api_start)
|
||||
app.router.add_get("/anomalous/download/status", api_status)
|
||||
app.router.add_post("/anomalous/download/cancel", api_cancel)
|
||||
app.router.add_get("/anomalous/download/settings", api_settings)
|
||||
app.router.add_post("/anomalous/download/settings", api_settings)
|
||||
+22
-9
@@ -1,7 +1,7 @@
|
||||
"""Shows a model file in this computer's file manager: Explorer or Finder with the file
|
||||
selected, the folder on Linux. The request names the model the way the browser lists it
|
||||
(type, folder index, subfolder, file name), never a path, and local_guard lets only this
|
||||
computer ask (it is a POST)."""
|
||||
selected, the folder on Linux; or, without a file name, opens a models folder (a type's root
|
||||
or a subfolder). The request names it the way the browser lists it (type, folder index,
|
||||
subfolder, file name), never a path, and local_guard lets only this computer ask (a POST)."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -10,7 +10,7 @@ import time
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
from .utils import model_file_within, resolve_folder_subdir
|
||||
|
||||
|
||||
def _explorer_windows():
|
||||
@@ -90,7 +90,20 @@ def _reveal(path):
|
||||
subprocess.Popen(["xdg-open", os.path.dirname(path)])
|
||||
|
||||
|
||||
def _open_folder(folder):
|
||||
if sys.platform == "win32":
|
||||
before = set(_explorer_windows())
|
||||
subprocess.Popen(f'explorer "{folder}"')
|
||||
threading.Thread(target=_raise_explorer, args=(folder, before), daemon=True).start()
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", folder])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
|
||||
|
||||
async def api_open_model_folder(request):
|
||||
"""{type, path_idx, subfolder, filename}: Explorer with the model selected; without a
|
||||
filename, the folder (type root or subfolder) itself."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
@@ -98,15 +111,15 @@ async def api_open_model_folder(request):
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
try:
|
||||
filename = require_filename(data.get("filename", ""))
|
||||
_, folder = resolve_folder_subdir(data.get("type", ""), int(data.get("path_idx", 0)), data.get("subfolder", "/"))
|
||||
path = resolve_within(folder, filename)
|
||||
filename = data.get("filename")
|
||||
path = model_file_within(folder, filename) if filename else None
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if not os.path.isfile(path):
|
||||
return web.json_response({"status": "error", "message": "Model file not found"}, status=404)
|
||||
if not os.path.isfile(path) if path else not os.path.isdir(folder):
|
||||
return web.json_response({"status": "error", "message": "Not found"}, status=404)
|
||||
try:
|
||||
_reveal(path)
|
||||
_reveal(path) if path else _open_folder(folder)
|
||||
except OSError as error:
|
||||
return web.json_response({"status": "error", "message": str(error)}, status=500)
|
||||
return web.json_response({"status": "success"})
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""What a model file's header says (model_kind.inspect_file: kind, base model, format), kept on
|
||||
disk so each file version is read once, also across restarts. With many models, or models on a
|
||||
network drive (a NAS, a cloud server's model disk), reading every header again each time the
|
||||
scan page or a models folder opens takes minutes; the header only changes with the file.
|
||||
|
||||
Kept in user/anomalous/model_heads.json by the file's real path, with its size and time: a
|
||||
changed file is read again. At most MAX_ENTRIES files, the longest unused dropped first, and
|
||||
written a few seconds after the last change (one write for a whole folder). Only this plugin
|
||||
reads it; it is a cache, not data to back up. It is kept on disk once the plugin's routes are
|
||||
set up (keep_on_disk); before that (tests, tools) it lives in memory only."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .activity_log import log_path
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
try:
|
||||
from ..model_identity import read_json
|
||||
from ..model_kind import inspect_file
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import read_json
|
||||
from model_kind import inspect_file
|
||||
|
||||
MAX_ENTRIES = 50000 # about 6 MB on disk; a library has far fewer models
|
||||
MAX_BYTES = 64 * 1024 * 1024 # a cache file larger than this is not read (nor written)
|
||||
SAVE_AFTER_S = 5
|
||||
FIELDS = ("kind", "base", "sure", "format")
|
||||
|
||||
_lock = threading.Lock()
|
||||
_heads = None # {real path: [size, mtime_ns, last used (days), kind, base, sure, format]}
|
||||
_timer = None
|
||||
_on_disk = False
|
||||
|
||||
|
||||
def keep_on_disk():
|
||||
"""From now on the cache is read from and written to cache_path() (the plugin's start)."""
|
||||
global _on_disk
|
||||
_on_disk = True
|
||||
|
||||
|
||||
def cache_path():
|
||||
return os.path.join(os.path.dirname(log_path()), "model_heads.json")
|
||||
|
||||
|
||||
def _load():
|
||||
global _heads
|
||||
if _heads is None:
|
||||
try:
|
||||
data = read_json(cache_path()) if _on_disk and os.path.getsize(cache_path()) <= MAX_BYTES else None
|
||||
except OSError:
|
||||
data = None
|
||||
_heads = {key: value for key, value in (data or {}).items()
|
||||
if isinstance(key, str) and isinstance(value, list) and len(value) == 3 + len(FIELDS)}
|
||||
return _heads
|
||||
|
||||
|
||||
def save():
|
||||
"""Writes the cache now (also what the timer does)."""
|
||||
global _timer
|
||||
with _lock:
|
||||
_timer = None
|
||||
heads = _load()
|
||||
if len(heads) > MAX_ENTRIES:
|
||||
for key in sorted(heads, key=lambda name: heads[name][2])[:len(heads) - MAX_ENTRIES]:
|
||||
del heads[key]
|
||||
snapshot = dict(heads)
|
||||
try:
|
||||
atomic_write_json(cache_path(), snapshot, max_bytes=MAX_BYTES)
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"[Anomalous Browser] Model header cache not saved: {error}")
|
||||
|
||||
|
||||
def _save_soon():
|
||||
global _timer
|
||||
if _on_disk and _timer is None:
|
||||
_timer = threading.Timer(SAVE_AFTER_S, save)
|
||||
_timer.daemon = True
|
||||
_timer.start()
|
||||
|
||||
|
||||
def inspect(path):
|
||||
"""model_kind.inspect_file(path), from the cache while the file is unchanged."""
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
return inspect_file(path)
|
||||
real, today = os.path.realpath(path), int(time.time() // 86400)
|
||||
with _lock:
|
||||
known = _load().get(real)
|
||||
if known and known[0] == stat.st_size and known[1] == stat.st_mtime_ns:
|
||||
if known[2] != today:
|
||||
known[2] = today
|
||||
_save_soon()
|
||||
return dict(zip(FIELDS, known[3:]))
|
||||
found = inspect_file(path)
|
||||
with _lock:
|
||||
_load()[real] = [stat.st_size, stat.st_mtime_ns, today, *(found.get(field) for field in FIELDS)]
|
||||
_save_soon()
|
||||
return found
|
||||
@@ -0,0 +1,435 @@
|
||||
"""Putting a model file the user drops on the models page into the right models folder.
|
||||
|
||||
A browser gives a dropped file's name, size, time and bytes, never its path. So:
|
||||
GET /anomalous/import/folders -> {types: {type: roots}, settings}: the folders a model can go to
|
||||
(model_download.roots_of) and the download settings, whose folder rule ({base}) imports share.
|
||||
POST /anomalous/import/inspect?name&size&mtime body: the file's first bytes (its header)
|
||||
-> what it is (model_kind.classify), files of the same name already there, and, when this
|
||||
computer's Downloads or Desktop holds that very file (same name, size and time; asked only
|
||||
by the computer ComfyUI runs on), a `token` for it.
|
||||
POST /anomalous/import/identify {token} -> its SHA-256, what Civitai knows of it (base model,
|
||||
model and version name) and a scanned model with the same SHA-256, if any.
|
||||
POST /anomalous/import/place {token, type, root, rel, keep} -> moves that file there (a copy
|
||||
when `keep`); same drive = a rename, another drive = copy, then the original goes.
|
||||
PUT /anomalous/import/upload?type&root&rel&size body: the whole file -> written there.
|
||||
identify, place and upload answer only the computer ComfyUI runs on, unless remote changes
|
||||
are allowed (local_guard.may_change).
|
||||
|
||||
A file already at the destination is never replaced: the new one gets " (2)". Each import is
|
||||
one activity log entry (model_import).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
from ..model_identity import file_sha256
|
||||
from ..model_kind import classify, inspect_file, parse_gguf, parse_safetensors
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import file_sha256
|
||||
from model_kind import classify, inspect_file, parse_gguf, parse_safetensors
|
||||
from .activity_log import add_entry
|
||||
from .download_sources import SourceUnreachable, civitai_by_hash
|
||||
from .local_guard import is_local as _is_local, may_change
|
||||
from .model_download import (
|
||||
CHUNK, MODEL_EXTENSIONS, SPACE_MARGIN, clean_folder, folder_type_for, forget_file_lists, read_settings, roots_of,
|
||||
)
|
||||
from .path_utils import resolve_within
|
||||
|
||||
KINDS = ("checkpoints", "loras", "vae", "text_encoders", "diffusion_models", "controlnet", "embeddings",
|
||||
"clip_vision", "upscale_models", "ipadapter", "model_patches")
|
||||
SEARCH_DEPTH = 2
|
||||
SEARCH_LIMIT = 20000
|
||||
TIME_SLACK_MS = 2000
|
||||
KEEP_TOKENS = 200
|
||||
_FOLDER_IDS = { # Windows known folders: Downloads, Desktop
|
||||
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
|
||||
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
|
||||
}
|
||||
|
||||
_lock = threading.Lock()
|
||||
_tokens = {} # token -> {path, name, size}
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""An import that cannot be done; `code` says why (the page has a sentence for each)."""
|
||||
|
||||
def __init__(self, code):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _error(code, status=400):
|
||||
return web.json_response({"error": code}, status=status)
|
||||
|
||||
|
||||
# --- where the dropped file is on this computer --------------------------------------------
|
||||
|
||||
def _known_folder(name):
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
folder_id = uuid.UUID(_FOLDER_IDS[name]).bytes_le
|
||||
path = ctypes.c_wchar_p()
|
||||
shell = ctypes.windll.shell32
|
||||
shell.SHGetKnownFolderPath.argtypes = [ctypes.c_char_p, wintypes.DWORD, wintypes.HANDLE, ctypes.POINTER(ctypes.c_wchar_p)]
|
||||
if shell.SHGetKnownFolderPath(folder_id, 0, None, ctypes.byref(path)) == 0:
|
||||
found = path.value
|
||||
ctypes.windll.ole32.CoTaskMemFree(path)
|
||||
if found and os.path.isdir(found):
|
||||
return found
|
||||
except Exception:
|
||||
pass
|
||||
fallback = os.path.expanduser("~/" + name.capitalize())
|
||||
return fallback if os.path.isdir(fallback) else ""
|
||||
|
||||
|
||||
def search_folders():
|
||||
"""[(label, path)] where dropped files usually come from: Downloads, then Desktop."""
|
||||
found = []
|
||||
for label in ("downloads", "desktop"):
|
||||
path = _known_folder(label)
|
||||
if path and all(os.path.realpath(path) != os.path.realpath(other) for _, other in found):
|
||||
found.append((label, path))
|
||||
return found
|
||||
|
||||
|
||||
def find_original(name, size, mtime_ms, folders=None):
|
||||
"""(label, path) of the file with this name, size and time in `folders` (and their
|
||||
subfolders two deep), else None."""
|
||||
wanted = name.lower()
|
||||
seen = 0
|
||||
for label, base in folders if folders is not None else search_folders():
|
||||
for current, dirs, files in os.walk(base):
|
||||
depth = 0 if current == base else os.path.relpath(current, base).count(os.sep) + 1
|
||||
dirs[:] = sorted(item for item in dirs if not item.startswith(".")) if depth < SEARCH_DEPTH else []
|
||||
for file in files:
|
||||
seen += 1
|
||||
if seen > SEARCH_LIMIT:
|
||||
return None
|
||||
if file.lower() != wanted:
|
||||
continue
|
||||
path = os.path.join(current, file)
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
if stat.st_size == size and (not mtime_ms or abs(stat.st_mtime * 1000 - mtime_ms) <= TIME_SLACK_MS):
|
||||
return label, path
|
||||
return None
|
||||
|
||||
|
||||
def _remember(path, name, size):
|
||||
token = uuid.uuid4().hex
|
||||
with _lock:
|
||||
_tokens[token] = {"path": path, "name": name, "size": size}
|
||||
while len(_tokens) > KEEP_TOKENS:
|
||||
_tokens.pop(next(iter(_tokens)))
|
||||
return token
|
||||
|
||||
|
||||
def _original(token):
|
||||
with _lock:
|
||||
item = _tokens.get(str(token or ""))
|
||||
if not item or not os.path.isfile(item["path"]) or os.path.getsize(item["path"]) != item["size"]:
|
||||
raise Refused("gone")
|
||||
return item
|
||||
|
||||
|
||||
# --- what it is ----------------------------------------------------------------------------
|
||||
|
||||
def import_types():
|
||||
known = getattr(folder_paths, "folder_names_and_paths", {})
|
||||
return [kind for kind in KINDS if kind in known]
|
||||
|
||||
|
||||
def classify_head(name, head):
|
||||
"""classify() from a file's first bytes, by its extension."""
|
||||
lower = name.lower()
|
||||
if lower.endswith((".safetensors", ".sft")):
|
||||
return classify("safetensors", parse_safetensors(head))
|
||||
if lower.endswith(".gguf"):
|
||||
return classify("gguf", parse_gguf(head))
|
||||
return classify(None, None)
|
||||
|
||||
|
||||
def same_name(kind, name):
|
||||
"""Files of this name already in the type's folders: [{root, rel, size}]."""
|
||||
if not kind:
|
||||
return []
|
||||
wanted, found = name.lower(), []
|
||||
for index, base in enumerate(folder_paths.get_folder_paths(kind) or []):
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for current, _dirs, files in os.walk(base):
|
||||
for file in files:
|
||||
if file.lower() == wanted:
|
||||
path = os.path.join(current, file)
|
||||
found.append({"root": index, "rel": os.path.relpath(path, base).replace(os.sep, "/"),
|
||||
"size": os.path.getsize(path)})
|
||||
return found[:10]
|
||||
|
||||
|
||||
def inspect(name, size, mtime_ms, head, local):
|
||||
found = find_original(name, size, mtime_ms) if local else None
|
||||
if found:
|
||||
result = inspect_file(found[1])
|
||||
result.pop("format", None)
|
||||
result.update(token=_remember(found[1], name, size), where=found[0])
|
||||
else:
|
||||
result = classify_head(name, head)
|
||||
if result["kind"] not in import_types():
|
||||
result.update(kind="", sure=False)
|
||||
result["same_name"] = same_name(result["kind"], name)
|
||||
return result
|
||||
|
||||
|
||||
def _same_hash(sha256):
|
||||
"""Where a scanned model with this SHA-256 is ("sub/name.safetensors"), else None."""
|
||||
from .model_resolution import collect_model_hash_index
|
||||
names = [key for key, value in collect_model_hash_index().items() if str(value.get("hash") or "").lower() == sha256]
|
||||
return next((key for key in names if "/" in key), names[0] if names else None)
|
||||
|
||||
|
||||
def identify(token):
|
||||
"""SHA-256, Civitai's record, and an already scanned copy, of a found file."""
|
||||
item = _original(token)
|
||||
sha256 = file_sha256(item["path"])
|
||||
result = {"sha256": sha256, "civitai": None, "reason": "", "duplicate": _same_hash(sha256)}
|
||||
try:
|
||||
found = civitai_by_hash(sha256)
|
||||
if found:
|
||||
result["civitai"] = {key: found.get(key, "") for key in ("base_model", "model_name", "version_name", "page")}
|
||||
else:
|
||||
result["reason"] = "not_found"
|
||||
except SourceUnreachable:
|
||||
result["reason"] = "network"
|
||||
return result
|
||||
|
||||
|
||||
# --- putting it there ----------------------------------------------------------------------
|
||||
|
||||
def free_path(path):
|
||||
"""`path`, or "name (2).ext", "name (3).ext"… when it is taken."""
|
||||
if not os.path.exists(path) and not os.path.exists(path + ".part"):
|
||||
return path
|
||||
stem, ext = os.path.splitext(path)
|
||||
number = 2
|
||||
while os.path.exists(f"{stem} ({number}){ext}") or os.path.exists(f"{stem} ({number}){ext}.part"):
|
||||
number += 1
|
||||
return f"{stem} ({number}){ext}"
|
||||
|
||||
|
||||
def destination(data, size=0):
|
||||
"""(type, root index, root path, dest) for a place/upload request; Refused if refused."""
|
||||
folder_type = folder_type_for([data.get("type")])
|
||||
if folder_type not in import_types():
|
||||
raise Refused("bad_type")
|
||||
paths = folder_paths.get_folder_paths(folder_type) or []
|
||||
try:
|
||||
root_index = int(data.get("root", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise Refused("bad_root")
|
||||
if not 0 <= root_index < len(paths):
|
||||
raise Refused("bad_root")
|
||||
try:
|
||||
rel = clean_folder(data.get("rel"))
|
||||
root = os.path.realpath(paths[root_index])
|
||||
dest = resolve_within(root, *rel.split("/"))
|
||||
except ValueError:
|
||||
raise Refused("bad_path")
|
||||
if not rel.lower().endswith(MODEL_EXTENSIONS):
|
||||
raise Refused("bad_name")
|
||||
probe = root
|
||||
while probe and not os.path.isdir(probe):
|
||||
probe = os.path.dirname(probe)
|
||||
if size and probe and shutil.disk_usage(probe).free < size + SPACE_MARGIN:
|
||||
raise Refused("no_space")
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
return folder_type, root_index, root, free_path(dest)
|
||||
|
||||
|
||||
def _same_drive(first, second):
|
||||
"""Whether `first` can be renamed to `second`; a link counts where the link is (moving
|
||||
it moves the link, not the model disk behind it)."""
|
||||
try:
|
||||
return os.lstat(first).st_dev == os.stat(os.path.dirname(second)).st_dev
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _rename_new(source, dest):
|
||||
"""Renames `source` to `dest` and never over a file there, even one that appeared since
|
||||
free_path looked (FileExistsError). Windows' rename refuses a taken name by itself; elsewhere
|
||||
a hard link does (of a link itself, not what it points to), with a last look for the file
|
||||
systems that have none."""
|
||||
if os.name == "nt":
|
||||
os.rename(source, dest)
|
||||
return
|
||||
try:
|
||||
os.link(source, dest, follow_symlinks=False)
|
||||
except FileExistsError:
|
||||
raise
|
||||
except OSError: # no hard links here (FAT, some network shares)
|
||||
if os.path.lexists(dest):
|
||||
raise FileExistsError(dest)
|
||||
os.rename(source, dest)
|
||||
return
|
||||
os.remove(source)
|
||||
|
||||
|
||||
def _looks_whole(source, copy):
|
||||
"""Whether `copy` has `source`'s size and its very bytes at the start, the middle and the end
|
||||
(a megabyte each): a cut or garbled copy shows, at no felt cost, with no fingerprint needed."""
|
||||
size = os.path.getsize(source)
|
||||
if os.path.getsize(copy) != size:
|
||||
return False
|
||||
with open(source, "rb") as first, open(copy, "rb") as second:
|
||||
for at in sorted({0, max(0, size // 2 - CHUNK // 2), max(0, size - CHUNK)}):
|
||||
first.seek(at)
|
||||
second.seek(at)
|
||||
if first.read(CHUNK) != second.read(CHUNK):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def move_into(source, dest, keep):
|
||||
"""Moves (or with `keep` copies) `source` to `dest`, never over a file; a copy goes through
|
||||
"<dest>.part" and is checked (_looks_whole) before the original goes."""
|
||||
if not keep and _same_drive(source, dest):
|
||||
_rename_new(source, dest)
|
||||
return
|
||||
part = dest + ".part"
|
||||
try:
|
||||
shutil.copyfile(source, part)
|
||||
if not _looks_whole(source, part):
|
||||
raise Refused("incomplete")
|
||||
_rename_new(part, dest)
|
||||
except BaseException:
|
||||
if os.path.exists(part):
|
||||
os.remove(part) # our own unfinished copy
|
||||
raise
|
||||
if not keep:
|
||||
os.remove(source) # moved: the whole file is at dest now
|
||||
|
||||
|
||||
def _finish(folder_type, root_index, root, dest, how, size):
|
||||
forget_file_lists()
|
||||
rel = os.path.relpath(dest, root).replace(os.sep, "/")
|
||||
add_entry("file", "model_import", rel, {"import": {
|
||||
"type": folder_type, "path_idx": root_index, "rel": rel, "file": os.path.basename(dest), "size": size, "how": how,
|
||||
}})
|
||||
return {"type": folder_type, "root": root_index, "rel": rel, "file": os.path.basename(dest)}
|
||||
|
||||
|
||||
def place(data):
|
||||
item = _original(data.get("token"))
|
||||
keep = data.get("keep") is True
|
||||
folder_type, root_index, root, dest = destination(data, item["size"] if keep else 0)
|
||||
move_into(item["path"], dest, keep)
|
||||
if not keep:
|
||||
with _lock:
|
||||
_tokens.pop(str(data.get("token")), None)
|
||||
return _finish(folder_type, root_index, root, dest, "copy" if keep else "move", item["size"])
|
||||
|
||||
|
||||
# --- routes --------------------------------------------------------------------------------
|
||||
|
||||
async def api_folders(request):
|
||||
types = {}
|
||||
for kind in import_types():
|
||||
types[kind] = await asyncio.to_thread(roots_of, kind)
|
||||
return web.json_response({"types": types, "settings": read_settings()})
|
||||
|
||||
|
||||
async def api_inspect(request):
|
||||
name = os.path.basename(str(request.query.get("name") or "").replace("\\", "/"))
|
||||
if not name.lower().endswith(MODEL_EXTENSIONS):
|
||||
return _error("not_model")
|
||||
try:
|
||||
size = int(request.query.get("size") or 0)
|
||||
mtime_ms = float(request.query.get("mtime") or 0)
|
||||
except ValueError:
|
||||
return _error("bad_request")
|
||||
head = await request.read()
|
||||
result = await asyncio.to_thread(inspect, name, size, mtime_ms, head, _is_local(request))
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
async def api_identify(request):
|
||||
if not may_change(request):
|
||||
return _error("not_local", 403)
|
||||
try:
|
||||
data = await request.json()
|
||||
return web.json_response(await asyncio.to_thread(identify, data.get("token")))
|
||||
except Refused as error:
|
||||
return _error(error.code, 404)
|
||||
except (ValueError, AttributeError):
|
||||
return _error("bad_request")
|
||||
|
||||
|
||||
async def api_place(request):
|
||||
if not may_change(request):
|
||||
return _error("not_local", 403)
|
||||
try:
|
||||
data = await request.json()
|
||||
return web.json_response(await asyncio.to_thread(place, data))
|
||||
except Refused as error:
|
||||
return _error(error.code, 404 if error.code == "gone" else 400)
|
||||
except (ValueError, AttributeError):
|
||||
return _error("bad_request")
|
||||
except OSError as error:
|
||||
print(f"[Anomalous Browser] Import: could not put the file in place: {error}")
|
||||
return _error("io", 500)
|
||||
|
||||
|
||||
async def api_upload(request):
|
||||
if not may_change(request):
|
||||
return _error("not_local", 403)
|
||||
try:
|
||||
size = int(request.query.get("size") or 0)
|
||||
folder_type, root_index, root, dest = await asyncio.to_thread(destination, dict(request.query), size)
|
||||
except Refused as error:
|
||||
return _error(error.code)
|
||||
except ValueError:
|
||||
return _error("bad_request")
|
||||
part = dest + ".part"
|
||||
received = 0
|
||||
try:
|
||||
target = await asyncio.to_thread(open, part, "wb")
|
||||
try:
|
||||
async for chunk in request.content.iter_chunked(CHUNK):
|
||||
await asyncio.to_thread(target.write, chunk)
|
||||
received += len(chunk)
|
||||
finally:
|
||||
await asyncio.to_thread(target.close)
|
||||
if size and received != size:
|
||||
raise Refused("incomplete")
|
||||
dest = free_path(dest) if os.path.exists(dest) else dest
|
||||
await asyncio.to_thread(os.replace, part, dest)
|
||||
except BaseException as error:
|
||||
if os.path.exists(part):
|
||||
os.remove(part) # our own unfinished upload
|
||||
if isinstance(error, Refused):
|
||||
return _error(error.code)
|
||||
if isinstance(error, OSError):
|
||||
print(f"[Anomalous Browser] Import: upload failed: {error}")
|
||||
return _error("io", 500)
|
||||
raise
|
||||
return web.json_response(await asyncio.to_thread(_finish, folder_type, root_index, root, dest, "upload", received))
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get("/anomalous/import/folders", api_folders)
|
||||
app.router.add_post("/anomalous/import/inspect", api_inspect)
|
||||
app.router.add_post("/anomalous/import/identify", api_identify)
|
||||
app.router.add_post("/anomalous/import/place", api_place)
|
||||
app.router.add_put("/anomalous/import/upload", api_upload)
|
||||
+48
-4
@@ -9,8 +9,12 @@ from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
try:
|
||||
from ..model_sidecars import backups, is_civitai_cover, keep_aside
|
||||
except ImportError:
|
||||
from model_sidecars import backups, is_civitai_cover, keep_aside
|
||||
from .trash import move_to_trash
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
from .utils import model_file_within, require_filename, resolve_folder_subdir, resolve_within
|
||||
|
||||
def _cache_token(file_path):
|
||||
try:
|
||||
@@ -30,6 +34,14 @@ async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png
|
||||
|
||||
dest_path = os.path.join(target_dir, f"{base_name}{preview_ext}")
|
||||
|
||||
# Civitai's picture, when it is the cover, is kept aside first, to bring back from the editor.
|
||||
base = os.path.join(target_dir, base_name)
|
||||
covers = [path for path in (os.path.join(target_dir, f"{base_name}{ext}") for ext in PREVIEW_SUFFIXES) if os.path.isfile(path)]
|
||||
if not backups(base):
|
||||
civitai = next((path for path in covers if is_civitai_cover(path, base, [])), None)
|
||||
if civitai:
|
||||
keep_aside(base, civitai)
|
||||
|
||||
# Only one cover is active: the others go to the Recycle Bin (a failure keeps them).
|
||||
older = [os.path.join(target_dir, f"{base_name}{ext}") for ext in PREVIEW_SUFFIXES]
|
||||
try:
|
||||
@@ -132,12 +144,44 @@ def _preview_url_for_model(folder_type, path_idx, base_dir, file_path):
|
||||
)
|
||||
if not preview_file:
|
||||
return ""
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
return _media_url(folder_type, path_idx, base_dir, os.path.join(root, preview_file))
|
||||
|
||||
|
||||
def _media_url(folder_type, path_idx, base_dir, media_path):
|
||||
"""The /anomalous/image URL of a file beside a model, with a token that changes with it."""
|
||||
rel_subfolder = os.path.relpath(os.path.dirname(media_path), base_dir)
|
||||
if rel_subfolder == '.':
|
||||
rel_subfolder = '/'
|
||||
q_type = urllib.parse.quote(folder_type)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder)
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
version = _cache_token(os.path.join(root, preview_file))
|
||||
q_file = urllib.parse.quote(os.path.basename(media_path))
|
||||
version = _cache_token(media_path)
|
||||
return f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={version}"
|
||||
|
||||
|
||||
def _model_covers(folder_type, path_idx, base_dir, file_path):
|
||||
"""Civitai's own image kept aside (<model>.civitai_bak.*, beside or in .amb) as a URL, or "",
|
||||
and whether the cover shown is another picture (the user's), not Civitai's (model_sidecars)."""
|
||||
base = os.path.splitext(file_path)[0]
|
||||
kept = backups(base)
|
||||
saved = kept[0] if kept else ""
|
||||
shown = next((base + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if os.path.isfile(base + suffix)), "")
|
||||
custom = bool(shown) and not is_civitai_cover(shown, base, kept)
|
||||
return {"civitai": _media_url(folder_type, path_idx, base_dir, saved) if saved else "", "custom": custom}
|
||||
|
||||
|
||||
async def api_model_covers(request):
|
||||
"""GET /anomalous/model_covers?type&path_idx&subfolder&filename - for the model editor: the
|
||||
Civitai cover it can bring back, and whether the one shown is the user's."""
|
||||
try:
|
||||
folder_type = request.query.get('type', '')
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
filename = require_filename(request.query.get('filename', ''))
|
||||
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, request.query.get('subfolder', '/'))
|
||||
file_path = model_file_within(target_dir, filename)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return web.json_response({"error": "Invalid model location"}, status=400)
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response({"error": "Model not found"}, status=404)
|
||||
return web.json_response(await asyncio.to_thread(_model_covers, folder_type, path_idx, base_dir, file_path))
|
||||
|
||||
+26
-11
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from aiohttp import web
|
||||
@@ -21,9 +22,11 @@ from .trash import move_to_trash, trash_failure
|
||||
from .utils import atomic_write_json
|
||||
try:
|
||||
from ..model_identity import USER_INFO_SUFFIX, read_json
|
||||
from ..model_sidecars import drop_empty_hidden_dir, existing, follow, is_civitai_cover, note_cover_hash, target
|
||||
except ImportError:
|
||||
from model_identity import USER_INFO_SUFFIX, read_json
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
from model_sidecars import drop_empty_hidden_dir, existing, follow, is_civitai_cover, note_cover_hash, target
|
||||
from .utils import model_file_within, require_filename, resolve_folder_subdir
|
||||
|
||||
def _first_existing_sidecar(base_path, suffixes):
|
||||
for suffix in suffixes:
|
||||
@@ -34,8 +37,9 @@ def _first_existing_sidecar(base_path, suffixes):
|
||||
|
||||
|
||||
def _reset_model_cover(base_path):
|
||||
"""Restore a recoverable cover without destroying the only existing image."""
|
||||
backup_path, backup_suffix = _first_existing_sidecar(base_path, CIVITAI_BACKUP_SUFFIXES)
|
||||
"""Restore a recoverable cover without destroying the only existing image. Civitai's picture
|
||||
kept aside becomes the cover again and is then noted as Civitai's instead of kept twice."""
|
||||
backup_path, backup_suffix = next(iter(existing(base_path, CIVITAI_BACKUP_SUFFIXES)), (None, None))
|
||||
original_path, _ = _first_existing_sidecar(base_path, MEDIA_EXTENSIONS)
|
||||
preview_paths = [
|
||||
f"{base_path}{suffix}"
|
||||
@@ -53,6 +57,8 @@ def _reset_model_cover(base_path):
|
||||
shutil.copy2(backup_path, temp_path)
|
||||
os.replace(temp_path, restored_path)
|
||||
move_to_trash(*[path for path in preview_paths if path != restored_path])
|
||||
if note_cover_hash(base_path, restored_path):
|
||||
move_to_trash(backup_path) # the cover is Civitai's picture now: no second copy
|
||||
except Exception as exc:
|
||||
try:
|
||||
if os.path.isfile(temp_path):
|
||||
@@ -62,6 +68,9 @@ def _reset_model_cover(base_path):
|
||||
return False, 'restore_failed', str(exc)
|
||||
return True, 'civitai_backup', None
|
||||
|
||||
if any(is_civitai_cover(path, base_path, []) for path in preview_paths):
|
||||
return True, 'civitai_backup', None # Civitai's picture is the cover already
|
||||
|
||||
if original_path:
|
||||
try:
|
||||
move_to_trash(*preview_paths)
|
||||
@@ -96,7 +105,7 @@ async def api_delete_model(request):
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
model_path = resolve_within(target_dir, filename)
|
||||
model_path = model_file_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if not os.path.exists(model_path):
|
||||
@@ -111,13 +120,14 @@ async def api_delete_model(request):
|
||||
for model_ext in MODEL_EXTENSIONS if base_name + model_ext != filename
|
||||
)
|
||||
sidecars = [] if shared_stem_in_use else [
|
||||
os.path.join(target_dir, base_name + suffix) for suffix in SIDECAR_SUFFIXES
|
||||
path for path, _ in existing(os.path.join(target_dir, base_name), SIDECAR_SUFFIXES)
|
||||
]
|
||||
# The model and its covers and info go to the Recycle Bin together, or nothing moves.
|
||||
try:
|
||||
moved = move_to_trash(model_path, *sidecars)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": trash_failure(e)})
|
||||
drop_empty_hidden_dir(target_dir)
|
||||
deleted_files = [os.path.basename(path) for path in moved]
|
||||
request[ACTIVITY_DETAIL] = {"files": deleted_files}
|
||||
|
||||
@@ -134,6 +144,10 @@ async def api_delete_model(request):
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
def _is_web_link(text):
|
||||
return not text or (len(text) <= 2048 and re.fullmatch(r"https?://[^\s/]+\.\S+", text, re.IGNORECASE) is not None)
|
||||
|
||||
|
||||
async def api_update_metadata(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -162,7 +176,7 @@ async def api_update_metadata(request):
|
||||
try:
|
||||
filename = require_filename(extracted_filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
file_path = model_file_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
@@ -182,6 +196,9 @@ async def api_update_metadata(request):
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return web.json_response({"status": "error", "message": "Model not found"})
|
||||
# A source link is opened from the page: a web address or nothing.
|
||||
if custom_source_url is not None and not _is_web_link(str(custom_source_url).strip()):
|
||||
return web.json_response({"status": "error", "message": "The source link must be an http(s) address."}, status=400)
|
||||
|
||||
physical_rename_skipped = physical_rename_requested and is_physical_rename_protected(
|
||||
folder_type=folder_type,
|
||||
@@ -193,7 +210,7 @@ async def api_update_metadata(request):
|
||||
model_ext = os.path.splitext(file_path)[1]
|
||||
# The user's edits have a file of their own (<model>.anomalous.json): a scan never
|
||||
# writes it, and other tools that rewrite .civitai.info cannot lose them.
|
||||
user_file = base_name + USER_INFO_SUFFIX
|
||||
user_file = target(base_name, USER_INFO_SUFFIX)
|
||||
if os.path.exists(user_file) and read_json(user_file) is None:
|
||||
return web.json_response({"status": "error", "message": f"{os.path.basename(user_file)} is unreadable; nothing was changed."})
|
||||
user_data = read_json(user_file) or {"format": 1}
|
||||
@@ -226,10 +243,8 @@ async def api_update_metadata(request):
|
||||
if new_file_path != file_path and not os.path.exists(new_file_path):
|
||||
os.rename(file_path, new_file_path)
|
||||
|
||||
for suffix in SIDECAR_SUFFIXES:
|
||||
old_sidecar = f"{base_name}{suffix}"
|
||||
if os.path.isfile(old_sidecar):
|
||||
os.rename(old_sidecar, os.path.join(target_dir, f"{safe_name}{suffix}"))
|
||||
for old_sidecar, suffix in existing(base_name, SIDECAR_SUFFIXES):
|
||||
os.rename(old_sidecar, follow(old_sidecar, os.path.join(target_dir, safe_name), suffix))
|
||||
|
||||
new_filename = f"{safe_name}{model_ext}"
|
||||
elif os.path.exists(new_file_path) and new_file_path != file_path:
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Models in the wrong models folder, and identical copies (the models page's Tidy view,
|
||||
ui_model_tidy.js).
|
||||
|
||||
GET /anomalous/placement/check[?deep=1] -> {misplaced, duplicates, unchecked}
|
||||
misplaced: models whose header (model_kind.py, only when sure) names another type than
|
||||
the folder they are in, with where they belong (the same subfolder in a folder of that
|
||||
type, on the same drive when there is one) and whether they still load where they are.
|
||||
duplicates: files with the same SHA-256 — known from scans, else read now for files of
|
||||
the same size (small ones always, large ones with deep). unchecked: the same-size large
|
||||
files a deep check would still read.
|
||||
GET /anomalous/placement/look?type&root&rel -> {kind, sure, base}: what one model's header says
|
||||
it is, even when unsure (the models page's "Change type", ui_model_retype.js).
|
||||
POST /anomalous/placement/move {type, root, rel, to_type, to_root, to_rel} -> moves a model
|
||||
and its sidecars (covers, info, the user's notes); never over a file (" (2)"). Only for
|
||||
the computer ComfyUI runs on, unless remote changes are allowed (local_guard.may_change).
|
||||
|
||||
belongs() is the same check for one file (the AI's search_models and move_model,
|
||||
api/mcp_model_use.py). Removing a copy goes through /anomalous/delete_model (Recycle Bin). Headers and computed
|
||||
SHA-256s are kept in memory per file (path, size, time).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
from ..model_identity import file_sha256
|
||||
from ..model_kind import inspect_file
|
||||
from ..model_sidecars import drop_empty_hidden_dir, existing, follow
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import file_sha256
|
||||
from model_kind import inspect_file
|
||||
from model_sidecars import drop_empty_hidden_dir, existing, follow
|
||||
from .activity_log import add_entry
|
||||
from .local_guard import may_change
|
||||
from .metadata import get_metadata
|
||||
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, SIDECAR_SUFFIXES
|
||||
from .model_download import MODEL_EXTENSIONS, clean_folder, forget_file_lists
|
||||
from .model_import import Refused, destination, import_types, move_into
|
||||
from .path_utils import model_reference_within
|
||||
|
||||
# (folder type, what the file is): it loads there all the same.
|
||||
WORKS_ANYWAY = {("diffusion_models", "checkpoints")}
|
||||
# Same-size files up to this are read at once (LoRAs, embeddings); larger ones on "Compare".
|
||||
AUTO_HASH = 256 * 1024 * 1024
|
||||
CACHE_LIMIT = 5000
|
||||
|
||||
_lock = threading.Lock()
|
||||
_heads = {} # path -> (size, mtime_ns, inspect_file result)
|
||||
_hashes = {} # path -> (size, mtime_ns, sha256)
|
||||
|
||||
|
||||
def _cached(store, path, stat, compute):
|
||||
key = (stat.st_size, stat.st_mtime_ns)
|
||||
with _lock:
|
||||
hit = store.get(path)
|
||||
if hit and hit[:2] == key:
|
||||
return hit[2]
|
||||
value = compute(path)
|
||||
with _lock:
|
||||
if len(store) >= CACHE_LIMIT:
|
||||
store.clear()
|
||||
store[path] = (*key, value)
|
||||
return value
|
||||
|
||||
|
||||
def library():
|
||||
"""Every model file once: [{type, root, rel, path, size, dev}] (a folder listed under two
|
||||
types counts for the first)."""
|
||||
seen, files = set(), []
|
||||
for folder_type in import_types():
|
||||
for index, base in enumerate(folder_paths.get_folder_paths(folder_type) or []):
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for current, dirs, names in os.walk(base):
|
||||
dirs[:] = sorted(name for name in dirs if not name.startswith("."))
|
||||
for name in sorted(names):
|
||||
if not name.lower().endswith(MODEL_EXTENSIONS):
|
||||
continue
|
||||
path = os.path.join(current, name)
|
||||
real = os.path.realpath(path)
|
||||
if real in seen:
|
||||
continue
|
||||
seen.add(real)
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
files.append({"type": folder_type, "root": index, "rel": os.path.relpath(path, base).replace(os.sep, "/"),
|
||||
"path": path, "size": stat.st_size, "dev": stat.st_dev, "stat": stat})
|
||||
return files
|
||||
|
||||
|
||||
def _card(item):
|
||||
"""What the page shows of a file: its name (the user's, else Civitai's, else the file's),
|
||||
Civitai version and cover URL."""
|
||||
try:
|
||||
meta = get_metadata(item["path"])
|
||||
except Exception:
|
||||
meta = {}
|
||||
folder, name = os.path.split(item["path"])
|
||||
stem = os.path.splitext(name)[0]
|
||||
cover = next((stem + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
|
||||
if os.path.isfile(os.path.join(folder, stem + suffix))), "")
|
||||
sub = item["rel"].rpartition("/")[0]
|
||||
url = ""
|
||||
if cover:
|
||||
url = (f"/anomalous/image?type={urllib.parse.quote(item['type'])}&path_idx={item['root']}"
|
||||
f"&subfolder={urllib.parse.quote(sub)}&filename={urllib.parse.quote(cover)}")
|
||||
version = meta.get("version_name") or ""
|
||||
name = meta.get("custom_name") or ""
|
||||
if not name and meta.get("model_id"): # Civitai's "Model - Version": the model's part
|
||||
full = meta.get("name") or ""
|
||||
name = full[:-len(" - " + version)] if version and full.endswith(" - " + version) else full
|
||||
return {"name": name or stem, "version": version, "preview_url": url}
|
||||
|
||||
|
||||
def home_for(item, kind):
|
||||
"""Where a file of `kind` goes: its own subfolder and name, in a folder of that type,
|
||||
preferably on its drive (a move is then a rename)."""
|
||||
roots = []
|
||||
for index, base in enumerate(folder_paths.get_folder_paths(kind) or []):
|
||||
if os.path.isdir(base):
|
||||
roots.append((index, os.stat(base).st_dev))
|
||||
if not roots:
|
||||
return None
|
||||
index = next((index for index, dev in roots if dev == item["dev"]), roots[0][0])
|
||||
return {"type": kind, "root": index, "rel": item["rel"]}
|
||||
|
||||
|
||||
def misplaced(files):
|
||||
found = []
|
||||
for item in files:
|
||||
result = _cached(_heads, item["path"], item["stat"], inspect_file)
|
||||
kind = result.get("kind")
|
||||
if not kind or not result.get("sure") or kind == item["type"] or kind not in import_types():
|
||||
continue
|
||||
home = home_for(item, kind)
|
||||
if home:
|
||||
found.append({"type": item["type"], "root": item["root"], "rel": item["rel"], "size": item["size"],
|
||||
"kind": kind, "base": result.get("base", ""), "works": (item["type"], kind) in WORKS_ANYWAY,
|
||||
"to": home, **_card(item)})
|
||||
return found
|
||||
|
||||
|
||||
def one(folder_type, root, rel, path):
|
||||
"""One model file as library() lists it."""
|
||||
stat = os.stat(path)
|
||||
return {"type": folder_type, "root": root, "rel": rel, "path": path, "size": stat.st_size, "dev": stat.st_dev, "stat": stat}
|
||||
|
||||
|
||||
def belongs(item):
|
||||
"""misplaced() of one file (from one()): where it belongs, or None when it is where it goes or its kind is unsure."""
|
||||
found = misplaced([item])
|
||||
return found[0] if found else None
|
||||
|
||||
|
||||
def _known_hash(item):
|
||||
try:
|
||||
value = str(get_metadata(item["path"]).get("hash") or "").lower()
|
||||
except Exception:
|
||||
value = ""
|
||||
if len(value) == 64:
|
||||
return value
|
||||
with _lock:
|
||||
hit = _hashes.get(item["path"])
|
||||
return hit[2] if hit and hit[:2] == (item["stat"].st_size, item["stat"].st_mtime_ns) else ""
|
||||
|
||||
|
||||
def _copy(item):
|
||||
"""One of identical files: where it is, how it shows, whether its folder fits what it is
|
||||
(the copy to keep, when only one does)."""
|
||||
kind = _cached(_heads, item["path"], item["stat"], inspect_file).get("kind")
|
||||
return {"type": item["type"], "root": item["root"], "rel": item["rel"],
|
||||
"fits": not kind or kind == item["type"], **_card(item)}
|
||||
|
||||
|
||||
def duplicates(files, deep=False):
|
||||
"""(groups of identical files, {groups, bytes} still to read for a deep check)."""
|
||||
by_size = {}
|
||||
for item in files:
|
||||
if item["size"]:
|
||||
by_size.setdefault(item["size"], []).append(item)
|
||||
groups, unchecked = {}, {"groups": 0, "bytes": 0}
|
||||
for size, items in by_size.items():
|
||||
if len(items) < 2:
|
||||
continue
|
||||
unknown = [item for item in items if not _known_hash(item)]
|
||||
read = deep or size <= AUTO_HASH
|
||||
if unknown and not read:
|
||||
unchecked["groups"] += 1
|
||||
unchecked["bytes"] += size * len(unknown)
|
||||
for item in items:
|
||||
sha = _known_hash(item)
|
||||
if not sha and read:
|
||||
sha = _cached(_hashes, item["path"], item["stat"], file_sha256)
|
||||
if sha:
|
||||
groups.setdefault(sha, []).append(item)
|
||||
found = [{"sha256": sha, "size": items[0]["size"], "files": [_copy(item) for item in items]}
|
||||
for sha, items in groups.items() if len(items) > 1]
|
||||
return found, unchecked
|
||||
|
||||
|
||||
def check(deep=False):
|
||||
files = library()
|
||||
found, unchecked = duplicates(files, deep)
|
||||
return {"misplaced": misplaced(files), "duplicates": found, "unchecked": unchecked, "checked": len(files)}
|
||||
|
||||
|
||||
def _sidecars(path):
|
||||
"""The model's covers, info and notes: files named by its stem, beside it or in the folder's
|
||||
.amb, unless another model in its folder has that stem too (then they are shared, and stay)."""
|
||||
folder, name = os.path.split(path)
|
||||
stem = os.path.splitext(name)[0]
|
||||
if any(os.path.isfile(os.path.join(folder, stem + ext)) for ext in MODEL_EXTENSIONS if stem + ext != name):
|
||||
return []
|
||||
return existing(os.path.join(folder, stem), SIDECAR_SUFFIXES)
|
||||
|
||||
|
||||
def _source(folder_type, root, rel):
|
||||
"""(root index, clean rel, file) of the model at type `folder_type`, root index `root`, `rel`."""
|
||||
if folder_type not in import_types():
|
||||
raise Refused("bad_type")
|
||||
paths = folder_paths.get_folder_paths(folder_type) or []
|
||||
try:
|
||||
index = int(root)
|
||||
if index < 0:
|
||||
raise IndexError(index)
|
||||
base = os.path.realpath(paths[index])
|
||||
rel = clean_folder(rel)
|
||||
source = model_reference_within(base, rel.replace("/", os.sep))
|
||||
except (ValueError, TypeError, IndexError):
|
||||
raise Refused("bad_path")
|
||||
if not os.path.isfile(source) or not rel.lower().endswith(MODEL_EXTENSIONS):
|
||||
raise Refused("gone")
|
||||
return index, rel, source
|
||||
|
||||
|
||||
def look(folder_type, root, rel):
|
||||
"""What the header of one model says it is: {type, root, rel, kind, sure, base}."""
|
||||
index, rel, source = _source(folder_type, root, rel)
|
||||
found = _cached(_heads, source, os.stat(source), inspect_file)
|
||||
return {"type": folder_type, "root": index, "rel": rel, "kind": found.get("kind") or "",
|
||||
"sure": bool(found.get("sure")), "base": found.get("base") or ""}
|
||||
|
||||
|
||||
def move(data):
|
||||
folder_type = data.get("type")
|
||||
index, rel, source = _source(folder_type, data.get("root", 0), data.get("rel"))
|
||||
to_type, to_root, root_path, dest = destination(
|
||||
{"type": data.get("to_type"), "root": data.get("to_root", 0), "rel": data.get("to_rel")}, os.path.getsize(source))
|
||||
sidecars = _sidecars(source)
|
||||
move_into(source, dest, keep=False)
|
||||
moved, left = [os.path.basename(dest)], []
|
||||
dest_stem = os.path.splitext(dest)[0]
|
||||
for path, suffix in sidecars:
|
||||
target = follow(path, dest_stem, suffix)
|
||||
try:
|
||||
if os.path.exists(target):
|
||||
raise FileExistsError(target)
|
||||
move_into(path, target, keep=False)
|
||||
moved.append(os.path.basename(target))
|
||||
except OSError as error:
|
||||
left.append(os.path.basename(path))
|
||||
print(f"[Anomalous Browser] Tidy: {os.path.basename(path)} stayed where it was: {error}")
|
||||
drop_empty_hidden_dir(os.path.dirname(source))
|
||||
forget_file_lists()
|
||||
to_rel = os.path.relpath(dest, root_path).replace(os.sep, "/")
|
||||
add_entry("file", "model_move", to_rel, {"move": {
|
||||
"from": {"type": folder_type, "path_idx": index, "rel": rel},
|
||||
"to": {"type": to_type, "path_idx": to_root, "rel": to_rel}, "files": moved, "left": left,
|
||||
}})
|
||||
return {"type": to_type, "root": to_root, "rel": to_rel, "left": left}
|
||||
|
||||
|
||||
async def api_check(request):
|
||||
deep = request.query.get("deep") == "1"
|
||||
return web.json_response(await asyncio.to_thread(check, deep))
|
||||
|
||||
|
||||
async def api_look(request):
|
||||
query = request.query
|
||||
try:
|
||||
return web.json_response(await asyncio.to_thread(look, query.get("type"), query.get("root", "0"), query.get("rel")))
|
||||
except Refused as error:
|
||||
return web.json_response({"error": error.code}, status=404 if error.code == "gone" else 400)
|
||||
|
||||
|
||||
async def api_move(request):
|
||||
if not may_change(request):
|
||||
return web.json_response({"error": "not_local"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError
|
||||
return web.json_response(await asyncio.to_thread(move, data))
|
||||
except Refused as error:
|
||||
return web.json_response({"error": error.code}, status=404 if error.code == "gone" else 400)
|
||||
except ValueError:
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
except OSError as error:
|
||||
print(f"[Anomalous Browser] Tidy: could not move the model: {error}")
|
||||
return web.json_response({"error": "io"}, status=500)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get("/anomalous/placement/check", api_check)
|
||||
app.router.add_get("/anomalous/placement/look", api_look)
|
||||
app.router.add_post("/anomalous/placement/move", api_move)
|
||||
@@ -10,8 +10,10 @@ import folder_paths
|
||||
|
||||
try:
|
||||
from ..model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
|
||||
from ..model_sidecars import find, target
|
||||
except ImportError:
|
||||
from model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
|
||||
from model_sidecars import find, target
|
||||
try:
|
||||
from ..model_policies import requires_hash_for_model_recovery
|
||||
except ImportError:
|
||||
@@ -105,10 +107,9 @@ def _size_candidate_payload(candidate, require_hash=False):
|
||||
def _compute_and_save_fallback_info(file_path, file_hash):
|
||||
try:
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
info_path = base_path + ".info"
|
||||
civitai_info_path = base_path + ".civitai.info"
|
||||
if os.path.exists(info_path) or os.path.exists(civitai_info_path):
|
||||
info_path = info_path if os.path.exists(info_path) else civitai_info_path
|
||||
info_path = find(base_path, ".info") or (civitai_info_path if os.path.exists(civitai_info_path) else None)
|
||||
if info_path:
|
||||
with open(info_path, encoding='utf-8') as source:
|
||||
info_data = json.load(source)
|
||||
else:
|
||||
@@ -129,7 +130,7 @@ def _compute_and_save_fallback_info(file_path, file_hash):
|
||||
"files": [{"hashes": {"SHA256": file_hash.lower()}}]
|
||||
}
|
||||
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
|
||||
atomic_write_json(info_path, info_data)
|
||||
atomic_write_json(info_path or target(base_path, ".info"), info_data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
"""When each model and node pack here was last used (the models page's Tidy tab, "Not used lately").
|
||||
|
||||
GET /anomalous/usage/check -> {models, packs, seen, now, ledger_since}
|
||||
Evidence, read only when the user asks:
|
||||
- the run record (usage_ledger.py): every run since it started, kept or not, and every
|
||||
workflow opened on the page (the open tabs are noted just before a check);
|
||||
- the outputs from before that: PNG images (their prompt names model files and node types;
|
||||
image_search.cached_record) and FLAC / Ogg sounds (audio_metadata), dated by the file;
|
||||
- the saved ComfyUI workflows, recipes, combos and parameter sets (any string naming a model
|
||||
file, any node type, any SHA-256).
|
||||
A model counts as used when its path, its file name (two files of one name: both count, the
|
||||
safe side) or its scanned SHA-256 appears.
|
||||
Nothing new is judged: a model or pack counts as idle since its last use or since it came
|
||||
here (`added`: the file's or folder's creation date), whichever is later.
|
||||
models: idle for 30 days or more, with {type, root, rel, size, name, version, preview_url,
|
||||
last_used (seconds or null), outputs, runs, opened, saved (saved files naming it), added}.
|
||||
packs: the node packs in custom_nodes but this plugin and ComfyUI Manager, {name, nodes,
|
||||
last_used, outputs, runs, opened, saved, added, web (it adds to ComfyUI's page too), feeds (packs
|
||||
whose nodes take a kind of thing only its nodes make), words (what to search the Manager
|
||||
for), kept (the user said they still use it)}; a pack of no nodes only works on the page or
|
||||
the server, nothing here can tell.
|
||||
seen: {outputs, saved, capped} what was read (capped: some outputs or files were not).
|
||||
ledger_since: when the run record started (null: nothing run since).
|
||||
|
||||
POST /anomalous/usage/keep {pack, kept} -> {kept: [names]}
|
||||
What no record can show (a pack used only through its panels or buttons) the user says:
|
||||
"I still use it", kept in user/anomalous/usage_kept.json ({packs: {name: since}}); only a
|
||||
pack ComfyUI loaded can be marked, any name unmarked.
|
||||
|
||||
What none of this can show (a workflow from before the record, dropped in and never saved) is
|
||||
said on the page; nothing here deletes or disables anything.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from . import usage_ledger
|
||||
from .audio_metadata import read_audio_tags
|
||||
from .backup import part_dirs
|
||||
from .image_search import MODEL_FILE, cached_record
|
||||
from .model_placement import _card, _known_hash, library
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
RECENT_SECONDS = 30 * 24 * 3600 # used or added within this: not listed
|
||||
MAX_IMAGES = 100_000 # outputs read at most, images and sounds
|
||||
MAX_SAVED = 20_000
|
||||
MAX_JSON_BYTES = 20 * 1024 * 1024
|
||||
SAVED_PARTS = ("comfy_workflows", "recipes", "combos", "parameters")
|
||||
SOUNDS = (".flac", ".ogg", ".opus")
|
||||
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
NOT_LISTED = {os.path.basename(PLUGIN_DIR).lower(), "comfyui-manager"} # the tools doing the tidying
|
||||
FEEDS_SHOWN = 3
|
||||
# Words left out of what the Manager is searched for: its search wants all words, in order, in
|
||||
# one of a pack's title, author or description, and pack folders carry these where titles do not.
|
||||
SEARCH_NOISE = {"comfyui", "comfy", "custom", "customnodes", "node", "nodes", "pack", "main", "master"}
|
||||
|
||||
_kept_lock = threading.Lock()
|
||||
|
||||
|
||||
def _refs(value):
|
||||
ref = str(value).replace("\\", "/").strip().lower()
|
||||
return {ref, ref.rsplit("/", 1)[-1]}
|
||||
|
||||
|
||||
def _arrived(stat, changed=True):
|
||||
"""When a file or folder came here: its creation date, or its last change when that is later
|
||||
(`changed`; a copied file keeps its old change date, a folder's changes with every update)."""
|
||||
born = getattr(stat, "st_birthtime", None) or stat.st_ctime
|
||||
return max(born, stat.st_mtime) if changed else born
|
||||
|
||||
|
||||
class Evidence:
|
||||
"""Per model reference / hash / node type: how many outputs, runs, openings and saved files,
|
||||
and the latest time."""
|
||||
|
||||
def __init__(self):
|
||||
self.outputs, self.runs, self.opened, self.saved, self.last = {}, {}, {}, {}, {}
|
||||
self.seen = {"outputs": 0, "saved": 0, "capped": False}
|
||||
|
||||
def add(self, keys, when=None):
|
||||
for key in keys:
|
||||
if when is None:
|
||||
self.saved[key] = self.saved.get(key, 0) + 1
|
||||
else:
|
||||
self.outputs[key] = self.outputs.get(key, 0) + 1
|
||||
self.last[key] = max(self.last.get(key, 0), when)
|
||||
|
||||
def add_noted(self, counts, key, when, count):
|
||||
"""A run record entry; `counts` is self.runs or self.opened."""
|
||||
counts[key] = counts.get(key, 0) + count
|
||||
self.last[key] = max(self.last.get(key, 0), when)
|
||||
|
||||
def of(self, keys):
|
||||
keys = [key for key in keys if key]
|
||||
last = max((self.last.get(key, 0) for key in keys), default=0)
|
||||
return {"last_used": last or None,
|
||||
**{kind: max((counts.get(key, 0) for key in keys), default=0)
|
||||
for kind, counts in (("outputs", self.outputs), ("runs", self.runs), ("opened", self.opened), ("saved", self.saved))}}
|
||||
|
||||
|
||||
def _walk_json(value, found):
|
||||
"""Model references ('m:'), SHA-256s ('h:') and node types ('t:') anywhere in saved JSON."""
|
||||
stack = [value]
|
||||
while stack:
|
||||
item = stack.pop()
|
||||
if isinstance(item, dict):
|
||||
for key in ("type", "class_type"):
|
||||
if isinstance(item.get(key), str) and item[key]:
|
||||
found.add("t:" + item[key])
|
||||
stack.extend(item.values())
|
||||
elif isinstance(item, list):
|
||||
stack.extend(item)
|
||||
elif isinstance(item, str):
|
||||
if MODEL_FILE.search(item):
|
||||
found.update("m:" + ref for ref in _refs(item))
|
||||
elif SHA256.match(item):
|
||||
found.add("h:" + item.upper())
|
||||
|
||||
|
||||
def _read_saved(evidence):
|
||||
dirs = part_dirs()
|
||||
for part in SAVED_PARTS:
|
||||
base = dirs.get(part)
|
||||
if not base or not os.path.isdir(base):
|
||||
continue
|
||||
for current, folders, names in os.walk(base):
|
||||
folders[:] = [name for name in folders if not name.startswith(".")]
|
||||
for name in names:
|
||||
if not name.lower().endswith(".json"):
|
||||
continue
|
||||
if evidence.seen["saved"] >= MAX_SAVED:
|
||||
evidence.seen["capped"] = True
|
||||
return
|
||||
path = os.path.join(current, name)
|
||||
try:
|
||||
if os.path.getsize(path) > MAX_JSON_BYTES:
|
||||
continue
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
found = set()
|
||||
_walk_json(data, found)
|
||||
evidence.seen["saved"] += 1
|
||||
evidence.add(found)
|
||||
|
||||
|
||||
def _sound_keys(path):
|
||||
"""What a sound's embedded prompt names, as evidence keys; empty without one."""
|
||||
try:
|
||||
prompt = json.loads(read_audio_tags(path).get("prompt") or "null")
|
||||
except (OSError, ValueError):
|
||||
return set()
|
||||
if not isinstance(prompt, dict):
|
||||
return set()
|
||||
types, models = usage_ledger.prompt_uses(prompt)
|
||||
return {"t:" + node_type for node_type in types} | {"m:" + ref for model in models for ref in _refs(model)}
|
||||
|
||||
|
||||
def _read_outputs(evidence):
|
||||
base = folder_paths.get_output_directory()
|
||||
for current, folders, names in os.walk(base):
|
||||
folders[:] = [name for name in folders if not name.startswith(".")]
|
||||
for name in names:
|
||||
lower = name.lower()
|
||||
if not lower.endswith((".png",) + SOUNDS):
|
||||
continue
|
||||
if evidence.seen["outputs"] >= MAX_IMAGES:
|
||||
evidence.seen["capped"] = True
|
||||
return
|
||||
path = os.path.join(current, name)
|
||||
try:
|
||||
when = os.stat(path).st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if lower.endswith(SOUNDS):
|
||||
keys = _sound_keys(path)
|
||||
else:
|
||||
record = cached_record(path, when)
|
||||
if not record:
|
||||
continue
|
||||
keys = {"m:" + ref for ref in record["models"]} | {"h:" + sha for sha in record["hashes"]}
|
||||
keys |= {"t:" + node_type for node_type in record.get("types", ())}
|
||||
if not keys:
|
||||
continue
|
||||
evidence.seen["outputs"] += 1
|
||||
evidence.add(keys, when)
|
||||
|
||||
|
||||
def _read_ledger(evidence):
|
||||
"""Every run and opened workflow since the record started; its start, or None."""
|
||||
ledger = usage_ledger.read()
|
||||
for names, counts in ((ledger, evidence.runs), (ledger["opened"], evidence.opened)):
|
||||
for node_type, (when, count) in names["types"].items():
|
||||
evidence.add_noted(counts, "t:" + node_type, when, count)
|
||||
for model, (when, count) in names["models"].items():
|
||||
for ref in _refs(model):
|
||||
evidence.add_noted(counts, "m:" + ref, when, count)
|
||||
return ledger["started"]
|
||||
|
||||
|
||||
def _io_types(cls):
|
||||
"""(kinds of thing a node takes, kinds it makes); combo lists and '*' left out."""
|
||||
taken, made = set(), set()
|
||||
try:
|
||||
inputs = cls.INPUT_TYPES() or {}
|
||||
for group in ("required", "optional"):
|
||||
for spec in (inputs.get(group) or {}).values():
|
||||
kind = spec[0] if isinstance(spec, (list, tuple)) and spec else spec
|
||||
if isinstance(kind, str):
|
||||
taken.add(kind)
|
||||
except Exception: # noqa: BLE001 - a pack's own code; it only means "takes nothing known"
|
||||
pass
|
||||
try:
|
||||
made = {kind for kind in (cls.RETURN_TYPES or ()) if isinstance(kind, str)}
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return taken - {"*"}, made - {"*"}
|
||||
|
||||
|
||||
def _search_words(name):
|
||||
words = [word for word in re.split(r"[\s_.\-]+", name.lower()) if word and word not in SEARCH_NOISE]
|
||||
return " ".join(words) or name.lower()
|
||||
|
||||
|
||||
def _pack_dirs():
|
||||
"""{name: {name, path, dir, types: []}} for the node packs ComfyUI loaded from custom_nodes."""
|
||||
import nodes # ComfyUI's registry, filled once ComfyUI has loaded the packs
|
||||
|
||||
roots = []
|
||||
try:
|
||||
roots = [os.path.normcase(os.path.realpath(root)) for root in folder_paths.get_folder_paths("custom_nodes") or []]
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
packs = {}
|
||||
for module, directory in (getattr(nodes, "LOADED_MODULE_DIRS", {}) or {}).items():
|
||||
real = os.path.normcase(os.path.realpath(directory))
|
||||
name = os.path.splitext(os.path.basename(module))[0]
|
||||
if real in roots: # a single .py file in custom_nodes
|
||||
packs[name] = {"name": name, "path": os.path.join(directory, name + ".py"), "dir": None, "types": []}
|
||||
elif os.path.normcase(os.path.realpath(os.path.dirname(directory))) in roots:
|
||||
packs[name] = {"name": name, "path": directory, "dir": real, "types": []}
|
||||
return packs
|
||||
|
||||
|
||||
def _packs():
|
||||
"""[{name, path, dir, types, web, feeds}] for the node packs ComfyUI loaded from custom_nodes."""
|
||||
import nodes
|
||||
|
||||
packs = _pack_dirs()
|
||||
io, core = {}, set()
|
||||
for node_type, cls in (getattr(nodes, "NODE_CLASS_MAPPINGS", {}) or {}).items():
|
||||
module = str(getattr(cls, "RELATIVE_PYTHON_MODULE", "") or "")
|
||||
taken, made = _io_types(cls)
|
||||
pack = module.split(".", 2)[1] if module.startswith("custom_nodes.") else None
|
||||
if pack in packs:
|
||||
packs[pack]["types"].append(node_type)
|
||||
io.setdefault(pack, [set(), set()])
|
||||
io[pack][0] |= taken
|
||||
io[pack][1] |= made
|
||||
elif pack is None:
|
||||
core |= taken | made
|
||||
|
||||
web_dirs = [os.path.normcase(os.path.realpath(path)) for path in (getattr(nodes, "EXTENSION_WEB_DIRS", {}) or {}).values()]
|
||||
for name, pack in packs.items():
|
||||
pack["web"] = bool(pack["dir"]) and any(path == pack["dir"] or path.startswith(pack["dir"] + os.sep) for path in web_dirs)
|
||||
own = io.get(name, [set(), set()])[1] - core
|
||||
pack["feeds"] = sorted((other for other, (taken, _) in io.items() if other != name and own & taken), key=str.lower)
|
||||
return [pack for name, pack in packs.items() if name.lower() not in NOT_LISTED]
|
||||
|
||||
|
||||
def kept_path():
|
||||
return os.path.join(os.path.dirname(usage_ledger.ledger_path()), "usage_kept.json")
|
||||
|
||||
|
||||
def _kept():
|
||||
"""{pack name: since} the user still uses."""
|
||||
try:
|
||||
with open(kept_path(), encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
packs = data.get("packs") if isinstance(data, dict) else None
|
||||
return packs if isinstance(packs, dict) else {}
|
||||
|
||||
|
||||
def keep(name, kept):
|
||||
"""Marks pack `name` as still used (`kept`) or not; the names marked. KeyError: not a loaded pack."""
|
||||
with _kept_lock:
|
||||
packs = _kept()
|
||||
if kept:
|
||||
if name not in _pack_dirs() or name.lower() in NOT_LISTED:
|
||||
raise KeyError(name)
|
||||
packs[name] = time.time()
|
||||
else:
|
||||
packs.pop(name, None)
|
||||
atomic_write_json(kept_path(), {"packs": packs})
|
||||
return sorted(packs)
|
||||
|
||||
|
||||
def check(now=None):
|
||||
now = time.time() if now is None else now
|
||||
evidence = Evidence()
|
||||
ledger_since = _read_ledger(evidence)
|
||||
_read_saved(evidence)
|
||||
_read_outputs(evidence)
|
||||
|
||||
models = []
|
||||
for item in library():
|
||||
keys = ["m:" + ref for ref in _refs(item["rel"])]
|
||||
sha = _known_hash(item)
|
||||
if sha:
|
||||
keys.append("h:" + sha.upper())
|
||||
use = evidence.of(keys)
|
||||
added = _arrived(item["stat"]) if item.get("stat") else None
|
||||
if now - max(use["last_used"] or 0, added or 0) < RECENT_SECONDS:
|
||||
continue
|
||||
models.append({"type": item["type"], "root": item["root"], "rel": item["rel"], "size": item["size"],
|
||||
"added": added, **_card(item), **use})
|
||||
|
||||
packs, kept = [], _kept()
|
||||
for pack in sorted(_packs(), key=lambda entry: entry["name"].lower()):
|
||||
uses = [evidence.of(["t:" + node_type]) for node_type in pack["types"]]
|
||||
try:
|
||||
added = _arrived(os.stat(pack["path"]), changed=False)
|
||||
except OSError:
|
||||
added = None
|
||||
packs.append({"name": pack["name"], "nodes": len(pack["types"]), "added": added, "web": pack["web"],
|
||||
"feeds": pack["feeds"][:FEEDS_SHOWN], "words": _search_words(pack["name"]), "kept": pack["name"] in kept,
|
||||
"last_used": max((use["last_used"] or 0 for use in uses), default=0) or None,
|
||||
**{kind: max((use[kind] for use in uses), default=0) for kind in ("outputs", "runs", "opened", "saved")}})
|
||||
return {"models": models, "packs": packs, "seen": evidence.seen, "now": now, "ledger_since": ledger_since}
|
||||
|
||||
|
||||
async def api_check(request):
|
||||
return web.json_response(await asyncio.to_thread(check))
|
||||
|
||||
|
||||
async def api_keep(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
name, kept = (data.get("pack"), data.get("kept")) if isinstance(data, dict) else (None, None)
|
||||
if not isinstance(name, str) or not name or not isinstance(kept, bool):
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
try:
|
||||
return web.json_response({"kept": await asyncio.to_thread(keep, name, kept)})
|
||||
except KeyError:
|
||||
return web.json_response({"error": "unknown_pack"}, status=404)
|
||||
except (OSError, ValueError):
|
||||
return web.json_response({"error": "write_failed"}, status=500)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get("/anomalous/usage/check", api_check)
|
||||
app.router.add_post("/anomalous/usage/keep", api_keep)
|
||||
+1
-1
@@ -10,7 +10,7 @@ from .model_catalog import (
|
||||
_allowed_folder_types, _collect_all_scan_models, _collect_folder_models,
|
||||
_collect_folders, _find_model_sync, _iter_search_models, _model_info_for_path,
|
||||
_resolve_paths_to_model_info_sync, _resolve_paths_to_previews_sync,
|
||||
api_base_models, api_batch_select, api_compatible_models, api_find_model,
|
||||
api_base_models, api_compatible_models, api_find_model,
|
||||
api_get_all_scan_models, api_get_folders, api_get_models,
|
||||
api_resolve_paths_to_previews,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""GET /anomalous/civitai_check: whether this computer reaches Civitai now, through the proxy in
|
||||
Settings -> Network when one is set (net_proxy.py); for Settings' "Test" button and the first-run
|
||||
setup. One small request with the key, if one is set, so a key Civitai refuses is told apart;
|
||||
when civitai.com does not answer, Civitai's other address (civitai.red) is asked too, and the
|
||||
answer says which one answered (`host`). It goes online, so it answers this computer only
|
||||
(local_guard LOCAL_GETS)."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .download_sources import civitai_headers
|
||||
|
||||
try:
|
||||
from ..civitai_client import API_HOSTS, answered
|
||||
from ..net_proxy import configured, failure, urlopen
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from civitai_client import API_HOSTS, answered
|
||||
from net_proxy import configured, failure, urlopen
|
||||
|
||||
PROBE_PATH = "/api/v1/models?limit=1"
|
||||
TIMEOUT = 8
|
||||
|
||||
|
||||
def check_civitai():
|
||||
"""{reachable, key ('set' | 'none' | 'refused'), ms, error, failure (net_proxy.failure), proxy,
|
||||
host (the address that answered, or '')}."""
|
||||
headers = civitai_headers()
|
||||
key = "set" if "Authorization" in headers else "none"
|
||||
started = time.monotonic()
|
||||
|
||||
def answer(reachable, key, error="", why="", host=""):
|
||||
return {"reachable": reachable, "key": key, "ms": round((time.monotonic() - started) * 1000),
|
||||
"error": error, "failure": why, "proxy": configured(), "host": host}
|
||||
first = None
|
||||
for host in API_HOSTS:
|
||||
try:
|
||||
with urlopen(urllib.request.Request(f"https://{host}{PROBE_PATH}", headers=headers), timeout=TIMEOUT) as response:
|
||||
response.read(1024)
|
||||
answered(host)
|
||||
return answer(True, key, host=host)
|
||||
except urllib.error.HTTPError as error:
|
||||
# Civitai answered: the network is fine; 401/403 with a key means the key is refused.
|
||||
answered(host)
|
||||
if error.code in (401, 403) and key == "set":
|
||||
return answer(True, "refused", f"HTTP {error.code}", failure(error), host)
|
||||
return answer(error.code < 500, key, f"HTTP {error.code}", failure(error), host)
|
||||
except (urllib.error.URLError, OSError, ValueError) as error:
|
||||
reason = getattr(error, "reason", None) or error
|
||||
first = first or answer(False, key, str(reason)[:200], failure(error))
|
||||
return first
|
||||
|
||||
|
||||
async def api_civitai_check(request):
|
||||
return web.json_response(await asyncio.to_thread(check_civitai))
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get("/anomalous/civitai_check", api_civitai_check)
|
||||
+56
-1
@@ -9,7 +9,7 @@ from aiohttp import web
|
||||
import folder_paths
|
||||
from .utils import require_filename, resolve_within
|
||||
from .recipe_constants import MAX_RECIPE_BYTES
|
||||
from .recipe_images import _parameter_gallery_images
|
||||
from .recipe_images import _parameter_gallery_images, _pictures_by_signature
|
||||
from .recipe_store import get_recipes_dir, _read_recipe
|
||||
from .workflow_schema import _parameter_signature
|
||||
from .trash import move_to_trash
|
||||
@@ -50,6 +50,42 @@ async def api_get_parameters(request):
|
||||
return web.json_response({"notebooks": notebooks})
|
||||
|
||||
|
||||
MAX_COUNTED_NOTEBOOKS = 5000
|
||||
|
||||
|
||||
def _parameter_set_signatures(parameters_dir):
|
||||
"""Each recipe's parameter sets by their values' signature: {recipe filename: [signature]},
|
||||
so the page can leave out the set saved with the recipe itself. Files over the save limit,
|
||||
unreadable ones and any past the first MAX_COUNTED_NOTEBOOKS are skipped."""
|
||||
sets = {}
|
||||
try:
|
||||
filenames = sorted(name for name in os.listdir(parameters_dir) if name.endswith(".json"))
|
||||
except OSError:
|
||||
return sets
|
||||
for filename in filenames[:MAX_COUNTED_NOTEBOOKS]:
|
||||
try:
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
if os.path.getsize(file_path) > MAX_RECIPE_BYTES:
|
||||
continue
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
recipe_filename = data.get("recipe_filename")
|
||||
signature = data.get("parameter_signature")
|
||||
value = signature.get("value") if isinstance(signature, dict) else None
|
||||
if isinstance(recipe_filename, str) and recipe_filename:
|
||||
sets.setdefault(recipe_filename, []).append(value if isinstance(value, str) else "")
|
||||
return sets
|
||||
|
||||
|
||||
async def api_get_parameter_set_signatures(request):
|
||||
sets = await asyncio.to_thread(_parameter_set_signatures, get_parameters_dir())
|
||||
return web.json_response({"sets": sets})
|
||||
|
||||
|
||||
_parameter_notebooks_cache = None
|
||||
_parameter_notebooks_cache_time = 0
|
||||
|
||||
@@ -152,10 +188,12 @@ async def api_get_parameters_by_type(request):
|
||||
if recipe_fn not in grouped:
|
||||
grouped[recipe_fn] = []
|
||||
|
||||
signature = (nb.get("data", {}).get("parameter_signature") or {})
|
||||
grouped[recipe_fn].append({
|
||||
"filename": nb.get("filename"),
|
||||
"name": nb.get("name"),
|
||||
"timestamp": nb.get("timestamp"),
|
||||
"signature": signature.get("value") if isinstance(signature, dict) else "",
|
||||
"nodes": matched_nodes
|
||||
})
|
||||
|
||||
@@ -349,6 +387,23 @@ async def api_rename_parameter(request):
|
||||
|
||||
return web.json_response({"status": "success", "filename": filename, "name": new_name})
|
||||
|
||||
MAX_PICTURE_SIGNATURES = 64
|
||||
|
||||
|
||||
async def api_get_parameter_pictures(request):
|
||||
"""GET /anomalous/parameter_pictures?signatures=a,b - for Current node: how many recent
|
||||
output pictures were made with each parameter set's values, and the newest, in one pass."""
|
||||
signatures = {value.lower() for value in request.query.get("signatures", "").split(",") if value}
|
||||
if not signatures or len(signatures) > MAX_PICTURE_SIGNATURES \
|
||||
or any(not re.fullmatch(r"[0-9a-f]{64}", value) for value in signatures):
|
||||
return web.json_response({"status": "error", "message": "Invalid signatures"}, status=400)
|
||||
try:
|
||||
pictures, scanned = await asyncio.to_thread(_pictures_by_signature, signatures)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read the pictures"}, status=500)
|
||||
return web.json_response({"status": "success", "pictures": pictures, "scanned": scanned})
|
||||
|
||||
|
||||
async def api_get_parameter_gallery(request):
|
||||
"""Find recent output PNGs whose embedded parameters match one parameter notebook."""
|
||||
try:
|
||||
|
||||
+18
-4
@@ -32,16 +32,30 @@ def _model_roots():
|
||||
yield folder_type, path_index, os.path.realpath(base_dir)
|
||||
|
||||
|
||||
def model_file_within(directory, filename):
|
||||
"""The model file `filename` (a bare name) in `directory`, which was resolved inside its
|
||||
models folder already, as named: a model that is a link stays the link (on a cloud server
|
||||
the models folder links into a shared, read-only model disk). Its covers and info are the
|
||||
ones beside the link, edits are written there and a delete moves the link; only the folder
|
||||
part must stay inside the models folder, and the name cannot leave it."""
|
||||
return os.path.join(directory, require_filename(filename))
|
||||
|
||||
|
||||
def model_reference_within(base_dir, relative):
|
||||
"""model_file_within for a path relative to the models folder `base_dir` (subfolders and
|
||||
a file name): the subfolders must stay inside it, through links too."""
|
||||
head, name = os.path.split(relative)
|
||||
return model_file_within(resolve_within(base_dir, head), name)
|
||||
|
||||
|
||||
def _resolve_exact_model_reference(saved_value):
|
||||
"""Resolve one saved model value without recursive scanning or hashing."""
|
||||
if not isinstance(saved_value, str) or not saved_value.strip():
|
||||
return None
|
||||
relative_value = saved_value.replace("/", os.sep)
|
||||
for folder_type, path_index, base_dir in _model_roots():
|
||||
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
|
||||
try:
|
||||
if os.path.commonpath((base_dir, candidate)) != base_dir:
|
||||
continue
|
||||
candidate = model_reference_within(base_dir, relative_value)
|
||||
except ValueError:
|
||||
continue
|
||||
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
|
||||
@@ -68,7 +82,7 @@ def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
|
||||
|
||||
|
||||
def require_filename(filename):
|
||||
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
|
||||
if not isinstance(filename, str) or filename in ("", ".", "..") or os.path.basename(filename) != filename:
|
||||
raise ValueError("Invalid filename")
|
||||
return filename
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Every model file in a few words, for the canvas's quick search (GET /anomalous/quick_index,
|
||||
web/modules/quick_search_data.js): its folder type, place and file, the name it shows, its
|
||||
base model, a few trigger words and its cover's URL. Read once when the search bar opens;
|
||||
the bar keeps it a little while. Files a folder type shares with another (unet and
|
||||
diffusion_models) are listed once; node packs' own files are not models."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .model_catalog import _collect_all_scan_models
|
||||
|
||||
MAX_WORDS = 6
|
||||
|
||||
|
||||
def _words(meta):
|
||||
words = meta.get("trainedWords") or meta.get("trigger_words") or []
|
||||
if isinstance(words, str):
|
||||
words = [words]
|
||||
return [str(word) for word in words if str(word).strip()][:MAX_WORDS]
|
||||
|
||||
|
||||
def compact_models(models):
|
||||
"""`models` (the shape GET /anomalous/all_scan_models gives) in a few words each."""
|
||||
seen, found = set(), []
|
||||
for model in models:
|
||||
if model.get("type") == "custom_nodes":
|
||||
continue
|
||||
key = (model.get("subfolder") or "", model.get("filename"), model.get("size_bytes"))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
meta = model.get("metadata") or {}
|
||||
found.append({
|
||||
"type": model.get("type"),
|
||||
"path_idx": model.get("path_idx", 0),
|
||||
"subfolder": model.get("subfolder") or "",
|
||||
"filename": model.get("filename"),
|
||||
"name": str(meta.get("custom_name") or "").strip(),
|
||||
"civitai_name": str(meta.get("name") or "").strip(),
|
||||
"base": str(meta.get("baseModel") or "").strip(),
|
||||
"words": _words(meta),
|
||||
"preview_url": model.get("preview_url") or "",
|
||||
})
|
||||
return found
|
||||
|
||||
|
||||
async def api_quick_index(request):
|
||||
payload = await asyncio.to_thread(_collect_all_scan_models, 1, 0)
|
||||
return web.json_response({"models": compact_models(payload.get("models") or [])})
|
||||
@@ -7,6 +7,9 @@ MAX_TAGS = 20
|
||||
MAX_TAG_LENGTH = 60
|
||||
MAX_NOTES_LENGTH = 3000
|
||||
MAX_MODEL_NOTE_LENGTH = 1000
|
||||
MAX_FOCUS_PARAMS = 12
|
||||
MAX_FOCUS_NOTE_LENGTH = 120
|
||||
MAX_FOCUS_NAME_LENGTH = 200
|
||||
MAX_THUMBNAIL_LENGTH = 1_500_000
|
||||
MAX_SOURCE_SUBFOLDER_LENGTH = 500
|
||||
MAX_RECIPE_BYTES = 12 * 1024 * 1024
|
||||
|
||||
+30
-1
@@ -1,6 +1,7 @@
|
||||
"""Contained Recipe image assets and bounded output-gallery inspection."""
|
||||
|
||||
from io import BytesIO
|
||||
from functools import lru_cache
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
@@ -192,6 +193,34 @@ def _recipe_gallery_images(node_signature):
|
||||
return matches, scanned
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _picture_parameter_signature(image_path, mtime_ns):
|
||||
"""A picture's parameter signature, read once per version of the file."""
|
||||
return _embedded_parameter_signature(image_path)
|
||||
|
||||
|
||||
def _pictures_by_signature(signatures):
|
||||
"""({signature: {"count", "newest": {filename, subfolder}}}, pictures read) for the recent
|
||||
output pictures made with each of `signatures`, newest first, in one pass."""
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
found = {}
|
||||
if not os.path.isdir(output_dir):
|
||||
return found, 0
|
||||
scanned = 0
|
||||
for mtime_ns, image_path in _recent_output_pngs(output_dir):
|
||||
scanned += 1
|
||||
signature = _picture_parameter_signature(image_path, mtime_ns)
|
||||
if signature not in signatures:
|
||||
continue
|
||||
entry = found.setdefault(signature, {"count": 0, "newest": None})
|
||||
entry["count"] += 1
|
||||
if entry["newest"] is None:
|
||||
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
|
||||
entry["newest"] = {"filename": os.path.basename(image_path),
|
||||
"subfolder": "" if relative_dir == "." else relative_dir.replace("\\", "/")}
|
||||
return found, scanned
|
||||
|
||||
|
||||
def _parameter_gallery_images(parameter_signature):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.isdir(output_dir):
|
||||
@@ -200,7 +229,7 @@ def _parameter_gallery_images(parameter_signature):
|
||||
scanned = 0
|
||||
for mtime_ns, image_path in _recent_output_pngs(output_dir):
|
||||
scanned += 1
|
||||
if _embedded_parameter_signature(image_path) != parameter_signature:
|
||||
if _picture_parameter_signature(image_path, mtime_ns) != parameter_signature:
|
||||
continue
|
||||
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
|
||||
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
|
||||
|
||||
+45
-80
@@ -1,4 +1,7 @@
|
||||
"""Bounded import/export for self-contained Workflow Recipe packages."""
|
||||
"""Recipe packages (配方包): one Workflow Recipe as a .zip to give to someone else — the recipe,
|
||||
its cover, its models' thumbnails and, when asked, its versions — and taking one in as a new
|
||||
recipe card. Bounded: entry names, count, sizes, compression ratio and checksums are checked
|
||||
before anything is written; an import never replaces a recipe (a taken name gets " (2)")."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
@@ -9,6 +12,7 @@ import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
@@ -19,10 +23,6 @@ from .recipe_store import get_recipes_dir
|
||||
from .utils import require_filename, resolve_within
|
||||
|
||||
|
||||
# Release gates: package transfers stay closed until validation is complete.
|
||||
# Keep the format implementation for compatibility testing.
|
||||
RECIPE_PACKAGE_EXPORT_ENABLED = False
|
||||
RECIPE_PACKAGE_IMPORT_ENABLED = False
|
||||
PACKAGE_VERSION = 1
|
||||
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
|
||||
MAX_ENTRY_COUNT = 256
|
||||
@@ -134,6 +134,18 @@ def _cover_asset_id(recipe):
|
||||
return require_filename(asset_id)
|
||||
|
||||
|
||||
def _picture_summary(recipe):
|
||||
"""What a package shows the receiver: (has a cover, how many model thumbnails). The cover
|
||||
is usually the small image inside recipe.json (`thumbnail`), not an asset file."""
|
||||
thumbnail = recipe.get("thumbnail")
|
||||
has_cover = bool(_cover_asset_id(recipe)) or (
|
||||
isinstance(thumbnail, str) and thumbnail.lower().startswith(recipe_schema.SAFE_THUMBNAIL_PREFIXES))
|
||||
references = recipe.get("params", {}).get("model_references", [])
|
||||
models = {_asset_id_from_reference(item) for item in references if isinstance(item, dict)} if isinstance(references, list) else set()
|
||||
models.discard(None)
|
||||
return has_cover, len(models)
|
||||
|
||||
|
||||
def _validate_manifest(manifest, archive, names):
|
||||
if not isinstance(manifest, dict) or manifest.get("package_version") != PACKAGE_VERSION:
|
||||
raise ValueError("Unsupported recipe package version")
|
||||
@@ -212,7 +224,7 @@ def _recipe_history_files(recipes_dir, filename):
|
||||
|
||||
|
||||
def _build_export(raw_recipe, recipes_dir, filename, options):
|
||||
include_snapshots = options.get("include_snapshots") is True
|
||||
include_snapshots = options.get("include_snapshots", True) is True
|
||||
include_history = options.get("include_history") is True
|
||||
include_identity = options.get("include_identity", True) is True
|
||||
include_model_notes = options.get("include_model_notes", True) is True
|
||||
@@ -372,42 +384,26 @@ def _write_staged_json(path, value):
|
||||
|
||||
|
||||
def _commit_import(record, payload):
|
||||
"""Writes the inspected package as a new recipe (never over one): (filename, name)."""
|
||||
recipes_dir = get_recipes_dir()
|
||||
package_recipe = json.loads(json.dumps(record["recipe"], ensure_ascii=False))
|
||||
collision = payload.get("collision", "rename")
|
||||
name_override = payload.get("name")
|
||||
if name_override is not None:
|
||||
if not isinstance(name_override, str) or not name_override.strip():
|
||||
raise ValueError("Invalid imported recipe name")
|
||||
package_recipe["name"] = name_override.strip()
|
||||
|
||||
existing_names = _existing_recipe_names(recipes_dir)
|
||||
target_filename = None
|
||||
if collision == "replace":
|
||||
target_filename = require_filename(payload.get("target_filename", ""))
|
||||
if not target_filename.endswith(".json"):
|
||||
raise ValueError("Invalid replacement target")
|
||||
if not os.path.isfile(resolve_within(recipes_dir, target_filename)):
|
||||
raise FileNotFoundError
|
||||
elif collision != "rename":
|
||||
raise ValueError("Unsupported collision choice")
|
||||
else:
|
||||
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names)
|
||||
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), _existing_recipe_names(recipes_dir))
|
||||
|
||||
normalized = recipe_schema._normalise_recipe(package_recipe)
|
||||
normalized["presentation"]["imported"] = True
|
||||
filename = target_filename or f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
|
||||
filename = f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
|
||||
normalized = recipe_schema._enrich_recipe(normalized)
|
||||
|
||||
staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir)
|
||||
final_path = resolve_within(recipes_dir, filename)
|
||||
final_assets = recipe_images._recipe_assets_dir(recipes_dir, filename)
|
||||
final_history = recipe_store._history_dir(recipes_dir, filename)
|
||||
backup_recipe = None
|
||||
backup_assets = None
|
||||
backup_history = None
|
||||
installed_assets = False
|
||||
installed_history = False
|
||||
installed = []
|
||||
try:
|
||||
archive, _ = _validate_zip(record["raw"])
|
||||
with archive:
|
||||
@@ -419,54 +415,33 @@ def _commit_import(record, payload):
|
||||
for name in record["history_names"]:
|
||||
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
|
||||
_write_staged_json(os.path.join(history_stage, require_filename(name.split("/", 1)[1])), history)
|
||||
|
||||
if collision == "replace":
|
||||
backup_recipe = os.path.join(staging, "old-recipe.json")
|
||||
os.replace(final_path, backup_recipe)
|
||||
if os.path.isdir(final_assets):
|
||||
backup_assets = os.path.join(staging, "old-assets")
|
||||
shutil.move(final_assets, backup_assets)
|
||||
if record["history_names"] and os.path.isdir(final_history):
|
||||
backup_history = os.path.join(staging, "old-history")
|
||||
shutil.move(final_history, backup_history)
|
||||
|
||||
os.replace(os.path.join(staging, "recipe.json"), final_path)
|
||||
# The images first, the recipe last: a recipe card never shows without its pictures.
|
||||
staged_assets = os.path.join(staging, "assets")
|
||||
if os.path.isdir(staged_assets):
|
||||
if os.listdir(staged_assets):
|
||||
os.makedirs(os.path.dirname(final_assets), exist_ok=True)
|
||||
os.replace(staged_assets, final_assets)
|
||||
installed_assets = True
|
||||
installed.append(final_assets)
|
||||
if record["history_names"]:
|
||||
history_stage = os.path.join(staging, "history")
|
||||
if os.path.isdir(final_history):
|
||||
shutil.rmtree(final_history)
|
||||
os.replace(history_stage, final_history)
|
||||
installed_history = True
|
||||
os.makedirs(os.path.dirname(final_history), exist_ok=True)
|
||||
os.replace(os.path.join(staging, "history"), final_history)
|
||||
installed.append(final_history)
|
||||
os.replace(os.path.join(staging, "recipe.json"), final_path)
|
||||
except Exception:
|
||||
if collision == "replace" and backup_recipe and os.path.isfile(backup_recipe):
|
||||
if installed_assets and os.path.isdir(final_assets):
|
||||
shutil.rmtree(final_assets, ignore_errors=True)
|
||||
if installed_history and os.path.isdir(final_history):
|
||||
shutil.rmtree(final_history, ignore_errors=True)
|
||||
if os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
os.replace(backup_recipe, final_path)
|
||||
if backup_assets and os.path.isdir(backup_assets):
|
||||
shutil.move(backup_assets, final_assets)
|
||||
if backup_history and os.path.isdir(backup_history):
|
||||
shutil.move(backup_history, final_history)
|
||||
for path in installed: # this import's own new folders; nothing of the user's
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return filename, normalized.get("name", "")
|
||||
|
||||
|
||||
def _download_name(recipe, filename):
|
||||
"""The .zip's name for the browser: the recipe's name, safe on every system."""
|
||||
name = "".join("_" if char in '<>:"/\\|?*' or ord(char) < 32 else char for char in str(recipe.get("name") or "")).strip(" .")
|
||||
return f"{name or os.path.splitext(filename)[0]}.anomalous-recipe.zip"
|
||||
|
||||
|
||||
async def api_export_recipe_package(request):
|
||||
if not RECIPE_PACKAGE_EXPORT_ENABLED:
|
||||
return web.json_response({
|
||||
"status": "error",
|
||||
"code": "recipe_export_disabled",
|
||||
"message": "Recipe package export is temporarily unavailable",
|
||||
}, status=503)
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
@@ -481,19 +456,13 @@ async def api_export_recipe_package(request):
|
||||
return web.json_response({"status": "error", "message": "Recipe not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not export recipe"}, status=500)
|
||||
return web.Response(
|
||||
body=package,
|
||||
content_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{os.path.splitext(filename)[0]}.anomalous-recipe.zip"'},
|
||||
)
|
||||
name = _download_name(recipe, filename)
|
||||
return web.Response(body=package, content_type="application/zip", headers={
|
||||
"Content-Disposition": f"attachment; filename=\"recipe.anomalous-recipe.zip\"; filename*=UTF-8''{urllib.parse.quote(name)}",
|
||||
})
|
||||
|
||||
|
||||
async def api_import_recipe_package_inspect(request):
|
||||
if not RECIPE_PACKAGE_IMPORT_ENABLED:
|
||||
return web.json_response({
|
||||
"status": "error", "code": "recipe_import_disabled",
|
||||
"message": "Recipe package import is temporarily unavailable",
|
||||
}, status=503)
|
||||
try:
|
||||
raw = await request.content.read(MAX_UPLOAD_BYTES + 1)
|
||||
report = await asyncio.to_thread(_inspect_package, raw)
|
||||
@@ -507,17 +476,15 @@ async def api_import_recipe_package_inspect(request):
|
||||
"token": token,
|
||||
"recipe": {"name": report["recipe"].get("name", ""), "tags": report["recipe"].get("tags", [])},
|
||||
"asset_count": len(report["asset_names"]),
|
||||
"has_cover": _picture_summary(report["recipe"])[0],
|
||||
"model_pictures": _picture_summary(report["recipe"])[1],
|
||||
"history_count": len(report["history_names"]),
|
||||
"existing_names": _existing_recipe_names(get_recipes_dir()),
|
||||
"imported_name": _unique_name(report["recipe"].get("name", ""), _existing_recipe_names(get_recipes_dir())),
|
||||
})
|
||||
|
||||
|
||||
async def api_import_recipe_package_commit(request):
|
||||
if not RECIPE_PACKAGE_IMPORT_ENABLED:
|
||||
return web.json_response({
|
||||
"status": "error", "code": "recipe_import_disabled",
|
||||
"message": "Recipe package import is temporarily unavailable",
|
||||
}, status=503)
|
||||
try:
|
||||
payload = await request.json()
|
||||
token = payload.get("token")
|
||||
@@ -529,8 +496,6 @@ async def api_import_recipe_package_commit(request):
|
||||
filename, name = await asyncio.to_thread(_commit_import, record, payload)
|
||||
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Replacement recipe not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=500)
|
||||
return web.json_response({"status": "success", "filename": filename, "name": name})
|
||||
|
||||
+54
-3
@@ -29,6 +29,9 @@ ALL_IN_ONE_LOADER_SPECS = {
|
||||
"efficient loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
|
||||
}
|
||||
PLACEHOLDER_MODEL_VALUES = {"none", "baked vae"}
|
||||
POWER_LORA_TYPE = "power lora loader (rgthree)"
|
||||
EASY_LORA_STACK_TYPE = "easy lorastack"
|
||||
EASY_STACK_ROWS = 10
|
||||
|
||||
|
||||
def _model_reference_specs(node):
|
||||
@@ -60,6 +63,29 @@ def _model_reference_specs(node):
|
||||
return specs
|
||||
|
||||
|
||||
def _model_reference_values(node):
|
||||
"""(widget_index, category, widget_name, saved_value) for every model a saved node names: known
|
||||
loaders by position, and two stack nodes by their rows: rgthree's Power Lora Loader (a row is a
|
||||
dict {on, lora, strength}; switched-off rows load nothing) and Easy-Use's LoRA stack
|
||||
(lora_N_name at 3 + 4*(N-1), up to num_loras). Must stay in sync with nodeModelValues in
|
||||
web/modules/recipe_identity.js."""
|
||||
values = _widget_values(node)
|
||||
lowered = _node_type(node).lower().strip()
|
||||
if lowered == POWER_LORA_TYPE:
|
||||
rows = [(index, value) for index, value in enumerate(values) if isinstance(value, dict) and "lora" in value]
|
||||
return [(index, "lora", f"lora_{row}", value.get("lora"))
|
||||
for row, (index, value) in enumerate(rows, 1) if value.get("on") is not False]
|
||||
if lowered == EASY_LORA_STACK_TYPE:
|
||||
try:
|
||||
count = max(0, min(int(values[2]), EASY_STACK_ROWS))
|
||||
except (IndexError, TypeError, ValueError):
|
||||
count = 0
|
||||
return [(3 + 4 * row, "lora", f"lora_{row + 1}_name", values[3 + 4 * row] if 3 + 4 * row < len(values) else None)
|
||||
for row in range(count)]
|
||||
return [(index, category, name, values[index] if index < len(values) else None)
|
||||
for index, category, name in _model_reference_specs(node)]
|
||||
|
||||
|
||||
def _identity_for_reference(saved_value):
|
||||
resolved = _resolve_exact_model_reference(saved_value)
|
||||
if not resolved:
|
||||
@@ -153,9 +179,7 @@ def _build_model_references(recipe, verify_identities=False):
|
||||
for node in workflow.get("nodes", []) if isinstance(workflow, dict) else []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
values = _widget_values(node)
|
||||
for widget_index, category, widget_name in _model_reference_specs(node):
|
||||
saved_value = values[widget_index] if widget_index < len(values) else None
|
||||
for widget_index, category, widget_name, saved_value in _model_reference_values(node):
|
||||
if not isinstance(saved_value, str) or not saved_value.strip():
|
||||
continue
|
||||
if saved_value.strip().lower() in PLACEHOLDER_MODEL_VALUES:
|
||||
@@ -277,6 +301,31 @@ def _normalise_source_image(value):
|
||||
}
|
||||
|
||||
|
||||
def _normalise_focus_params(entries):
|
||||
"""A workflow's focus values (重点参数): [{node, type, widget, note}], all short text; anything else is refused."""
|
||||
if not isinstance(entries, list) or len(entries) > MAX_FOCUS_PARAMS:
|
||||
raise ValueError("Invalid recipe focus values")
|
||||
kept = []
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("Invalid recipe focus value")
|
||||
node, node_type, widget, note = (entry.get(key, "") for key in ("node", "type", "widget", "note"))
|
||||
if not all(isinstance(value, str) for value in (node, node_type, widget, note)):
|
||||
raise ValueError("Invalid recipe focus value")
|
||||
if (
|
||||
not node or not widget or len(node) > 32
|
||||
or max(len(node_type), len(widget)) > MAX_FOCUS_NAME_LENGTH
|
||||
or len(note) > MAX_FOCUS_NOTE_LENGTH
|
||||
):
|
||||
raise ValueError("Invalid recipe focus value")
|
||||
if (node, widget) in seen:
|
||||
continue
|
||||
seen.add((node, widget))
|
||||
kept.append({"node": node, "type": node_type, "widget": widget, "note": note.strip()})
|
||||
return kept
|
||||
|
||||
|
||||
def _normalise_recipe(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
@@ -324,6 +373,8 @@ def _normalise_recipe(payload):
|
||||
reference["user_note"] = user_note.strip()
|
||||
else:
|
||||
reference.pop("user_note", None)
|
||||
if "focusParams" in params:
|
||||
params["focusParams"] = _normalise_focus_params(params["focusParams"])
|
||||
_validate_workflow(workflow)
|
||||
workflow_scope = payload.get("workflow_scope", "complete")
|
||||
if workflow_scope not in {"partial", "complete"}:
|
||||
|
||||
+36
-1
@@ -26,13 +26,14 @@ from .recipe_schema import (
|
||||
_enrich_recipe as _schema_enrich_recipe, _normalise_recipe, _normalise_source_image,
|
||||
_identity_for_reference as _schema_identity_for_reference,
|
||||
_model_reference_key, _model_reference_specs,
|
||||
_model_roots, _preserve_model_reference_fields,
|
||||
_model_roots, _normalise_focus_params, _preserve_model_reference_fields,
|
||||
_resolve_exact_model_reference, _updated_recipe, _workflow_identity_for_reference,
|
||||
)
|
||||
from .recipe_store import (
|
||||
_archive_recipe, _delete_recipe_with_history, _history_dir, _list_recipe_history,
|
||||
_list_recipes, _read_recipe, _write_recipe, get_recipes_dir,
|
||||
)
|
||||
from .local_guard import may_change
|
||||
from .utils import require_filename, resolve_within
|
||||
from .workflow_schema import (
|
||||
_clean_volatile_params, _node_title, _node_type, _normalise_workflow_key,
|
||||
@@ -359,6 +360,40 @@ async def api_update_recipe(request):
|
||||
})
|
||||
|
||||
|
||||
def _set_recipe_focus(recipes_dir, filename, entries):
|
||||
"""Writes a recipe's focus values (重点参数) alone: no version kept, nothing else read again."""
|
||||
focus = _normalise_focus_params(entries)
|
||||
path = resolve_within(recipes_dir, filename)
|
||||
if os.path.getsize(path) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Recipe is too large")
|
||||
recipe = _read_recipe(path)
|
||||
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
|
||||
recipe["params"] = {**params, "focusParams": focus}
|
||||
_write_recipe(recipes_dir, filename, recipe)
|
||||
return focus
|
||||
|
||||
|
||||
async def api_set_recipe_focus(request):
|
||||
"""Marks or notes a recipe's focus values (a star in its details). Only from this computer (local_guard.may_change)."""
|
||||
if not may_change(request):
|
||||
return web.json_response({"status": "error", "message": "Only from this computer"}, status=403)
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
focus = await asyncio.to_thread(_set_recipe_focus, get_recipes_dir(), filename, payload.get("focusParams"))
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid focus values"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500)
|
||||
return web.json_response({"status": "success", "focusParams": focus})
|
||||
|
||||
|
||||
async def api_get_recipe_history(request):
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""ComfyUI's refusal of a run, said as what to do next: run_workflow's answer when ComfyUI does not
|
||||
take the prompt. The page passes the refusal on as data (web/modules/mcp_run.js `refusal`: its
|
||||
error and each node's, from ComfyUI's validation); each problem becomes one line naming the node,
|
||||
what is wrong and the tool that fixes it, so the AI does not have to read ComfyUI's raw report and
|
||||
guess. Kinds not listed here keep ComfyUI's own message."""
|
||||
|
||||
MAX_LINES = 8
|
||||
|
||||
NODE_HINTS = {
|
||||
"required_input_missing": "node {node} ({class_type}) has nothing wired into {input}: connect_nodes into it, or add_node with wire_to={node}",
|
||||
"bad_linked_input": "node {node} ({class_type}): {input} is wired from an output of the wrong kind; connect_nodes the right one ({details})",
|
||||
"return_type_mismatch": "node {node} ({class_type}): {input} is wired from an output of the wrong kind; connect_nodes the right one ({details})",
|
||||
"invalid_input_type": "node {node} ({class_type}): {input} has a value of the wrong kind ({details}); set_values with the right one",
|
||||
"value_smaller_than_min": "node {node} ({class_type}): {input} = {value} is below its minimum ({details}); set_values within the range",
|
||||
"value_bigger_than_max": "node {node} ({class_type}): {input} = {value} is above its maximum ({details}); set_values within the range",
|
||||
"value_not_in_list": ("node {node} ({class_type}): {input} = {value} is not one of its choices. A model this computer "
|
||||
"lacks: check_workflow_models / fix_workflow_models, or search_models then set_model; otherwise "
|
||||
"set_values with one of its choices (find_nodes lists them)"),
|
||||
}
|
||||
TOP_HINTS = {
|
||||
"prompt_no_outputs": ("the workflow has no output node: add SaveImage (or PreviewImage) behind the VAE Decode with "
|
||||
"add_node(type='SaveImage', wire_from=<its id>)"),
|
||||
"invalid_prompt": "{message} {details}: a node type this ComfyUI does not have; find_nodes for one it has, or ask the user to install its node pack",
|
||||
}
|
||||
|
||||
|
||||
class _Blank(dict):
|
||||
def __missing__(self, key):
|
||||
return "?"
|
||||
|
||||
|
||||
def _say(template, item):
|
||||
return template.format_map(_Blank({key: str(value) for key, value in item.items() if value not in (None, "")}))
|
||||
|
||||
|
||||
def say(refusal):
|
||||
"""What to do about ComfyUI's refusal (the page's `refusal` data), or "" when it says nothing."""
|
||||
if not isinstance(refusal, dict):
|
||||
return ""
|
||||
lines = []
|
||||
for item in refusal.get("nodes") or []:
|
||||
if isinstance(item, dict):
|
||||
template = NODE_HINTS.get(item.get("type"))
|
||||
lines.append(_say(template, item) if template else
|
||||
_say("node {node} ({class_type}): {message} {details}", item).replace(" ?", ""))
|
||||
if not lines:
|
||||
template = TOP_HINTS.get(refusal.get("type"))
|
||||
if template:
|
||||
lines.append(_say(template, refusal))
|
||||
elif refusal.get("message"):
|
||||
lines.append(_say("{message} {details}", refusal).replace(" ?", ""))
|
||||
if not lines:
|
||||
return ""
|
||||
more = len(lines) - MAX_LINES
|
||||
return ("ComfyUI did not take the run: " + "; ".join(lines[:MAX_LINES]) + (f"; and {more} more" if more > 0 else "")
|
||||
+ ". Fix these, then run_workflow again.")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""The user's choice to leave a model out of scans, or keep it in: POST /anomalous/scan_choice,
|
||||
kept in each model's <model>.anomalous.json ("scan"; model_identity.scan_choice reads it). The
|
||||
scan page asks it about new models that look like no drawing model; a model's details change it."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .local_guard import may_change
|
||||
from .path_utils import atomic_write_json, resolve_folder_subdir
|
||||
from .utils import model_file_within
|
||||
|
||||
try:
|
||||
from ..model_identity import USER_INFO_SUFFIX, read_json
|
||||
from ..model_sidecars import target as sidecar_target
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import USER_INFO_SUFFIX, read_json
|
||||
from model_sidecars import target as sidecar_target
|
||||
|
||||
MAX_CHOICE_FILES = 2000
|
||||
|
||||
|
||||
def _set_scan_choice(targets, choice):
|
||||
"""Writes the user's scan choice into each picked model's <model>.anomalous.json ("scan":
|
||||
"skip" or "scan"; "" removes it). `targets`: [{type, path_idx, subfolder, files}] as a scan
|
||||
takes them. Returns (models changed, file names left as they were: not found, not a
|
||||
.safetensors, or an unreadable .anomalous.json)."""
|
||||
if choice not in ("skip", "scan", "") or not isinstance(targets, list):
|
||||
raise ValueError("Invalid scan choice")
|
||||
picked = []
|
||||
for target in targets:
|
||||
names = target.get("files") if isinstance(target, dict) else None
|
||||
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
|
||||
raise ValueError("each target needs a list of file names")
|
||||
_, target_dir = resolve_folder_subdir(target.get("type"), int(target.get("path_idx", 0)), target.get("subfolder") or "/")
|
||||
picked += [(target_dir, name) for name in names]
|
||||
if len(picked) > MAX_CHOICE_FILES:
|
||||
raise ValueError("Too many models")
|
||||
changed, failed = 0, []
|
||||
for target_dir, name in picked:
|
||||
try:
|
||||
path = model_file_within(target_dir, name)
|
||||
except ValueError:
|
||||
failed.append(name)
|
||||
continue
|
||||
user_file = sidecar_target(os.path.splitext(path)[0], USER_INFO_SUFFIX)
|
||||
user = read_json(user_file)
|
||||
if not name.endswith(".safetensors") or not os.path.isfile(path) or (user is None and os.path.exists(user_file)):
|
||||
failed.append(name)
|
||||
continue
|
||||
user = user or {"format": 1}
|
||||
if choice:
|
||||
user["scan"] = choice
|
||||
else:
|
||||
user.pop("scan", None)
|
||||
atomic_write_json(user_file, user)
|
||||
changed += 1
|
||||
return changed, failed
|
||||
|
||||
|
||||
async def api_set_scan_choice(request):
|
||||
"""POST /anomalous/scan_choice {targets, choice} - leave models out of scans ("skip"), keep
|
||||
them in ("scan"), or forget the choice (""). Only from this computer (local_guard.may_change)."""
|
||||
if not may_change(request):
|
||||
return web.json_response({"status": "error", "message": "Only from this computer"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
changed, failed = await asyncio.to_thread(_set_scan_choice, data.get("targets"), data.get("choice", ""))
|
||||
except (AttributeError, KeyError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid scan choice"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save the choice"}, status=500)
|
||||
return web.json_response({"status": "success", "changed": changed, "failed": failed})
|
||||
+4
-1
@@ -72,6 +72,7 @@ class ScanJob:
|
||||
self.files = []
|
||||
self.unchanged = 0
|
||||
self.civitai_down = False
|
||||
self.civitai_error = "" # why Civitai could not be reached (net_proxy.failure), the first one
|
||||
self.errors = []
|
||||
|
||||
def add_folder(self, report_file, base_dir, located=None):
|
||||
@@ -87,6 +88,7 @@ class ScanJob:
|
||||
self.unchanged += int(event.get("unchanged") or 0)
|
||||
elif event.get("event") == "civitai_down":
|
||||
self.civitai_down = True
|
||||
self.civitai_error = self.civitai_error or str(event.get("reason") or "")[:40]
|
||||
for entry in entries:
|
||||
path = entry.get("renamed_to") if entry.get("renamed_to") and entry.get("status") != "failed" else entry.get("file")
|
||||
if not isinstance(path, str):
|
||||
@@ -116,6 +118,7 @@ class ScanJob:
|
||||
return {
|
||||
"kind": self.kind, "options": self.options, "started": self.started,
|
||||
"finished": time.time(), "counts": counts, "civitai_down": self.civitai_down,
|
||||
"civitai_error": self.civitai_error,
|
||||
"errors": self.errors[:10], "files": files[:MAX_FILES],
|
||||
}
|
||||
|
||||
@@ -129,7 +132,7 @@ class ScanJob:
|
||||
print(f"[Anomalous Browser] Could not keep the scan result: {error}")
|
||||
try:
|
||||
target = result["files"][0]["filename"] if self.kind == "one" and result["files"] else ""
|
||||
detail = {"scan": {key: result[key] for key in ("kind", "options", "counts", "civitai_down", "errors")},
|
||||
detail = {"scan": {key: result[key] for key in ("kind", "options", "counts", "civitai_down", "civitai_error", "errors")},
|
||||
"files": result["files"][:MAX_CHANGES], "total": len(self.files),
|
||||
"seconds": round(result["finished"] - result["started"])}
|
||||
add_entry("file", "scan_done", target, detail)
|
||||
|
||||
+219
-28
@@ -1,34 +1,63 @@
|
||||
"""What the scan page shows before a scan: how many models are matched on Civitai, unmatched or
|
||||
new, and which ones the last two are (located like the scan report's models, so the page can
|
||||
open them); also the model files a scan never reads (other formats than .safetensors), so the
|
||||
page can say why they are not there."""
|
||||
page can say why they are not there, the models the user set to "don't scan", and the new ones
|
||||
whose header looks like no drawing model (a voice model, say), for the page to ask about. The
|
||||
components a drawing model loads (VAE, text encoders…) are counted and listed on their own too,
|
||||
since many are not on Civitai and that is no fault; and some covers of the scanned models, for
|
||||
the scan card's band of pictures. And for the scan page's picker, every model
|
||||
a scan reads with its name, size, cover and state.
|
||||
|
||||
Working the summary out reads every model folder, which takes a while with many models or on
|
||||
a network drive. So the last one is kept (in memory, and in user/anomalous/scan_summary.json
|
||||
for after a restart) and handed out at once (`?quick=1`) while a fresh one is worked out;
|
||||
requests that come meanwhile wait for that same one instead of reading the folders again. The
|
||||
models found on Civitai are kept with it and listed on request (GET /anomalous/scan_matched),
|
||||
so the page's summary stays small."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .activity_log import log_path
|
||||
from .folder_types import get_active_scan_paths
|
||||
from .metadata import get_metadata
|
||||
from .path_utils import atomic_write_json
|
||||
from .model_media import _preview_url_for_model
|
||||
from .scan_report import locate_root
|
||||
|
||||
try:
|
||||
from ..model_identity import is_unmatched, sidecar_info, unmatched_reason
|
||||
from ..model_identity import is_unmatched, read_json, scan_choice, sidecar_info, unmatched_reason
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import is_unmatched, sidecar_info, unmatched_reason
|
||||
from model_identity import is_unmatched, read_json, scan_choice, sidecar_info, unmatched_reason
|
||||
|
||||
MAX_LISTED = 2000 # models listed per kind; the counts stay exact
|
||||
MAX_PICKABLE = 5000 # models the picker lists; its total stays exact
|
||||
COVER_SAMPLE = 48 # covers the scan card's band gets (it shows as many as fit)
|
||||
# Model files in other formats: listed as "not scanned" (scans read only .safetensors).
|
||||
OTHER_MODEL_EXTENSIONS = (".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx", ".sft", ".engine")
|
||||
# Folders of the parts a drawing model loads: many are not on Civitai, and that is no fault.
|
||||
COMPONENT_TYPES = ("vae", "clip", "text_encoders", "clip_vision", "upscale_models")
|
||||
# What the picker needs of a model's metadata: its card badge (model_source.js) and its name.
|
||||
PICKER_FIELDS = ("info_source", "unmatched_reason", "scan_choice", "not_drawing", "baseModel", "base_guess", "custom_name")
|
||||
# New models' headers are read this many at a time: on a network drive (a NAS, a cloud server's
|
||||
# model disk) each read waits on the network, and a thousand one after another take minutes.
|
||||
# They are read through get_metadata, whose cache keeps them until the file changes (the page
|
||||
# opened again reads none, and the picker after it finds them there).
|
||||
HEAD_READERS = 16
|
||||
MAX_SAVED_BYTES = 16 * 1024 * 1024 # the kept summary; MAX_LISTED keeps it far smaller
|
||||
_last = {} # the last summary worked out ("summary", "at") and its models found on Civitai ("matched")
|
||||
_working = None # the summary being worked out (an asyncio future), shared by the requests meanwhile
|
||||
|
||||
|
||||
def _summarize():
|
||||
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
|
||||
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
|
||||
which the next online scan looks up. `skipped`: model files in other formats, outside hidden
|
||||
folders (tools keep their working files there)."""
|
||||
def _walk():
|
||||
"""Each model file in the active scan folders once, as ((type, path_idx), base_dir, path,
|
||||
other): the .safetensors files scans read, and (`other`) model files in other formats outside
|
||||
hidden folders (tools keep their working files there)."""
|
||||
seen = set()
|
||||
total = matched = 0
|
||||
unmatched, new, skipped = [], [], []
|
||||
for base_dir in get_active_scan_paths():
|
||||
located = locate_root(base_dir) or ("", 0)
|
||||
for root, _dirs, files in os.walk(base_dir):
|
||||
@@ -42,30 +71,192 @@ def _summarize():
|
||||
if real in seen:
|
||||
continue
|
||||
seen.add(real)
|
||||
if other:
|
||||
skipped.append({"type": located[0], "path_idx": located[1], "filename": filename,
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")})
|
||||
continue
|
||||
total += 1
|
||||
info = sidecar_info(os.path.splitext(path)[0])
|
||||
if info is not None and not is_unmatched(info):
|
||||
matched += 1
|
||||
continue
|
||||
item = {"type": located[0], "path_idx": located[1], "filename": filename,
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
|
||||
if info is None:
|
||||
new.append(item)
|
||||
else:
|
||||
item.update(reason=unmatched_reason(info), base=info.get("baseModel") or "")
|
||||
unmatched.append(item)
|
||||
yield located, base_dir, path, other
|
||||
|
||||
|
||||
def _looks_not_drawing(path):
|
||||
"""Whether the header of a new model reads but names no drawing model kind."""
|
||||
return bool(get_metadata(path).get("not_drawing"))
|
||||
|
||||
|
||||
def _found_item(item, info):
|
||||
"""A model found on Civitai, for the list of them: where it is, Civitai's name, its base model."""
|
||||
model = info.get("model") if isinstance(info.get("model"), dict) else {}
|
||||
names = [str(model.get("name") or ""), str(info.get("name") or "")]
|
||||
return {**item, "name": " · ".join(name for name in names if name), "base": str(info.get("baseModel") or "")}
|
||||
|
||||
|
||||
def _summarize(with_matched=False):
|
||||
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
|
||||
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
|
||||
which the next online scan looks up. `skipped`: model files in other formats. `excluded`:
|
||||
models set to "don't scan", not in the other counts. A new model not chosen about whose
|
||||
header reads but names no drawing model kind carries `not_drawing` (counted too); only new
|
||||
models' headers are read. Models in a component folder carry `component` (counted too) and
|
||||
are listed again, matched ones as well, in `component_models` with their `state`
|
||||
(matched | unmatched | new); `components` counts them. `covers`: see `_covers`."""
|
||||
total = matched = 0
|
||||
unmatched, new, skipped, excluded, components, scanned, to_read, found = [], [], [], [], [], [], [], []
|
||||
for located, base_dir, path, other in _walk():
|
||||
item = {"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
|
||||
if other:
|
||||
skipped.append(item)
|
||||
continue
|
||||
base = os.path.splitext(path)[0]
|
||||
choice = scan_choice(base)
|
||||
if choice == "skip":
|
||||
excluded.append(item)
|
||||
continue
|
||||
total += 1
|
||||
info = sidecar_info(base)
|
||||
if located[0] in COMPONENT_TYPES:
|
||||
item["component"] = True
|
||||
components.append(item)
|
||||
elif info is not None:
|
||||
scanned.append((located, base_dir, path))
|
||||
if info is not None and not is_unmatched(info):
|
||||
matched += 1
|
||||
item["state"] = "matched"
|
||||
if with_matched and not item.get("component"):
|
||||
found.append(_found_item(item, info))
|
||||
continue
|
||||
if info is None:
|
||||
if not choice:
|
||||
to_read.append((item, path))
|
||||
item["state"] = "new"
|
||||
new.append(item)
|
||||
else:
|
||||
item.update(state="unmatched", reason=unmatched_reason(info), base=info.get("baseModel") or "")
|
||||
unmatched.append(item)
|
||||
if to_read:
|
||||
with ThreadPoolExecutor(max_workers=HEAD_READERS) as pool:
|
||||
for (item, _path), not_drawing in zip(to_read, pool.map(_looks_not_drawing, [path for _item, path in to_read])):
|
||||
if not_drawing:
|
||||
item["not_drawing"] = True
|
||||
return {
|
||||
**({"matched_models": found} if with_matched else {}),
|
||||
"total": total, "matched": matched, "unmatched": len(unmatched), "new": len(new),
|
||||
"pending": sum(item["reason"] in ("offline", "network") for item in unmatched),
|
||||
"damaged": sum(item["reason"] == "damaged" for item in unmatched),
|
||||
"not_drawing": sum(bool(item.get("not_drawing")) for item in new),
|
||||
"excluded": len(excluded), "excluded_models": excluded[:MAX_LISTED],
|
||||
"unmatched_models": unmatched[:MAX_LISTED], "new_models": new[:MAX_LISTED],
|
||||
"skipped": len(skipped), "skipped_models": skipped[:MAX_LISTED],
|
||||
"components": {
|
||||
"total": len(components),
|
||||
**{state: sum(item["state"] == state for item in components) for state in ("matched", "unmatched", "new")},
|
||||
"pending": sum(item.get("reason") in ("offline", "network") for item in components),
|
||||
},
|
||||
"component_models": components[:MAX_LISTED],
|
||||
"covers": _covers(scanned),
|
||||
}
|
||||
|
||||
|
||||
def _modified(entry):
|
||||
try:
|
||||
return os.path.getmtime(entry[2])
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def _covers(scanned):
|
||||
"""Up to COVER_SAMPLE covers of the scanned models (components left out, they seldom have
|
||||
one), the newest model files first, as {name, preview_url}: the scan card's band of pictures."""
|
||||
covers = []
|
||||
for located, base_dir, path in sorted(scanned, key=_modified, reverse=True):
|
||||
url = _preview_url_for_model(located[0], located[1], base_dir, path)
|
||||
if url:
|
||||
covers.append({"name": os.path.splitext(os.path.basename(path))[0], "preview_url": url})
|
||||
if len(covers) >= COVER_SAMPLE:
|
||||
break
|
||||
return covers
|
||||
|
||||
|
||||
def _pickable():
|
||||
"""Every model a scan reads (the models set to "don't scan" too), located like the summary's,
|
||||
with its size, cover and what its card badge needs; `roots`: the name of each models folder
|
||||
they are in and of the folder holding it, to tell two folders of one type apart."""
|
||||
models, roots, total = [], {}, 0
|
||||
for located, base_dir, path, other in _walk():
|
||||
if other:
|
||||
continue
|
||||
total += 1
|
||||
if len(models) >= MAX_PICKABLE:
|
||||
continue
|
||||
key = f"{located[0]}|{located[1]}"
|
||||
if key not in roots:
|
||||
roots[key] = {"type": located[0], "path_idx": located[1], "name": os.path.basename(os.path.normpath(base_dir)),
|
||||
"parent": os.path.basename(os.path.dirname(os.path.normpath(base_dir)))}
|
||||
meta = get_metadata(path)
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
size = 0
|
||||
models.append({
|
||||
"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/"), "size": size,
|
||||
"preview_url": _preview_url_for_model(located[0], located[1], base_dir, path),
|
||||
"metadata": {field: meta.get(field) for field in PICKER_FIELDS},
|
||||
})
|
||||
return {"models": models, "total": total, "roots": list(roots.values())}
|
||||
|
||||
|
||||
def _summary_path():
|
||||
return os.path.join(os.path.dirname(log_path()), "scan_summary.json")
|
||||
|
||||
|
||||
def _work_out():
|
||||
"""A fresh summary, kept as the last one (and on disk when it changed)."""
|
||||
summary = _summarize(with_matched=True)
|
||||
matched = summary.pop("matched_models")
|
||||
changed = summary != _last.get("summary")
|
||||
_last.update(summary=summary, matched=matched[:MAX_PICKABLE], matched_total=len(matched), at=time.time())
|
||||
if changed:
|
||||
try:
|
||||
atomic_write_json(_summary_path(), {"at": _last["at"], "summary": summary}, max_bytes=MAX_SAVED_BYTES)
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"[Anomalous Browser] Scan summary not kept: {error}")
|
||||
return summary
|
||||
|
||||
|
||||
async def _fresh():
|
||||
global _working
|
||||
if _working is None or _working.done():
|
||||
_working = asyncio.ensure_future(asyncio.to_thread(_work_out))
|
||||
return await asyncio.shield(_working)
|
||||
|
||||
|
||||
def _kept():
|
||||
"""The last summary ({"at", "summary"}), from memory or from disk; None before the first."""
|
||||
if _last.get("summary"):
|
||||
return {"at": _last["at"], "summary": _last["summary"]}
|
||||
try:
|
||||
if os.path.getsize(_summary_path()) > MAX_SAVED_BYTES:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
data = read_json(_summary_path())
|
||||
return data if data and isinstance(data.get("summary"), dict) else None
|
||||
|
||||
|
||||
async def api_scan_summary(request):
|
||||
"""GET /anomalous/scan_summary - counts, and the unmatched and new models."""
|
||||
return web.json_response(await asyncio.to_thread(_summarize))
|
||||
"""GET /anomalous/scan_summary - counts, and the unmatched and new models. `?quick=1`: the
|
||||
last one at once ({"at", "summary"}, or {} before the first), for the page to show while it
|
||||
asks for a fresh one."""
|
||||
if request.query.get("quick"):
|
||||
return web.json_response(await asyncio.to_thread(_kept) or {})
|
||||
return web.json_response(await _fresh())
|
||||
|
||||
|
||||
async def api_scan_matched(request):
|
||||
"""GET /anomalous/scan_matched - the models found on Civitai (components apart), as the last
|
||||
summary saw them: {"models": [{type, path_idx, filename, rel, name, base}], "total"}."""
|
||||
if "matched" not in _last:
|
||||
await _fresh()
|
||||
return web.json_response({"models": _last.get("matched") or [], "total": _last.get("matched_total", 0)})
|
||||
|
||||
|
||||
async def api_scan_pickable(request):
|
||||
"""GET /anomalous/scan_pickable - the models the scan settings' picker lists."""
|
||||
return web.json_response(await asyncio.to_thread(_pickable))
|
||||
|
||||
+4
-2
@@ -17,8 +17,10 @@ from .path_utils import resolve_folder_subdir
|
||||
from .scan_report import ScanJob, locate_root
|
||||
try:
|
||||
from ..model_policies import is_physical_rename_protected
|
||||
from ..net_proxy import child_env
|
||||
except ImportError:
|
||||
from model_policies import is_physical_rename_protected
|
||||
from net_proxy import child_env
|
||||
|
||||
|
||||
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -334,7 +336,7 @@ async def api_scan_folder(request):
|
||||
try:
|
||||
cmd = [sys.executable, SCRAPER_PATH, target_dir, "--folder-type", folder_type, *flags]
|
||||
cmd.extend(["--progress-file", progress_file, "--report-file", report_file])
|
||||
process = subprocess.Popen(cmd, cwd=PLUGIN_DIR)
|
||||
process = subprocess.Popen(cmd, cwd=PLUGIN_DIR, env=child_env())
|
||||
_update_scan_marker(marker_file, worker_pid=process.pid)
|
||||
return_code = process.wait()
|
||||
job.add_folder(report_file, base_dir, (folder_type, path_idx))
|
||||
@@ -442,7 +444,7 @@ async def api_scan_all(request):
|
||||
if job.civitai_down: # an earlier folder found Civitai unreachable
|
||||
cmd.append("--civitai-down")
|
||||
cmd.extend(["--progress-file", progress_file, "--report-file", report_file])
|
||||
process = subprocess.Popen(cmd, cwd=PLUGIN_DIR)
|
||||
process = subprocess.Popen(cmd, cwd=PLUGIN_DIR, env=child_env())
|
||||
_update_scan_marker(marker_file, worker_pid=process.pid)
|
||||
return_code = process.wait()
|
||||
_update_scan_marker(marker_file, worker_pid=0)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Settings → Models → "Side files": where the files only this plugin uses sit beside each model
|
||||
(model_sidecars.py), and putting the ones already there in place.
|
||||
|
||||
GET /anomalous/sidecars/plan?layout=beside|hidden -> {layout, moves, duplicates, models}
|
||||
What putting them in `layout` would do over every model folder: side files to move, and
|
||||
Civitai pictures kept twice (a copy kept aside that is the cover as well) that can go.
|
||||
POST /anomalous/sidecars/apply {layout} -> {layout, moved, deduped, failed: [names], more}
|
||||
Saves the setting first (new files go there at once), drops the extra Civitai copies to the
|
||||
Recycle Bin once the cover is noted as Civitai's, then moves each file, never over another:
|
||||
one that cannot move stays where it is and is still read there. Empty .amb folders go when
|
||||
the files move back beside the models. This computer only (a POST, local_guard.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .backup import _models
|
||||
from .path_utils import atomic_write_json
|
||||
from .trash import move_to_trash, trash_failure
|
||||
|
||||
try:
|
||||
from .. import model_sidecars as sidecars
|
||||
except ImportError:
|
||||
import model_sidecars as sidecars
|
||||
|
||||
FAILED_SHOWN = 20
|
||||
|
||||
|
||||
def _work(layout):
|
||||
"""(moves {from: to}, duplicates [(base, cover, copies kept aside)], models looked at)."""
|
||||
moves, duplicates, models = {}, {}, 0
|
||||
for _type, _root, path, _rel in _models():
|
||||
models += 1
|
||||
base = os.path.splitext(path)[0]
|
||||
for found, suffix in sidecars.existing(base, sidecars.OWN_SUFFIXES):
|
||||
wanted = sidecars.hidden_path(base, suffix) if layout == 'hidden' else base + suffix
|
||||
if found != wanted:
|
||||
moves[found] = wanted
|
||||
kept = sidecars.backups(base)
|
||||
if kept and base not in duplicates and sidecars.find(base, '.info'):
|
||||
covers = [base + suffix for suffix in sidecars.ACTIVE_COVER_SUFFIXES if os.path.isfile(base + suffix)]
|
||||
cover = next((c for c in covers if any(sidecars.same_file(c, copy) for copy in kept)), None)
|
||||
if cover:
|
||||
duplicates[base] = (cover, kept)
|
||||
return moves, duplicates, models
|
||||
|
||||
|
||||
def plan(layout):
|
||||
moves, duplicates, models = _work(layout)
|
||||
dropped = {copy for _cover, kept in duplicates.values() for copy in kept}
|
||||
return {"layout": layout, "moves": len([path for path in moves if path not in dropped]),
|
||||
"duplicates": len(dropped), "models": models}
|
||||
|
||||
|
||||
def _save_layout(layout):
|
||||
try:
|
||||
with open(sidecars.CONFIG_PATH, encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
config = {}
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
config['SIDECAR_LAYOUT'] = layout
|
||||
atomic_write_json(sidecars.CONFIG_PATH, config)
|
||||
|
||||
|
||||
def apply(layout):
|
||||
_save_layout(layout)
|
||||
moves, duplicates, _models_seen = _work(layout)
|
||||
failed = []
|
||||
dropped = []
|
||||
for base, (cover, kept) in duplicates.items():
|
||||
try:
|
||||
if sidecars.note_cover_hash(base, cover):
|
||||
dropped += kept
|
||||
except OSError as error:
|
||||
failed.append(f"{os.path.basename(base)}: {error}")
|
||||
deduped = 0
|
||||
if dropped:
|
||||
try:
|
||||
move_to_trash(*dropped)
|
||||
deduped = len(dropped)
|
||||
except Exception as error: # noqa: BLE001 - kept, and said why
|
||||
failed.append(trash_failure(error))
|
||||
folders = set()
|
||||
moved = 0
|
||||
for source, destination in moves.items():
|
||||
if source in dropped or not os.path.isfile(source):
|
||||
continue
|
||||
try:
|
||||
if os.path.exists(destination):
|
||||
raise FileExistsError(os.path.basename(destination))
|
||||
if sidecars.is_hidden(destination):
|
||||
sidecars.make_hidden_dir(os.path.dirname(os.path.dirname(destination)))
|
||||
os.replace(source, destination)
|
||||
moved += 1
|
||||
folders.add(os.path.dirname(source))
|
||||
except OSError as error:
|
||||
failed.append(f"{os.path.basename(source)}: {error}")
|
||||
for folder in folders:
|
||||
if os.path.basename(folder) == sidecars.HIDDEN_DIR:
|
||||
sidecars.drop_empty_hidden_dir(os.path.dirname(folder))
|
||||
return {"layout": layout, "moved": moved, "deduped": deduped,
|
||||
"failed": failed[:FAILED_SHOWN], "more": max(0, len(failed) - FAILED_SHOWN)}
|
||||
|
||||
|
||||
def _layout_of(value):
|
||||
return value if value in sidecars.LAYOUTS else None
|
||||
|
||||
|
||||
async def api_plan(request):
|
||||
layout = _layout_of(request.query.get('layout'))
|
||||
if not layout:
|
||||
return web.json_response({"error": "bad_layout"}, status=400)
|
||||
return web.json_response(await asyncio.to_thread(plan, layout))
|
||||
|
||||
|
||||
async def api_apply(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
layout = _layout_of(data.get('layout')) if isinstance(data, dict) else None
|
||||
if not layout:
|
||||
return web.json_response({"error": "bad_layout"}, status=400)
|
||||
return web.json_response(await asyncio.to_thread(apply, layout))
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get('/anomalous/sidecars/plan', api_plan)
|
||||
app.router.add_post('/anomalous/sidecars/apply', api_apply)
|
||||
+53
-27
@@ -1,16 +1,24 @@
|
||||
"""Bounded translation-provider HTTP route."""
|
||||
"""Prompt translation (POST /anomalous/translate): DeepL when the user saved a key in Settings
|
||||
-> Translation, else Google's free endpoint, else MyMemory. When DeepL was asked and failed, the
|
||||
answer says why (`deepl_error`) while another provider still translates."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ..net_proxy import urlopen
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from net_proxy import urlopen
|
||||
|
||||
|
||||
_DEEPL_TARGETS = {
|
||||
"zh": "ZH", "zh-CN": "ZH", "zh-TW": "ZH-HANT", "en": "EN", "ja": "JA", "ko": "KO",
|
||||
"zh": "ZH-HANS", "zh-CN": "ZH-HANS", "zh-TW": "ZH-HANT", "en": "EN-US", "ja": "JA", "ko": "KO",
|
||||
"fr": "FR", "de": "DE", "es": "ES", "ru": "RU", "pt": "PT-BR",
|
||||
}
|
||||
|
||||
@@ -20,18 +28,43 @@ def _translate_with_deepl(text, tl, deepl_key):
|
||||
d_tl = _DEEPL_TARGETS.get(tl)
|
||||
if not d_tl:
|
||||
return None
|
||||
url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate"
|
||||
payload = urllib.parse.urlencode({
|
||||
"auth_key": deepl_key,
|
||||
"text": text,
|
||||
"target_lang": d_tl
|
||||
}).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=payload)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
# A Free account's key ends in ":fx". The key goes in the Authorization header: DeepL no
|
||||
# longer takes it as an auth_key parameter (2025).
|
||||
url = "https://api-free.deepl.com/v2/translate" if deepl_key.endswith(":fx") else "https://api.deepl.com/v2/translate"
|
||||
payload = json.dumps({"text": [text], "target_lang": d_tl}).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=payload, method="POST", headers={
|
||||
"Authorization": f"DeepL-Auth-Key {deepl_key}",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
with urlopen(req, timeout=8) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
return result["translations"][0]["text"]
|
||||
|
||||
|
||||
def _deepl_error(error):
|
||||
"""Why DeepL did not translate, for Settings: bad_key, quota, busy, or the error's own words."""
|
||||
if isinstance(error, urllib.error.HTTPError):
|
||||
if error.code in (401, 403):
|
||||
return "bad_key"
|
||||
if error.code == 456:
|
||||
return "quota"
|
||||
if error.code == 429:
|
||||
return "busy"
|
||||
return f"HTTP {error.code}"
|
||||
return str(error) or type(error).__name__
|
||||
|
||||
|
||||
def _deepl_key():
|
||||
"""The DeepL key saved in Settings -> Translation (config.json), or ""."""
|
||||
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
key = json.load(f).get("DEEPL_API_KEY", "")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return ""
|
||||
return key.strip() if isinstance(key, str) else ""
|
||||
|
||||
|
||||
def _translate_with_google(text, tl):
|
||||
clients = ["dict-chrome-ex", "gtx"]
|
||||
last_err = None
|
||||
@@ -42,7 +75,7 @@ def _translate_with_google(text, tl):
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
if result and isinstance(result, list) and len(result) > 0 and isinstance(result[0], list):
|
||||
translated_text = "".join([part[0] for part in result[0] if part and len(part) > 0 and part[0]])
|
||||
@@ -65,7 +98,7 @@ def _translate_with_mymemory(text, tl):
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
res = data.get("responseData", {}).get("translatedText")
|
||||
if res and res != text:
|
||||
@@ -92,31 +125,24 @@ async def api_translate(request):
|
||||
if not text:
|
||||
return web.json_response({"translated": "", "status": "success"})
|
||||
|
||||
# 1. Try DeepL if configured
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
deepl_key = ""
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
deepl_key = cfg.get("DEEPL_API_KEY", "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 1. DeepL, when a key is saved
|
||||
deepl_key = _deepl_key()
|
||||
deepl_error = None
|
||||
if deepl_key:
|
||||
try:
|
||||
translated = await asyncio.to_thread(_translate_with_deepl, text, tl, deepl_key)
|
||||
if translated:
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "deepl"})
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] DeepL translate failed: {e}")
|
||||
deepl_error = _deepl_error(e)
|
||||
print(f"[Anomalous] DeepL translate failed: {deepl_error}")
|
||||
why = {"deepl_error": deepl_error} if deepl_error else {}
|
||||
|
||||
# 2. Try Google Translate (client=dict-chrome-ex)
|
||||
try:
|
||||
translated = await asyncio.to_thread(_translate_with_google, text, tl)
|
||||
if translated:
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "google"})
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "google", **why})
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] Google translate failed: {e}")
|
||||
|
||||
@@ -124,7 +150,7 @@ async def api_translate(request):
|
||||
try:
|
||||
translated = await asyncio.to_thread(_translate_with_mymemory, text, tl)
|
||||
if translated:
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "mymemory"})
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "mymemory", **why})
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] MyMemory translate failed: {e}")
|
||||
raise e
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Version and updates of the companion node pack Anomalous_TTS (GPT-SoVITS Settings).
|
||||
|
||||
Only a git checkout of the official repository is touched, found among the
|
||||
custom_nodes folders by its node class and checked by its `origin` remote. The
|
||||
rules are those of the plugin's own version panel (api/version_manager.py):
|
||||
published releases are fetched only when asked, a switch checks out a
|
||||
published release tag, refuses over modified tracked files and can be undone.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import version_manager as vm
|
||||
|
||||
OFFICIAL = ("demongatanjieu", "anomalous_tts")
|
||||
NODE_CLASS = "AnomalousTTS_CharacterSpeech"
|
||||
STATE_FILE = os.path.join(vm.PLUGIN_DIR, ".anomalous_tts_version.local.json")
|
||||
READ_LIMIT = 200_000
|
||||
|
||||
|
||||
def _node_folders():
|
||||
"""custom_nodes folders whose nodes.py defines the Anomalous_TTS node (disabled ones left out)."""
|
||||
for base in folder_paths.get_folder_paths("custom_nodes"):
|
||||
try:
|
||||
names = sorted(os.listdir(base))
|
||||
except OSError:
|
||||
continue
|
||||
for name in names:
|
||||
folder = os.path.join(base, name)
|
||||
if name.endswith(".disabled") or not os.path.exists(os.path.join(folder, ".git")):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(folder, "nodes.py"), encoding="utf-8", errors="replace") as handle:
|
||||
if NODE_CLASS in handle.read(READ_LIMIT):
|
||||
yield folder
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def find_checkout():
|
||||
"""The installed Anomalous_TTS as a version_manager.Checkout, or None (not installed, not a git
|
||||
clone, or cloned from somewhere else)."""
|
||||
for folder in _node_folders():
|
||||
repo = vm.Checkout(folder, STATE_FILE)
|
||||
owner_repo = vm._github_repo(vm._git_out("remote", "get-url", "origin", repo=repo))
|
||||
if owner_repo and tuple(part.lower() for part in owner_repo) == OFFICIAL:
|
||||
return repo
|
||||
return None
|
||||
|
||||
|
||||
def _checkout():
|
||||
repo = find_checkout()
|
||||
if repo is None:
|
||||
raise vm.VersionError("tts_missing", "Anomalous_TTS is not installed as a git clone of its GitHub repository")
|
||||
return repo
|
||||
|
||||
|
||||
def state():
|
||||
repo = find_checkout()
|
||||
return {"found": False} if repo is None else {"found": True, "state": vm.current_state(repo)}
|
||||
|
||||
|
||||
async def api_get_state(request):
|
||||
"""GET /anomalous/tts_version - Installed Anomalous_TTS version (local, no network)."""
|
||||
try:
|
||||
return web.json_response({"success": True, **(await asyncio.to_thread(state))})
|
||||
except vm.VersionError as error:
|
||||
return vm.error_response(error)
|
||||
|
||||
|
||||
async def api_get_releases(request):
|
||||
"""GET /anomalous/tts_version/releases?refresh=1 - Its published releases (network, only when asked)."""
|
||||
refresh = request.query.get("refresh") == "1"
|
||||
return await vm.run(lambda: vm.list_releases(refresh, _checkout()))
|
||||
|
||||
|
||||
async def api_update(request):
|
||||
"""POST /anomalous/tts_version/update {tag} - Check out a published release."""
|
||||
tag = str((await vm.json_body(request)).get("tag", ""))
|
||||
return await vm.run(lambda: vm.switch_to(tag, _checkout()))
|
||||
|
||||
|
||||
async def api_undo(request):
|
||||
"""POST /anomalous/tts_version/undo - Back to where the last update came from."""
|
||||
return await vm.run(lambda: vm.undo_last_switch(_checkout()))
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get('/anomalous/tts_version', api_get_state)
|
||||
app.router.add_get('/anomalous/tts_version/releases', api_get_releases)
|
||||
app.router.add_post('/anomalous/tts_version/update', api_update)
|
||||
app.router.add_post('/anomalous/tts_version/undo', api_undo)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""What each run used and each opened workflow held, kept from the day this started (the Tidy
|
||||
tab's "Not used lately").
|
||||
|
||||
ComfyUI keeps no lasting note of which nodes and models a run used: its history is gone after a
|
||||
restart and its log names neither. So every prompt queued here passes `note_prompt` (ComfyUI's
|
||||
on_prompt hook) on its way in, whether it makes an image, a sound, a video or nothing kept, and
|
||||
the page posts what a workflow holds when it is opened (POST /anomalous/usage/opened {types,
|
||||
models}, usage_notes.js; this computer only). Node types and model files go into
|
||||
user/anomalous/usage_ledger.json with when each was last used and how often:
|
||||
{started, types: {type: [last, runs]}, models: {ref: [last, runs]},
|
||||
opened: {types: {type: [last, times]}, models: {ref: [last, times]}}}.
|
||||
It stays on this computer, is saved a few seconds after a note, and noting never stops a run.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from .image_search import MODEL_FILE
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
SAVE_DELAY = 5.0 # seconds after a note: several in a row are saved once
|
||||
MAX_NAMES = 20_000 # per kind; the longest unused go first
|
||||
MAX_POSTED = 5_000 # names one opened workflow may post, per kind
|
||||
MAX_NAME_CHARS = 512
|
||||
|
||||
_lock = threading.Lock()
|
||||
_ledger = None
|
||||
_timer = None
|
||||
|
||||
|
||||
def ledger_path():
|
||||
user_dir = folder_paths.get_user_directory() if hasattr(folder_paths, "get_user_directory") else None
|
||||
if not user_dir:
|
||||
user_dir = os.path.join(folder_paths.base_path, "user")
|
||||
directory = os.path.join(user_dir, "anomalous")
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
return os.path.join(directory, "usage_ledger.json")
|
||||
|
||||
|
||||
def _names(data):
|
||||
"""{type or ref: [last, count]} from the saved file, {} when it is not one."""
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _load():
|
||||
global _ledger
|
||||
if _ledger is None:
|
||||
data = {}
|
||||
try:
|
||||
with open(ledger_path(), encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
opened = data.get("opened") if isinstance(data.get("opened"), dict) else {}
|
||||
_ledger = {
|
||||
"started": data.get("started") if isinstance(data.get("started"), (int, float)) else None,
|
||||
"types": _names(data.get("types")),
|
||||
"models": _names(data.get("models")),
|
||||
"opened": {"types": _names(opened.get("types")), "models": _names(opened.get("models"))},
|
||||
}
|
||||
return _ledger
|
||||
|
||||
|
||||
def _copy(ledger):
|
||||
return {"started": ledger["started"], "types": dict(ledger["types"]), "models": dict(ledger["models"]),
|
||||
"opened": {kind: dict(ledger["opened"][kind]) for kind in ("types", "models")}}
|
||||
|
||||
|
||||
def _save():
|
||||
global _timer
|
||||
with _lock:
|
||||
_timer = None
|
||||
ledger = _load()
|
||||
for names in (ledger, ledger["opened"]):
|
||||
for kind in ("types", "models"):
|
||||
if len(names[kind]) > MAX_NAMES:
|
||||
names[kind] = dict(sorted(names[kind].items(), key=lambda entry: entry[1][0], reverse=True)[:MAX_NAMES])
|
||||
snapshot = _copy(ledger)
|
||||
try:
|
||||
atomic_write_json(ledger_path(), snapshot)
|
||||
except (OSError, ValueError) as error:
|
||||
logging.warning("[Anomalous Browser] Could not save the usage record: %s", error)
|
||||
|
||||
|
||||
def prompt_uses(prompt):
|
||||
"""(node types, model file references) a queued prompt names."""
|
||||
types, models = set(), set()
|
||||
for node in prompt.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if isinstance(node.get("class_type"), str) and node["class_type"]:
|
||||
types.add(node["class_type"])
|
||||
for value in (node.get("inputs") or {}).values():
|
||||
if isinstance(value, dict) and isinstance(value.get("lora"), str):
|
||||
value = value["lora"] # a row of rgthree's Power Lora Loader
|
||||
if isinstance(value, str) and MODEL_FILE.search(value):
|
||||
models.add(value.replace("\\", "/").strip().lower())
|
||||
return types, models
|
||||
|
||||
|
||||
def _note(types, models, when, opened):
|
||||
"""Counts `types` and `models` as used at `when`, among the runs or the opened workflows."""
|
||||
global _timer
|
||||
with _lock:
|
||||
ledger = _load()
|
||||
if ledger["started"] is None:
|
||||
ledger["started"] = when
|
||||
names = ledger["opened"] if opened else ledger
|
||||
for kind, found in (("types", types), ("models", models)):
|
||||
for name in found:
|
||||
count = names[kind].get(name, [0, 0])[1]
|
||||
names[kind][name] = [when, count + 1]
|
||||
if _timer is None:
|
||||
_timer = threading.Timer(SAVE_DELAY, _save)
|
||||
_timer.daemon = True
|
||||
_timer.start()
|
||||
|
||||
|
||||
def note(prompt, when=None):
|
||||
"""Note what `prompt` (ComfyUI's API prompt) uses, as of `when`; saved shortly after."""
|
||||
if not isinstance(prompt, dict):
|
||||
return
|
||||
types, models = prompt_uses(prompt)
|
||||
_note(types, models, time.time() if when is None else when, opened=False)
|
||||
|
||||
|
||||
def note_prompt(json_data):
|
||||
"""ComfyUI's on_prompt hook: notes the run and hands the request on unchanged."""
|
||||
try:
|
||||
note((json_data or {}).get("prompt"))
|
||||
except Exception as error: # noqa: BLE001 - a note must never stop a run
|
||||
logging.warning("[Anomalous Browser] Could not note a run's nodes and models: %s", error)
|
||||
return json_data
|
||||
|
||||
|
||||
def _posted(values):
|
||||
"""The names of a posted list: strings of a sane length, at most MAX_POSTED; None when not a list."""
|
||||
if not isinstance(values, list):
|
||||
return None
|
||||
return {value for value in values[:MAX_POSTED] if isinstance(value, str) and 0 < len(value) <= MAX_NAME_CHARS}
|
||||
|
||||
|
||||
async def api_opened(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
types, models = _posted(data.get("types")), _posted(data.get("models"))
|
||||
if types is None or models is None:
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
models = {model.replace("\\", "/").strip().lower() for model in models if MODEL_FILE.search(model)}
|
||||
_note(types, models, time.time(), opened=True)
|
||||
return web.json_response({"noted": len(types) + len(models)})
|
||||
|
||||
|
||||
def read():
|
||||
"""The record as noted so far (a copy), shaped as the module docstring says."""
|
||||
with _lock:
|
||||
return _copy(_load())
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_post("/anomalous/usage/opened", api_opened)
|
||||
+105
-66
@@ -6,6 +6,9 @@ release tag. Nothing runs automatically: every network call starts from a
|
||||
button in the UI. Switching refuses when tracked files have local changes, and
|
||||
only tags that are published releases (or at least exist on the remote) can be
|
||||
checked out.
|
||||
|
||||
Every function acts on the plugin itself unless given a `Checkout`: the
|
||||
companion node pack Anomalous_TTS is updated the same way (api/tts_version.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -20,6 +23,11 @@ import urllib.request
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ..net_proxy import urlopen
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from net_proxy import urlopen
|
||||
|
||||
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
STATE_FILE = os.path.join(PLUGIN_DIR, ".anomalous_version.local.json")
|
||||
# A version whose tree contains this file has the version panel; older ones do not.
|
||||
@@ -34,6 +42,21 @@ _lock = threading.Lock()
|
||||
_releases_cache = {"expires": 0, "data": None}
|
||||
|
||||
|
||||
class Checkout:
|
||||
"""Another git checkout to manage: its folder, where its last switch is noted, and the file
|
||||
that marks versions with a version panel (None: no such notion)."""
|
||||
|
||||
def __init__(self, root, state_file, marker=None):
|
||||
self.root = root
|
||||
self.state_file = state_file
|
||||
self.marker = marker
|
||||
self.cache = {"expires": 0, "data": None}
|
||||
|
||||
|
||||
def _root(repo):
|
||||
return repo.root if repo else PLUGIN_DIR
|
||||
|
||||
|
||||
class VersionError(Exception):
|
||||
"""A user-facing failure with a stable code for the UI."""
|
||||
|
||||
@@ -52,14 +75,14 @@ def _git_executable():
|
||||
return found
|
||||
|
||||
|
||||
def _git(*args, timeout=30, check=True):
|
||||
def _git(*args, timeout=30, check=True, repo=None):
|
||||
kwargs = {}
|
||||
if os.name == "nt":
|
||||
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_git_executable(), "-C", PLUGIN_DIR, *args],
|
||||
[_git_executable(), "-C", _root(repo), *args],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=timeout, env=env, **kwargs,
|
||||
)
|
||||
@@ -85,17 +108,17 @@ def version_key(tag):
|
||||
return (numbers, 1 if not suffix else 0, suffix)
|
||||
|
||||
|
||||
def current_state():
|
||||
def current_state(repo=None):
|
||||
"""Local only (no network): what is installed and whether it can be switched."""
|
||||
if not os.path.isdir(os.path.join(PLUGIN_DIR, ".git")) and _git_out("rev-parse", "--is-inside-work-tree") != "true":
|
||||
if not os.path.isdir(os.path.join(_root(repo), ".git")) and _git_out("rev-parse", "--is-inside-work-tree", repo=repo) != "true":
|
||||
return {"is_git": False}
|
||||
commit = _git_out("rev-parse", "HEAD")
|
||||
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD") or None
|
||||
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD") or None
|
||||
described = _git_out("describe", "--tags", "--always")
|
||||
base_tag = _git_out("describe", "--tags", "--abbrev=0") or None
|
||||
commit = _git_out("rev-parse", "HEAD", repo=repo)
|
||||
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD", repo=repo) or None
|
||||
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD", repo=repo) or None
|
||||
described = _git_out("describe", "--tags", "--always", repo=repo)
|
||||
base_tag = _git_out("describe", "--tags", "--abbrev=0", repo=repo) or None
|
||||
# Porcelain lines are "XY path"; keep the leading status column (no strip before slicing).
|
||||
status = _git("status", "--porcelain", "--untracked-files=no", check=False).stdout
|
||||
status = _git("status", "--porcelain", "--untracked-files=no", check=False, repo=repo).stdout
|
||||
changed = [line[3:] for line in status.splitlines() if line.strip()]
|
||||
return {
|
||||
"is_git": True,
|
||||
@@ -106,8 +129,8 @@ def current_state():
|
||||
"label": exact_tag or described or commit[:12],
|
||||
"dirty": bool(changed),
|
||||
"changed_files": changed[:20],
|
||||
"remote": _git_out("remote", "get-url", "origin") or None,
|
||||
"previous": _read_state().get("previous"),
|
||||
"remote": _git_out("remote", "get-url", "origin", repo=repo) or None,
|
||||
"previous": _read_state(repo).get("previous"),
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +147,7 @@ def _fetch_github_releases(owner, repo):
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "Anomalous-Model-Browser",
|
||||
})
|
||||
with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT) as response:
|
||||
with urlopen(request, timeout=NETWORK_TIMEOUT) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
releases = []
|
||||
for item in payload if isinstance(payload, list) else []:
|
||||
@@ -141,36 +164,50 @@ def _fetch_github_releases(owner, repo):
|
||||
return releases
|
||||
|
||||
|
||||
def _remote_tags():
|
||||
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3)
|
||||
def _remote_tags(repo=None):
|
||||
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3, repo=repo)
|
||||
tags = [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line]
|
||||
return [{"tag": tag, "name": tag, "published_at": None, "prerelease": False, "notes": "", "url": None} for tag in tags]
|
||||
|
||||
|
||||
def list_releases(force=False):
|
||||
def _remote_tag_at(commit, repo=None):
|
||||
"""The remote tag on `commit`, for a checkout whose own tags were never fetched (a clone made
|
||||
before the release, then updated by a pull of its branch)."""
|
||||
output = _git_out("ls-remote", "--tags", "origin", timeout=NETWORK_TIMEOUT * 3, repo=repo)
|
||||
for line in output.splitlines():
|
||||
sha, _, ref = line.partition("\t")
|
||||
if commit and sha.startswith(commit) and ref.startswith("refs/tags/"):
|
||||
return ref[len("refs/tags/"):].removesuffix("^{}")
|
||||
return None
|
||||
|
||||
|
||||
def list_releases(force=False, repo=None):
|
||||
"""Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback."""
|
||||
state = current_state()
|
||||
state = current_state(repo)
|
||||
if not state.get("is_git"):
|
||||
raise VersionError("not_git", "The plugin is not a git checkout")
|
||||
cache = repo.cache if repo else _releases_cache
|
||||
now = time.monotonic()
|
||||
if not force and _releases_cache["data"] and now < _releases_cache["expires"]:
|
||||
releases, source = _releases_cache["data"]
|
||||
if not force and cache["data"] and now < cache["expires"]:
|
||||
releases, source = cache["data"]
|
||||
else:
|
||||
releases, source = None, None
|
||||
repo = _github_repo(state.get("remote"))
|
||||
if repo:
|
||||
github = _github_repo(state.get("remote"))
|
||||
if github:
|
||||
try:
|
||||
releases, source = _fetch_github_releases(*repo), "github"
|
||||
releases, source = _fetch_github_releases(*github), "github"
|
||||
except Exception:
|
||||
releases = None
|
||||
if releases is None:
|
||||
releases, source = _remote_tags(), "git"
|
||||
releases, source = _remote_tags(repo), "git"
|
||||
if not releases:
|
||||
raise VersionError("offline", "Could not reach GitHub or the git remote")
|
||||
releases.sort(key=lambda item: (item["published_at"] or "", version_key(item["tag"])), reverse=True)
|
||||
if source == "git":
|
||||
releases.sort(key=lambda item: version_key(item["tag"]), reverse=True)
|
||||
_releases_cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
|
||||
cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
|
||||
if not state.get("tag") and not state.get("dirty"):
|
||||
state["tag"] = _remote_tag_at(state.get("commit"), repo)
|
||||
|
||||
current_key = version_key(state.get("tag") or state.get("base_tag"))
|
||||
stable = [item for item in releases if not item["prerelease"]]
|
||||
@@ -188,24 +225,25 @@ def list_releases(force=False):
|
||||
|
||||
# ---------- switching ----------
|
||||
|
||||
def _read_state():
|
||||
def _read_state(repo=None):
|
||||
try:
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as handle:
|
||||
with open(repo.state_file if repo else STATE_FILE, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _write_state(data):
|
||||
temporary = STATE_FILE + ".tmp"
|
||||
def _write_state(data, repo=None):
|
||||
path = repo.state_file if repo else STATE_FILE
|
||||
temporary = path + ".tmp"
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, ensure_ascii=False, indent=2)
|
||||
os.replace(temporary, STATE_FILE)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def _require_switchable():
|
||||
state = current_state()
|
||||
def _require_switchable(repo=None):
|
||||
state = current_state(repo)
|
||||
if not state.get("is_git"):
|
||||
raise VersionError("not_git", "The plugin is not a git checkout")
|
||||
if state["dirty"]:
|
||||
@@ -213,25 +251,26 @@ def _require_switchable():
|
||||
return state
|
||||
|
||||
|
||||
def _known_tag(tag):
|
||||
def _known_tag(tag, repo=None):
|
||||
if not TAG_NAME.match(str(tag or "")):
|
||||
raise VersionError("bad_tag", "Invalid version name")
|
||||
tags = {item["tag"] for item in list_releases()["releases"]}
|
||||
tags = {item["tag"] for item in list_releases(repo=repo)["releases"]}
|
||||
if tag not in tags:
|
||||
raise VersionError("unknown_tag", f"{tag} is not a published version")
|
||||
return tag
|
||||
|
||||
|
||||
def _fetch_tag(tag):
|
||||
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT)
|
||||
def _fetch_tag(tag, repo=None):
|
||||
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT, repo=repo)
|
||||
|
||||
|
||||
def preview_switch(tag):
|
||||
def preview_switch(tag, repo=None):
|
||||
"""Fetch the target and report what switching would mean, without changing anything."""
|
||||
state = _require_switchable()
|
||||
_known_tag(tag)
|
||||
_fetch_tag(tag)
|
||||
has_switcher = _git("cat-file", "-e", f"refs/tags/{tag}:{SWITCHER_MARKER}", check=False).returncode == 0
|
||||
state = _require_switchable(repo)
|
||||
_known_tag(tag, repo)
|
||||
_fetch_tag(tag, repo)
|
||||
marker = repo.marker if repo else SWITCHER_MARKER
|
||||
has_switcher = not marker or _git("cat-file", "-e", f"refs/tags/{tag}:{marker}", check=False, repo=repo).returncode == 0
|
||||
target_key = version_key(tag)
|
||||
current_key = version_key(state.get("tag") or state.get("base_tag"))
|
||||
return {
|
||||
@@ -243,15 +282,15 @@ def preview_switch(tag):
|
||||
}
|
||||
|
||||
|
||||
def switch_to(tag):
|
||||
def switch_to(tag, repo=None):
|
||||
with _lock:
|
||||
state = _require_switchable()
|
||||
_known_tag(tag)
|
||||
_fetch_tag(tag)
|
||||
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}")
|
||||
_write_state({"previous": previous, "switched_at": time.time()})
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
state = _require_switchable(repo)
|
||||
_known_tag(tag, repo)
|
||||
_fetch_tag(tag, repo)
|
||||
previous = {"commit": _git_out("rev-parse", "HEAD", repo=repo), "branch": state["branch"], "label": state["label"]}
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}", repo=repo)
|
||||
_write_state({"previous": previous, "switched_at": time.time()}, repo)
|
||||
return {"state": current_state(repo), "restart_required": True}
|
||||
|
||||
|
||||
def _default_branch():
|
||||
@@ -282,39 +321,39 @@ def return_to_latest():
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
|
||||
|
||||
def undo_last_switch():
|
||||
def undo_last_switch(repo=None):
|
||||
with _lock:
|
||||
previous = _read_state().get("previous")
|
||||
previous = _read_state(repo).get("previous")
|
||||
if not previous or not previous.get("commit"):
|
||||
raise VersionError("no_previous", "There is no switch to undo")
|
||||
state = _require_switchable()
|
||||
here = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
|
||||
state = _require_switchable(repo)
|
||||
here = {"commit": _git_out("rev-parse", "HEAD", repo=repo), "branch": state["branch"], "label": state["label"]}
|
||||
if previous.get("branch"):
|
||||
# Going back to a branch means its current tip, even if it moved since (e.g. a Manager update).
|
||||
_git("checkout", "--quiet", previous["branch"])
|
||||
_git("checkout", "--quiet", previous["branch"], repo=repo)
|
||||
else:
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"])
|
||||
_write_state({"previous": here, "switched_at": time.time()})
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"], repo=repo)
|
||||
_write_state({"previous": here, "switched_at": time.time()}, repo)
|
||||
return {"state": current_state(repo), "restart_required": True}
|
||||
|
||||
|
||||
# ---------- routes ----------
|
||||
|
||||
def _error_response(error):
|
||||
def error_response(error):
|
||||
status = 409 if error.code in {"dirty", "diverged"} else 400
|
||||
if error.code in {"offline", "git_timeout", "git_failed"}:
|
||||
status = 502
|
||||
return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status)
|
||||
|
||||
|
||||
async def _run(handler, *args):
|
||||
async def run(handler, *args):
|
||||
try:
|
||||
return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))})
|
||||
except VersionError as error:
|
||||
return _error_response(error)
|
||||
return error_response(error)
|
||||
|
||||
|
||||
async def _json_body(request):
|
||||
async def json_body(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -327,32 +366,32 @@ async def api_get_version(request):
|
||||
try:
|
||||
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
|
||||
except VersionError as error:
|
||||
return _error_response(error)
|
||||
return error_response(error)
|
||||
|
||||
|
||||
async def api_get_releases(request):
|
||||
"""GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked)."""
|
||||
return await _run(list_releases, request.query.get("refresh") == "1")
|
||||
return await run(list_releases, request.query.get("refresh") == "1")
|
||||
|
||||
|
||||
async def api_preview_switch(request):
|
||||
"""POST /anomalous/version/preview {tag}"""
|
||||
return await _run(preview_switch, str((await _json_body(request)).get("tag", "")))
|
||||
return await run(preview_switch, str((await json_body(request)).get("tag", "")))
|
||||
|
||||
|
||||
async def api_switch_version(request):
|
||||
"""POST /anomalous/version/switch {tag}"""
|
||||
return await _run(switch_to, str((await _json_body(request)).get("tag", "")))
|
||||
return await run(switch_to, str((await json_body(request)).get("tag", "")))
|
||||
|
||||
|
||||
async def api_return_to_latest(request):
|
||||
"""POST /anomalous/version/latest"""
|
||||
return await _run(return_to_latest)
|
||||
return await run(return_to_latest)
|
||||
|
||||
|
||||
async def api_undo_switch(request):
|
||||
"""POST /anomalous/version/undo"""
|
||||
return await _run(undo_last_switch)
|
||||
return await run(undo_last_switch)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
+54
-20
@@ -2,7 +2,13 @@
|
||||
description, and preview media. The API key comes from the plugin's config.json.
|
||||
|
||||
fetch_civitai_info tells the two failures apart: None means Civitai answered that it does not
|
||||
know the file; CivitaiUnreachable means it could not be asked (no connection, timeouts, errors).
|
||||
know the file; CivitaiUnreachable means it could not be asked (no connection, timeouts, errors),
|
||||
and carries why (net_proxy.failure). Requests go through Settings' proxy (net_proxy.py).
|
||||
|
||||
Civitai answers its API on two addresses, civitai.com and civitai.red. Some networks reach only
|
||||
the second (mainland China's, a cloud server there), so when one does not answer the other is
|
||||
asked, and the one that answered is asked first next time (api_hosts). The pictures stay on
|
||||
image.civitai.com either way.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -12,11 +18,29 @@ import urllib.error
|
||||
import urllib.request
|
||||
from typing import Dict, Optional
|
||||
|
||||
try:
|
||||
from .net_proxy import failure, urlopen
|
||||
except ImportError: # the scan process runs this as a top-level module
|
||||
from net_proxy import failure, urlopen
|
||||
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
API_HOSTS = ("civitai.com", "civitai.red")
|
||||
_answered = [API_HOSTS[0]] # the address that answered last
|
||||
|
||||
|
||||
def api_hosts():
|
||||
"""Civitai's API addresses, the one that answered last first."""
|
||||
return [_answered[0]] + [host for host in API_HOSTS if host != _answered[0]]
|
||||
|
||||
|
||||
def answered(host):
|
||||
"""Notes that `host` answered (with data or a 404), so it is asked first from now on."""
|
||||
_answered[0] = host
|
||||
|
||||
|
||||
class CivitaiUnreachable(Exception):
|
||||
"""Civitai did not answer (network, timeout or server error) after the retries."""
|
||||
"""Civitai did not answer (network, timeout or server error) after the retries; args[0] says
|
||||
why ('timeout', 'refused', 'http:503' …)."""
|
||||
|
||||
|
||||
def _load_api_key():
|
||||
@@ -46,32 +70,42 @@ def _headers():
|
||||
|
||||
def fetch_civitai_info(file_hash: str, max_retries: int = 3) -> Optional[Dict]:
|
||||
"""Civitai's record of the model version with this file hash; None when Civitai does not
|
||||
know the file (404). Raises CivitaiUnreachable when it could not be asked."""
|
||||
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
|
||||
know the file (404). Raises CivitaiUnreachable when it could not be asked. Each try asks
|
||||
the other address too when the first does not answer at all."""
|
||||
why = "other"
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
|
||||
return json.loads(response.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(f"\033[93m[Skip] 模型 Hash {file_hash} 未在 Civitai 找到 (404)。\033[0m")
|
||||
return None
|
||||
print(f"[-] 请求异常,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"[-] 网络请求超时或异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
|
||||
except Exception as e:
|
||||
print(f"[-] 未知异常: {e} (尝试 {attempt+1}/{max_retries})")
|
||||
for host in api_hosts():
|
||||
url = f"https://{host}/api/v1/model-versions/by-hash/{file_hash}"
|
||||
try:
|
||||
with urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
answered(host)
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
answered(host)
|
||||
print(f"\033[93m[Skip] 模型 Hash {file_hash} 未在 Civitai 找到 (404)。\033[0m")
|
||||
return None
|
||||
why = failure(e)
|
||||
print(f"[-] 请求异常,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
|
||||
break # Civitai answered with an error: the other address is the same Civitai
|
||||
except urllib.error.URLError as e:
|
||||
why = failure(e)
|
||||
print(f"[-] {host} 网络请求超时或异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
|
||||
except Exception as e:
|
||||
why = failure(e)
|
||||
print(f"[-] {host} 未知异常: {e} (尝试 {attempt+1}/{max_retries})")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2)
|
||||
print(f"\033[93m[Skip] 模型 Hash {file_hash} 网络重试失败。\033[0m")
|
||||
raise CivitaiUnreachable(file_hash)
|
||||
raise CivitaiUnreachable(why)
|
||||
|
||||
|
||||
def fetch_model_description(model_id) -> str:
|
||||
"""The model page's description (the version record only has the version's); '' when unavailable."""
|
||||
try:
|
||||
request = urllib.request.Request(f"https://civitai.com/api/v1/models/{model_id}", headers=_headers())
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
request = urllib.request.Request(f"https://{api_hosts()[0]}/api/v1/models/{model_id}", headers=_headers())
|
||||
with urlopen(request, timeout=10) as response:
|
||||
return json.loads(response.read().decode('utf-8')).get("description") or ""
|
||||
except Exception as e:
|
||||
print(f"[-] 获取模型主页详细说明失败: {e}")
|
||||
@@ -82,7 +116,7 @@ def download_media(url: str, base_path: str, max_retries: int = 3):
|
||||
"""Downloads an image or video to base_path + the extension its type calls for; the path, or None."""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
|
||||
with urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
|
||||
content_type = response.headers.get("Content-Type", "").lower()
|
||||
ext = ".png"
|
||||
if "video/mp4" in content_type: ext = ".mp4"
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
Start with the repository-root [`ARCHITECTURE.md`](../../ARCHITECTURE.md), then
|
||||
open only the topic needed for the current task.
|
||||
|
||||
- [`ui-flows.md`](ui-flows.md): the UI map's hand-written part, in Chinese: what each part of
|
||||
a page is for, what a click does and why, and one name per thing (a glossary the structure
|
||||
check enforces). Where a word on screen is drawn and what it calls comes from the code:
|
||||
`node tools/ui_map.mjs find <words>` (`tools/ui_map.mjs`, its index `tools/ui_map_scan.mjs`;
|
||||
`build` writes the whole map to the untracked `docs/ui-map.md`).
|
||||
- [`backend.md`](backend.md): Python routes, filesystem safety, persistence,
|
||||
scan state, model sidecars, and backend performance.
|
||||
- [`frontend.md`](frontend.md): extension bootstrap, UI ownership, localization,
|
||||
@@ -21,4 +26,4 @@ open only the topic needed for the current task.
|
||||
These documents describe current contracts rather than implementation history.
|
||||
For exact changes, use Git history. For user-visible releases, use
|
||||
`CHANGELOG.md`. For recurring implementation traps, use
|
||||
`.agents/logs/ai_lessons.md`.
|
||||
[`lessons.md`](lessons.md).
|
||||
|
||||
+376
-16
@@ -9,9 +9,8 @@ recipe persistence, or filesystem-facing routes.
|
||||
package. `api/__init__.py` registers `aiohttp` routes, all prefixed with
|
||||
`/anomalous/`. Changes to Python modules require a full ComfyUI restart; a
|
||||
frontend reload alone does not replace registered handlers or module state.
|
||||
Route registration uses explicit module references. `tests/test_route_manifest.py`
|
||||
locks the HTTP method/path pairs so internal ownership changes cannot silently
|
||||
drop, duplicate, or rename an endpoint.
|
||||
Route registration uses explicit module references, so moving a handler between
|
||||
modules cannot silently drop, duplicate, or rename an endpoint.
|
||||
|
||||
The backend owns filesystem authority. A browser-supplied path, filename,
|
||||
category, output reference, recipe asset, or archive member is untrusted until
|
||||
@@ -20,7 +19,20 @@ small `api/utils.py` compatibility facade):
|
||||
|
||||
- `resolve_folder_subdir()` for configured model-folder boundaries;
|
||||
- `resolve_within()` for containment below an owned root;
|
||||
- `require_filename()` for single-file identifiers.
|
||||
- `require_filename()` for single-file identifiers (never `.` or `..`);
|
||||
- `model_file_within()` / `model_reference_within()` for a model file in a resolved folder.
|
||||
|
||||
A model file can be a link: on a cloud server (AutoDL) every model in the models folder links
|
||||
into the platform's shared, read-only model disk, under a name like `6b4679…` without an
|
||||
extension. Resolving the file would leave the models folder and lose its covers and info, which
|
||||
are written beside the link. So the folder part is resolved and must stay inside its root
|
||||
(through links too), and the file is taken as named: `model_file_within` is the folder joined
|
||||
with a bare `require_filename` name, `get_metadata` reads the sidecars beside the path it is
|
||||
given (`abspath`, not `realpath`), and edits, renames and deletes act on the link, never on the
|
||||
disk behind it. Only when a link has no info beside it are the info files beside the file it
|
||||
links to read (`_info_base`): another tool (A1111's Civitai Helper) may have left them there. Nothing in the plugin writes into a model file. Tidy's moves and the
|
||||
import's `move_into` tell a rename from a copy by where the link is (`lstat`), so moving a
|
||||
linked model renames the link instead of copying the model disk's file onto the server.
|
||||
|
||||
Never rely only on `..` rejection. Absolute Windows paths, UNC paths, alternate
|
||||
separators, device paths, and symlinks can bypass naïve string checks. Media
|
||||
@@ -47,13 +59,27 @@ look; every change under `/anomalous/` answers only the computer ComfyUI runs on
|
||||
middleware (`api/local_guard.py`, registered first in `setup_routes`) decides it for all routes:
|
||||
a request other than GET must be local unless its path is in `OPEN_POSTS` (reads sent as POST:
|
||||
`resolve_paths_to_previews`, `resolve_hash_batch`, `inspect_image_material`); a GET is open
|
||||
unless its path is in `LOCAL_GETS` (`backup/file`, `version/releases`, `import/folders`).
|
||||
unless its path is in `LOCAL_GETS` (`backup/file`, `version/releases`, `import/folders`,
|
||||
`civitai_check`: the first-run setup's one request to Civitai, `api/network_check.py`).
|
||||
`GET /anomalous/quick_index` (`api/quick_index.py`) is an open read like `/anomalous/models`:
|
||||
every model file in a few words for the canvas's quick search.
|
||||
`/anomalous/mcp` answers other devices itself. Local means: a loopback peer, no proxy headers
|
||||
(`Forwarded`, `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`) and a loopback `Host`, so a
|
||||
page that reached the server under another name (DNS rebinding) is refused too. A refusal is
|
||||
HTTP 403 with `code: local_only` and a bilingual message. `GET /anomalous/access` tells the page
|
||||
whether it is local; `remote_view.js` then shows a "view only" chip in the header.
|
||||
|
||||
ComfyUI on a rented cloud server is only ever opened from another device. An empty
|
||||
`allow_remote_changes` file in the plugin folder, made on the server and read once at start
|
||||
(`REMOTE_CHANGES`), makes `local_guard.may_change` true for every request: the middleware and
|
||||
the routes that check for themselves (import, placement, scan choice, recipe focus) use
|
||||
`may_change`; `is_local` stays for what needs the browser on this computer (import's search of
|
||||
this computer's Downloads) and for MCP. `GET /anomalous/access` answers `{local, changes}`, and
|
||||
for another device also `command`: the line that makes the file (or, when it is there, removes
|
||||
it), with this server's real path, which the chip's dialog offers to copy; it only runs where
|
||||
someone pastes it, in the server's terminal. The chip reads "remote changes on" while the file
|
||||
is there. Usage and the risk: `docs/guides/cloud-server.md`.
|
||||
|
||||
## Storage ownership
|
||||
|
||||
Runtime UI settings and newly stored API keys live in `api/config.json`.
|
||||
@@ -145,7 +171,15 @@ polling after its UI has been reopened.
|
||||
|
||||
`scraper.py --report-file` appends one JSON line per model it changed (`matched`,
|
||||
`inferred` with its reason, `failed` with the error, renames and covers) and events
|
||||
(`civitai_down`, `done` with the unchanged count). One model's error is reported and
|
||||
(`civitai_down` with `reason`, net_proxy.py's name for the failure, and `done` with the
|
||||
unchanged count; the result carries the first reason as `civitai_error`). The scan process
|
||||
gets Settings' proxy as HTTP(S)_PROXY (`net_proxy.child_env`). Civitai answers its API on
|
||||
civitai.com and civitai.red, and some networks (a cloud server in mainland China) reach only the
|
||||
second: `civitai_client.api_hosts` gives both, the one that answered last first, and every API
|
||||
request (the scan, import's and Model Check's lookups, `civitai_check`, which names the `host`)
|
||||
asks the other when the first does not answer at all; an HTTP error is Civitai's own answer and
|
||||
is not asked again. The pictures stay on image.civitai.com, so after `COVERS_DOWN_AFTER` (2)
|
||||
covers in a row that would not download the scan fetches no more covers (`covers_down`). One model's error is reported and
|
||||
the scan goes on. `ScanJob` in `api/scan_report.py` reads each folder's report when its
|
||||
worker exits and, before the scan's marker is released, writes the scan's result to
|
||||
`user/anomalous/last_scan.json` and the activity log, so whoever sees the scan end can
|
||||
@@ -153,12 +187,327 @@ read its result. The scan page's picked or listed models go to `/anomalous/scan_
|
||||
`targets` (`--targets-file` per folder), so one scan has one result.
|
||||
|
||||
An unmatched model's `.info` (`id` -1) keeps why: `anomalous_unmatched_reason` is
|
||||
`not_found` (Civitai answered 404), `network` (no answer after the retries) or
|
||||
`offline`. Online scans look up `network` and `offline` models again; `not_found` ones
|
||||
wait for "look up again". After `CIVITAI_DOWN_AFTER` models in a row without an answer
|
||||
`not_found` (Civitai answered 404), `network` (no answer after the retries),
|
||||
`offline`, or `damaged`: Civitai had no record and the safetensors header does not read
|
||||
whole (`model_identity.safetensors_damage`: too short, a header longer than the file, not
|
||||
JSON, or a tensor ending past the file's end; only the header is read). A damaged header
|
||||
changes the file's hash, so the file is to be downloaded again; the scan summary counts these
|
||||
(`damaged`). Online scans look up `network` and `offline` models again; `not_found` and
|
||||
`damaged` ones wait for "look up again". After `CIVITAI_DOWN_AFTER` models in a row without an answer
|
||||
a scan stops asking (`--civitai-down` carries this to the next folders) and infers the
|
||||
rest from the files, instead of waiting for every timeout.
|
||||
|
||||
A model can be left out of scans: `"scan": "skip"` in its `<model>.anomalous.json` (the
|
||||
user's own layer, which scans never write; `model_identity.scan_choice`). A scan of a whole
|
||||
folder skips it and the scan summary counts it apart (`excluded`), not in the other counts;
|
||||
picked by hand (`targets`) it is scanned all the same. `"scan": "scan"` records that the user
|
||||
was asked and kept it. Nothing decides this alone: the summary only marks new models whose
|
||||
safetensors header reads but names no drawing model kind (`model_kind.inspect_file`, kind "",
|
||||
a voice model say) as `not_drawing`, and the scan page asks. Those headers are read
|
||||
`HEAD_READERS` (16) at a time, since on a network drive each read waits on the network (1041
|
||||
new models on a cloud server's model disk took 79 s one after another), through
|
||||
`get_metadata`, which reads headers through `api/model_heads.py`: each file's answer is kept
|
||||
by real path with its size and time in user/anomalous/model_heads.json (at most 50,000 files,
|
||||
the longest unused dropped first, written a few seconds after the last change, and only once
|
||||
`setup_routes` called `keep_on_disk`, so tests stay in memory), so a header is read once per
|
||||
file version, also after a restart. The last summary is kept too (`_last`, and
|
||||
user/anomalous/scan_summary.json when it changed): `?quick=1` hands it out at once for the page
|
||||
to show while a fresh one is worked out, and requests meanwhile await that same count
|
||||
(`_fresh`). The models found on Civitai are kept with it, components apart, and
|
||||
`GET /anomalous/scan_matched` lists them (file, Civitai's name, base model; at most 5000), so
|
||||
the summary the page reads each time stays small. `POST /anomalous/scan_choice`
|
||||
(`api/scan_choice.py`, this computer only) takes the picked models as scan `targets`, at most
|
||||
2000, and writes only `.safetensors` files' layers that are readable.
|
||||
|
||||
The summary also counts the models in component folders (`COMPONENT_TYPES`: VAE, text
|
||||
encoders, CLIP, CLIP Vision, upscalers) as `components` and lists them, found ones too, in
|
||||
`component_models` with their `state`; they stay in the other counts, and their list items carry
|
||||
`component`, so the page can show them apart. Folder type decides, not the file's header.
|
||||
`covers` holds up to 48 covers of scanned models, components left out, newest model file first
|
||||
(`{name, preview_url}`), for the pictures on top of the scan card.
|
||||
|
||||
The model editor writes through `POST /anomalous/update_metadata` (only the fields it sends:
|
||||
`custom_name`, `custom_notes`, `custom_source_url`, which must be empty or an http(s) address,
|
||||
else 400 and nothing changes) into the user layer, and covers through `set_custom_cover` /
|
||||
`upload_custom_cover` first. `GET /anomalous/model_covers?type&path_idx&subfolder&filename`
|
||||
(`api/model_media.py`, read-only) tells it Civitai's kept image (`<model>.civitai_bak.*`, as an
|
||||
image URL) and whether the cover shown is the user's, so it can offer Civitai's back.
|
||||
|
||||
Current node's saved values: `GET /anomalous/parameters/by_node_type` gives each recipe set's
|
||||
file and values' `signature`; `GET /anomalous/parameter_pictures?signatures=a,b`
|
||||
(`api/parameters.py`, at most 64 signatures, 64 hex characters each) counts, in one pass over
|
||||
the newest output pictures (`recipe_images._recent_output_pngs`), how many were made with each
|
||||
set's values and names the newest. A picture's signature is read once per file version
|
||||
(`lru_cache` of 4096 entries keyed by path and modification time), which the workflow page's
|
||||
`parameter_gallery` shares.
|
||||
|
||||
The scan page's picker reads `GET /anomalous/scan_pickable` (`api/scan_summary.py`): the
|
||||
same files the summary counts (the scan folders' `.safetensors`, each once), the "don't scan"
|
||||
ones included, up to 5000 with the exact `total`. Each carries its locator (`type`,
|
||||
`path_idx`, `rel`), size, cover URL and only the metadata fields its badge needs, not the
|
||||
descriptions. Its picks go to `/anomalous/scan_all` as `targets`, like a list page's.
|
||||
|
||||
## MCP endpoint
|
||||
|
||||
`track_run(prompt_ids, task, wait_seconds)` attaches existing submissions from this server to
|
||||
an open task without running anything. IDs must exist in its queue/history; each is owned by
|
||||
one recorded run. Retrying an identical batch returns that run, not the task's newest run.
|
||||
External callers must supply their own returned IDs; no queue-difference attribution is used.
|
||||
`run_workflow` waits up to `wait_seconds` (default 15, max 55) after starting its watcher,
|
||||
outside the submission semaphore. Text and structured responses include actual state,
|
||||
results/error and next action. Long runs still require `run_status(wait_seconds=55)`;
|
||||
the stateless MCP transport cannot wake an idle AI on its own. Task `live` reflects an
|
||||
in-flight recorded step, not recency of contact.
|
||||
|
||||
AI gate decisions are single-use: `ai_gates.check` consumes an immediate approval before
|
||||
dispatch; only an unused timed-out approval may authorize an identical retry. Missing
|
||||
step records fail closed for `ask`. `finish_step` preserves decisions received after a
|
||||
gate timeout; loading an unanswered gate after restart makes it eligible for late-answer
|
||||
delivery. Gate decisions wake both gate waiters and `get_answers` readers. Explicit task
|
||||
selection in `ai_panels._collect` is exclusive, including when that task no longer exists;
|
||||
session-scoped `get_answers` resolves its own active task before collecting input.
|
||||
|
||||
`api/ai_task_handoff.py` selects automatic resumption candidates without mutating ownership:
|
||||
it ranks unread user messages, marks, materials and panel answers
|
||||
by the input's own timestamp, not the task's last background update. Only inputs less than
|
||||
two hours old qualify; future timestamps and automatic timeout choices do not. A task with
|
||||
an in-flight step is excluded from automatic takeover; explicit task selection is unchanged.
|
||||
For five minutes after `report(done=True)`, reads stay in the finished task and panels/notes
|
||||
reopen it. `ai_tasks.current_task` applies this to both implicit and explicit task ids, including
|
||||
step recording without a start receipt. Automatic follow-ups yield to waiting tasks and unread
|
||||
replies; an explicit id keeps precedence and does not consume another task waiting for an AI.
|
||||
Task error counts only treat completed successful steps as recovery, never running, asking,
|
||||
allowed or cancelled steps. New receipts keep a compact `recovery_target` (explicit node,
|
||||
connection, workflow or resource, and setting names for `set_values`), so another target's
|
||||
success does not hide the failure. Legacy receipts lacking this identity retain tool-level
|
||||
matching with each other; no targets are guessed or migrated into historical records.
|
||||
`ai_answer_wait.collect` is shared by `get_answers` and question tools: after the bounded wait,
|
||||
typing/deadline checks, automatic choices and a final nonblocking collection share the task
|
||||
lock. A user answer that won the race is returned in that call, without a stale stop note.
|
||||
Without a panel id, deadlines are checked once per waiting panel, including expired ones.
|
||||
Typing counts from real keys only (the page posts at most every 5 s) and holds the deadline for
|
||||
`AFTER_TYPING` after the last one; a question the user typed on since it was asked is never
|
||||
answered on timeout, the AI is told they began an answer and ends its turn.
|
||||
Question receipts preserve messages and attach their pictures even when no option was chosen.
|
||||
|
||||
Run monitoring failures end the receipt as `stopped` with a reason without cancelling the
|
||||
host workflow. No detected queue entry is not success. Batch progress keeps the complete
|
||||
prompt list, and partial errors are saved even before all prompts finish. Marked-image
|
||||
validation and persistence run in a worker thread; invalid cross-drive paths are validation
|
||||
errors rather than uncaught route failures.
|
||||
|
||||
Run ownership is explicit: the page claims `run_workflow` with the `prompt_ids` capability,
|
||||
then returns `{prompt_ids, queued, requested, submission_error?}` from native submissions.
|
||||
An old page's claim fails with a refresh message before dispatch. The server validates the
|
||||
bounded, unique ID list and retains its order; `ai_task_runs.watch` queries only those IDs,
|
||||
never a global queue difference. A partial batch continues tracking accepted IDs but finishes
|
||||
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.
|
||||
From the same prompt (or the history of a run quicker than the first look) the watcher keeps
|
||||
`map` (`api/ai_run_map.py`: boxes by role with their facts, links typed from
|
||||
`NODE_CLASS_MAPPINGS[...].RETURN_TYPES`, reroutes and primitives wired through, seeds left out
|
||||
of `params`) and `changes` against the task's previous run with a map.
|
||||
|
||||
Canvas actions carry the task's own tab: `mcp_server._call` marks the call with its task
|
||||
(`ai_task_canvas.begin_call`, a context variable that `asyncio.to_thread` copies), `ask_page`
|
||||
adds `_task_canvas: {mode, tab, title}` to the page actions in `TAB_ACTIONS` and keeps the tab
|
||||
an outcome reports (`remember`); `tell` adds `canvas_tab` to the answer once when a tab was made.
|
||||
`download_missing` carries this context too. A recorded tab that is missing or cannot be
|
||||
activated fails on the page before executing the action, rather than using another workflow.
|
||||
Message `@` references resolve combo/workflow names with the internal `exact_name` filter
|
||||
in `mcp_tools` before limiting results; public list-tool searches remain fuzzy.
|
||||
The default mode is in `anomalous/ai_canvas.json` (GET/POST `/anomalous/ai_gates` `canvas_tab`),
|
||||
a task's own in its `canvas` record (POST `/anomalous/ai_tasks/{id}` `{canvas}`).
|
||||
|
||||
`POST /anomalous/ai_tasks/{id}/pictures` (`api/ai_pictures.py`) hands output pictures to an open
|
||||
task; pictures handed before the AI looked join one step.
|
||||
|
||||
`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:
|
||||
the `MCP-Protocol-Version`, `Mcp-Method` and (for `tools/call`) `Mcp-Name` headers must
|
||||
match the body, or 400 with -32020; an unknown version gets 400 with -32022 and the
|
||||
supported list. `initialize` selects the legacy shape (2025-03-26 to 2025-11-25); no
|
||||
session id is minted, so both eras are stateless. Unknown methods are -32601 (404 for
|
||||
modern requests).
|
||||
|
||||
`api/mcp_arguments.py` owns unambiguous required-argument renaming: one needed argument missing,
|
||||
one unknown argument whose name is a known other name for it (ALIASES, from the tool exams) and
|
||||
whose value has its type. A same-typed value under any other name is not taken. The MCP server normalizes
|
||||
before recording a step or checking a gate, so the approval signature, displayed arguments,
|
||||
execution and result references all use the same names. Direct `mcp_tools.call_tool` callers
|
||||
use the same normalization; the successful receipt tells the AI which name was corrected.
|
||||
|
||||
The peer must be loopback with no proxy header (`Forwarded`, `X-Forwarded-*`,
|
||||
`X-Real-IP`), and the Host and any Origin must name a loopback host; anything else is
|
||||
403. ComfyUI's own origin middleware also applies.
|
||||
|
||||
Tools (`api/mcp_tools.py`) only read and run in worker threads, four at a time. Model ids
|
||||
are `type:path_idx:relative/path` and images are paths under the output folder; both are
|
||||
resolved with `resolve_folder_subdir` / `resolve_within`, so an id cannot leave its root.
|
||||
A tool's own failure (bad id, missing file, unexpected error) is a result with
|
||||
`isError: true`, not a protocol error. Pictures attached on request are the gallery's
|
||||
512 px thumbnails (at most 1.5 MB).
|
||||
|
||||
Acting tools (`api/mcp_actions.py`, and `api/mcp_canvas_edit.py` for building a workflow)
|
||||
never delete or rename files; `remove_nodes` removes canvas nodes only, one Ctrl+Z step, and
|
||||
`new_workflow` opens a new unsaved tab named after the AI's words and the time, so it never
|
||||
opens or replaces a saved workflow. Canvas actions go
|
||||
through `mcp_bridge.ask_page`: it sends `{id, action, args}` as the `anomalous.mcp`
|
||||
websocket event to every open page and waits (30 s, 90 s for model checks; at most 20
|
||||
waiting). The first page to POST `{id, claim: true}` to `/anomalous/mcp/bridge` gets it
|
||||
(hidden tabs wait 1.5 s so a visible one wins) and posts `{id, outcome}` back. No page,
|
||||
no answer in time, or the page's refusal becomes a tool error. Scans and speech call this
|
||||
server's own `/anomalous/scan_all`, `/anomalous/download/status`, `/anomalous_tts/characters`, `/prompt` and
|
||||
`/history` over loopback, so they run exactly as from the UI (speech waits up to two
|
||||
minutes for the file). Downloads go through the page (`model_download.js` downloadMissing /
|
||||
downloadFrom), so the placement rules and the put-in after a download are the UI's own. Canvas changes are posted to the activity log with `via: "mcp"`.
|
||||
|
||||
`find_nodes(query=...)` returns up to five
|
||||
brief candidates within a small text budget; `types=[...]` explicitly requests full settings.
|
||||
`add_node` accepts `insert_after`, optional `insert_type`, and `wire_from`/`wire_to` (one ID or up
|
||||
to eight IDs). `connect_nodes` accepts just two node IDs. Both return actual connections,
|
||||
remaining required inputs and whether the branch reaches an output; reachability is not run
|
||||
validation. Same-type sockets of the same name pair up (a ControlNet's positive/negative into a
|
||||
sampler's). An occupied input that `wire_to` feeds is spliced when the new node takes that type: the
|
||||
old source feeds the new node's matching input. Otherwise automatic wiring never replaces occupied
|
||||
inputs without `replace:true`, except the selected downstream edge of an insertion; `insert_after`
|
||||
splices every type the node passes through (a LoRA's MODEL and CLIP). The legacy explicit `input` form still defaults
|
||||
to output slot zero and permits replacement. Ambiguity is an error with bounded candidates.
|
||||
`add_nodes` takes a group in API form for the open canvas: a link names a node of the group or a
|
||||
canvas node as `"#12"` and an output by slot or name; a node's `wire_to` feeds canvas nodes as
|
||||
`add_node` does, splicing only into inputs the group itself leaves free. The page lays the group out
|
||||
as one block in a free spot and applies it in one transaction with the same rollback.
|
||||
`get_situation` includes at most six path chains and eight node lines; its overall 3600-character
|
||||
budget trims these and reports omitted counts. Full canvas details remain explicitly available.
|
||||
|
||||
## Backups
|
||||
|
||||
`api/backup.py` packs the user's data into one .zip (`POST /anomalous/backup/export`, then
|
||||
`GET /anomalous/backup/file`) in the temp folder (the newest three are kept): the stores
|
||||
under `stores/<part>/` (recipes, combos, materials, parameters, and `comfy_workflows` =
|
||||
`user/default/workflows` when chosen; the notebooks' legacy-migration marker stays out),
|
||||
`settings/config.json` without `CIVITAI_API_KEY` plus `download_settings.json`, and for each
|
||||
model with a user layer or a user cover `models/<id>/user.json` and `cover<suffix>` (and,
|
||||
unless left out, its scan file `scan<.info|.civitai.info>`; Civitai's cover only when asked,
|
||||
stored once as `civitai<.civitai_bak suffix>`), listed
|
||||
in `manifest.json` with type, relative path, size and SHA-256 (from its sidecars; none is
|
||||
computed). The manifest's `library` lists every model the same way plus its download page;
|
||||
inspecting a backup returns the ones this computer lacks, which the frontend looks up and
|
||||
downloads with the model download routes (into the same folder and path). A cover is the user's when it is not byte-identical to `<model>.civitai_bak.*`,
|
||||
as in the scanner.
|
||||
|
||||
`POST /anomalous/backup/inspect` receives the .zip into the temp folder, refuses anything
|
||||
that is not this plugin's backup, too large, or has an unsafe member path, and reports per
|
||||
store how many files are new, the same or different, and how many models it finds here.
|
||||
`POST /anomalous/backup/apply` then writes only the chosen parts: new files are added, the
|
||||
same skipped, different ones replaced only with `replace` (the file here goes to the
|
||||
Recycle Bin first). Models are found by SHA-256, else by models folder, path and equal
|
||||
size. A scan file is written where the model has none (replaced only with `replace`);
|
||||
Civitai's cover where it has no cover at all, with its `civitai_bak` copy so it stays
|
||||
Civitai's. Their user layer is merged field by field (empty fields filled; `replace` takes the
|
||||
backup's, the old file to the Recycle Bin); the backup's cover replaces a Civitai cover
|
||||
(whose `civitai_bak` copy stays) but not another user cover unless `replace`. Settings
|
||||
are written only when chosen, keeping this computer's Civitai key. Export and import are
|
||||
activity entries (`backup_export`, `backup_import`).
|
||||
|
||||
## Model downloads
|
||||
|
||||
`api/model_download.py` serves Model Check's downloads. `POST /anomalous/download/lookup`
|
||||
asks `api/download_sources.py` where each missing model comes from: Civitai by the
|
||||
workflow's hash (the version's file with that SHA-256 or AutoV2), else a link the
|
||||
workflow carries — a Civitai link naming a model version (the file of that name, else
|
||||
its primary file), a Hugging Face or hf-mirror file link (size and SHA-256 from the
|
||||
site's headers), a GitHub release asset or raw file — else ComfyUI-Manager's model list
|
||||
(`api/manager_catalog.py`, the installed Manager's `model-list.json`, entries of the same
|
||||
file name fitting the node's folder type; an entry of no stated type fits only a node of unknown
|
||||
type; `list_site` says where its file is). The lookup also says whether the list is here
|
||||
(`manager_list`). With the workflow's hash only a list entry
|
||||
whose file has that SHA-256 counts (several of one name are each asked); without one,
|
||||
a single entry of that name is offered marked `by_name`, never in "Download all" or
|
||||
the MCP's download_missing_models. A link whose file has another SHA-256 than the
|
||||
workflow's hash is refused (`different_file`). A site that stops answering is skipped
|
||||
for the rest of the lookup (`down`), the others are still asked, so Civitai failing in
|
||||
mainland China does not stop Hugging Face through its mirror. It also returns each type's model folders with free
|
||||
space and subfolders (two levels).
|
||||
|
||||
`POST /anomalous/download/start` takes the source, type, root index and a relative path
|
||||
and refuses: a non-https link or one off Civitai, Hugging Face, hf-mirror or GitHub; a
|
||||
path leaving the root (`resolve_within`) or not ending in a model extension; a file that
|
||||
exists; not enough space. Jobs run one at a time in a worker thread into
|
||||
`<file>.part`; a new start for the same file continues it with a Range request. The
|
||||
Civitai key is sent to Civitai only, as an unredirected header, so it never reaches the
|
||||
file storage the download redirects to. An HTML answer (a login page) fails as
|
||||
`not_a_file`, 401/403 as `needs_key` or `forbidden`. The finished file must match the
|
||||
source's SHA-256 (else the workflow's hash) or it is removed as `hash_mismatch`; then it
|
||||
is renamed into place, ComfyUI's file-list caches are cleared and the activity log gets
|
||||
a `model_download` entry. A file from Hugging Face first gets its repository's model card
|
||||
(`api/hf_card.py`, `/api/models/<repo>` on the same host): the first example image (else
|
||||
the repository's first picture) as `<model>.preview.<ext>` when the model has no cover, and
|
||||
its base model, trigger words (`instance_prompt`) and example prompts as notes plus the
|
||||
repository as link in the user layer, only where those are empty. `GET /anomalous/download/status` lists the jobs of this run,
|
||||
`POST /anomalous/download/cancel` stops one (its `.part` goes). The default place is
|
||||
`user/anomalous/download_settings.json` (`{place: workflow | folder, folder, hf_mirror}`).
|
||||
With `hf_mirror` (the lookup's flag; the page sends the setting, or for a Chinese interface
|
||||
true while it is unset) Hugging Face files are looked up and fetched on hf-mirror.com, and
|
||||
without it on huggingface.co, whichever host the link names. The mirror sends networks
|
||||
outside China back to huggingface.co; the lookup follows such a redirect once for the size
|
||||
and SHA-256.
|
||||
|
||||
## Model import
|
||||
|
||||
`api/model_import.py` takes model files the user drops on the models page. A browser gives a
|
||||
dropped file's name, size, time and bytes but never its path, so `POST
|
||||
/anomalous/import/inspect` gets the name, size and time with the file's header bytes, and,
|
||||
asked from this computer (`local_guard.is_local`), looks for the same file (name, size, time
|
||||
within 2 s) in the Downloads and Desktop known folders and two subfolder levels below. A
|
||||
found file gets an in-memory token; the client never sends a path. What the file is comes
|
||||
from `model_kind.py`: the safetensors header's tensor names, shapes and kohya/modelspec
|
||||
metadata, or a GGUF's `general.architecture` and tensor names; `.ckpt/.pt/.pth/.bin` are never
|
||||
opened (the user picks the type). Types without a folder in this ComfyUI are not offered.
|
||||
Files of the same name already in that type's folders are listed. `POST
|
||||
/anomalous/import/identify` hashes a found file in a thread and asks Civitai by SHA-256 (base
|
||||
model, model and version name) and the scanned-model hash index for an identical copy.
|
||||
|
||||
`POST /anomalous/import/place` (local only) moves the found file into `{type, root, rel}`:
|
||||
a rename on the same drive, else a copy through `<dest>.part` checked by size, then the
|
||||
original is removed (or kept with `keep`). `PUT /anomalous/import/upload` (local only) streams the request
|
||||
body (not limited by ComfyUI's upload size) into `<dest>.part` and renames it when the size
|
||||
matches. Both check the destination like downloads (`resolve_within`, model extension, free
|
||||
space) and never replace a file: a taken name becomes `name (2).ext`. A failed copy or
|
||||
upload removes only its own `.part`. Each import clears the file-list caches and adds a
|
||||
`model_import` activity entry; the page then scans the new files. The destination the page
|
||||
proposes follows the download settings' folder (`{base}` = the base model's folder).
|
||||
|
||||
## Tidy check
|
||||
|
||||
`api/model_placement.py` serves the models page's Tidy view. `GET /anomalous/placement/check`
|
||||
walks every import type's folders once (a folder listed under two types counts for the first;
|
||||
real paths deduplicated) and reads each header through `model_kind.inspect_file`, kept in
|
||||
memory per path, size and time. A file whose header names another type than its folder, only
|
||||
when `sure`, is misplaced; it goes to the same relative path in a folder of that type, on the
|
||||
same drive when one exists. `works` marks a whole checkpoint in the diffusion models folder,
|
||||
which still loads there. Identical files are grouped by SHA-256: the scan's (metadata) hash, or
|
||||
one computed earlier this run; files of one size whose hash is unknown are only counted
|
||||
(`unchecked`) unless `deep=1` reads them. `POST /anomalous/placement/move` (local only) moves the model with
|
||||
`model_import.destination` and `move_into` (never over a file: " (2)") and then its sidecars
|
||||
(`SIDECAR_SUFFIXES`; none when another model in the folder shares the stem), reports those that
|
||||
could not follow (`left`), clears the file-list caches and logs `model_move`. Removing a copy is
|
||||
`/anomalous/delete_model` (Recycle Bin, sidecars along).
|
||||
|
||||
## Metadata and cache behavior
|
||||
|
||||
The output gallery keeps one ordered directory snapshot for at most ten seconds
|
||||
@@ -202,9 +551,19 @@ fall back to the original (a video without a readable frame has no poster).
|
||||
Sidecar handling is non-negotiable because it can destroy user metadata or
|
||||
media.
|
||||
|
||||
- `.civitai_bak.*` is a persistent restore source created from a real Civitai
|
||||
download. Setting a custom cover changes only `.preview.*`.
|
||||
- Cover reset first restores `.civitai_bak.*` to `.preview.*`. Otherwise it may
|
||||
- Where the side files are is `model_sidecars.py`'s to say: every path to `.info`,
|
||||
`.anomalous.json` and `.civitai_bak.*` goes through it (`find` to read, `target` to write,
|
||||
`existing` / `follow` to move or delete), since they may be beside the model or in the
|
||||
folder's `.amb` (Settings → Models, `SIDECAR_LAYOUT`). A file is written where it is
|
||||
already, so a folder moved half way never has two copies. Covers and other tools' files
|
||||
(`.civitai.info`, `.yaml`, `.txt`) are always beside the model. The folder tree leaves `.amb` out.
|
||||
- A cover is Civitai's when its SHA-256 is the one the scan noted in the `.info`
|
||||
(`anomalous_cover_sha256`) or it is a copy of a `.civitai_bak.*`. A scan saves Civitai's
|
||||
picture straight as the cover (fetched under a working name first) and notes it; it keeps a
|
||||
`.civitai_bak.*` only while the user's own cover shows. Setting a custom cover first moves
|
||||
Civitai's picture aside as `.civitai_bak.*` (the restore source), then changes `.preview.*`.
|
||||
- Cover reset first restores `.civitai_bak.*` to `.preview.*` (then notes it and drops the
|
||||
copy aside); a cover that is Civitai's already stays. Otherwise it may
|
||||
remove `.preview.*` only when a bare original cover can take over. If the
|
||||
preview is the only image, preserve it and return a visible warning. Reset
|
||||
never silently downloads from the network.
|
||||
@@ -219,8 +578,9 @@ media.
|
||||
sidecar suffixes. Cleanup must not delete a same-stem model with another
|
||||
extension. If such a sibling remains, ambiguous stem-keyed sidecars remain.
|
||||
- Rename/delete/reset uses centralized immutable suffix tuples and a constant
|
||||
number of exact-path checks. Do not walk, glob, index, hash, or call the
|
||||
network for these operations.
|
||||
number of exact-path checks (both places for the plugin's own files). Do not walk,
|
||||
glob, index or call the network for these operations; only covers (small) are hashed,
|
||||
to tell Civitai's picture from the user's.
|
||||
|
||||
Keep product wording precise: deletion cleans sidecars; rename migrates them.
|
||||
|
||||
@@ -234,5 +594,5 @@ Keep product wording precise: deletion cleans sidecars; rename migrates them.
|
||||
a one-field object.
|
||||
- Large input collections use a JSON `POST` body. Do not place them in a query
|
||||
string or pass them as command-line arguments on Windows.
|
||||
- The private local `tests/` directory is ignored and is not imported by runtime
|
||||
code or shipped in the installable plugin.
|
||||
- `tests/` holds the plugin's tests (how to run them: `docs/guides/testing.md`,
|
||||
`tools/run_tests.mjs`). Runtime code never imports it.
|
||||
|
||||
+169
-32
@@ -5,11 +5,44 @@ localization, media, or explicit canvas mutations.
|
||||
|
||||
## Bootstrap and module ownership
|
||||
|
||||
The verified AMB0/AMB1 workflow share-code Import / Export Center is available
|
||||
from the Workflows page's top bar (⇅) through `window.AMB_WorkflowShare.showUnifiedModal()`. Both directions
|
||||
are enabled by explicit product decision. It is independent of the paused Recipe
|
||||
package import/export. Image/workflow hash
|
||||
injection, host saving, and ordinary image downloads remain unchanged.
|
||||
`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,
|
||||
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). `web/modules/ui_ai_run_map.js` draws a run's `map` under its step
|
||||
(one line, the changes as chips, the boxes in a `<details>`, scaled to the column) and for a `run_map` stage block
|
||||
(the panel ctx's `stepOf`). `web/modules/mcp_task_tab.js` wraps each page action in `mcp_bridge.js`: with the task's
|
||||
mode "new", it clicks the task tab's button in `.workflow-tabs` (falling back to `Workspace.NextOpenedWorkflow`)
|
||||
and waits for `activeWorkflow` to be it. A missing or unactivatable recorded tab fails the action;
|
||||
it never substitutes the current workflow. Only a task without a recorded tab copies
|
||||
`app.graph.serialize()` into a new tab before its first change. Missing-model downloads follow
|
||||
the same task tab as reads and runs. Explicit `new_workflow` / `open_workflow` can replace the tab; the outcome
|
||||
reports `tab`. `ui_ai_task_canvas.js` is the header's mode switch and tab chip.
|
||||
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.
|
||||
Unended tasks with no active step await an update; silence never means completion.
|
||||
|
||||
`mcp_run.js` follows one native `app.queuePrompt` request by its host request ID and
|
||||
`promptQueueing`/`promptQueued` events. A temporary `api.queuePrompt` wrapper preserves the
|
||||
original receiver/arguments and collects only that batch's submission responses, stopping
|
||||
on its first failure or completion. Cleanup restores the API and removes both listeners on
|
||||
all exits. The batch receipt can return before the native queue finishes later manual work;
|
||||
the native promise remains error-handled. Already-busy or unsupported host queues are refused
|
||||
before calling the native method. Seed changes, widgets and serialization remain host-owned.
|
||||
|
||||
Share and import is the Workflows page's ⇅ (`ui_share.js`). Its share code
|
||||
(`share_code.js`, no DOM) is AMB2: the workflow's lean form — node id, type, widget values,
|
||||
position to 10 px, and only what differs from a fresh node of the type (`LiteGraph.createNode`
|
||||
serialized as the template; a node pack's node always keeps its ports, so its links survive on
|
||||
a computer still lacking the pack), links by slot, groups, the `anomalous_*` extras — deflated
|
||||
and spelled as Chinese characters (14 bits each, U+4E00..U+8DFF, a first byte marking the
|
||||
padding) or base64url. Encoding rebuilds the workflow from the lean form and compares
|
||||
(`sameWorkflow`); subgraphs, native reroutes or any difference make the code hold the whole
|
||||
workflow. AMB0/AMB1 codes still decode. The same dialog imports a pasted code or workflow JSON,
|
||||
a dropped or chosen workflow file or image (`app.handleFile`), or a backup .zip
|
||||
(`ui_backup.js`). It is independent of the paused Recipe package import/export.
|
||||
|
||||
ComfyUI loads JavaScript in the extension `WEB_DIRECTORY` as ES modules.
|
||||
`web/main.js` registers `Anomalous.ModelBrowser` and coordinates host hooks.
|
||||
@@ -39,7 +72,8 @@ Major UI panels live in `web/modules/ui_*.js`. Shared browser state remains on
|
||||
the `AnomalousBrowser` instance. Pure parsing, normalization, comparison, and
|
||||
transaction helpers remain in focused modules rather than acquiring DOM state.
|
||||
|
||||
`ui_sidebar.js` assembles the browser window and the model folder list; the left
|
||||
`ui_sidebar.js` assembles the browser window and places the model folder column
|
||||
(`ui_folder_list.js`, the current type's folders only); the left
|
||||
icon rail (`ui_shell_rail.js`), page navigation and the list column's open state
|
||||
(`ui_shell_nav.js`), the home page (`ui_home.js`) and the window frame
|
||||
(`ui_shell_frame.js`) are their own modules. Every page change goes through
|
||||
@@ -50,7 +84,7 @@ The Prompt Studio shortcut (its command and setting ids still say "materials") i
|
||||
native ComfyUI command/keybinding; a deferred window-key fallback invokes the same
|
||||
command path only when the host did not open the studio, so handled shortcuts are not executed twice.
|
||||
`ui_browser_navigation.js` owns shared panel hiding, recoverable detail cleanup,
|
||||
and workspace return. `ui_scan_page.js` owns the scan page and `scan_runner.js` scan launch and polling,
|
||||
and workspace return. `ui_scan_page.js` owns the scan page and `scan_runner.js` scan launch and polling (an online scan first goes through `scan_connection.js`: Civitai reachable, or the user picks scanning from the files),
|
||||
`ui_folder_manager.js` is the settings page's Model folders view (grouping mode,
|
||||
visibility and order, saved on each change). Public entry functions remain
|
||||
browser-instance methods so existing actions share current browser state.
|
||||
@@ -66,27 +100,65 @@ The main surfaces are:
|
||||
- Sidebar shell: navigation and persistent scan controls; focused child modules
|
||||
own the scan page, folder manager, and help dialog.
|
||||
- Grid and model detail: model browsing plus coordinated detail display in
|
||||
`ui_detail.js`, metadata editing in `ui_model_editor.js`, and advanced selection
|
||||
in `ui_model_selector.js`.
|
||||
- Gallery: generated outputs and the full-screen Image Detail Studio Workbench.
|
||||
`ui_detail.js` (its head with the actions in `model_detail_head.js`), metadata editing in
|
||||
`ui_model_editor.js`; `model_widget_path.js` puts a
|
||||
model path into a loader's widget. The scan page's model picker is `ui_scan_picker.js`, its settings card `ui_scan_settings.js`; opened afresh, the page shows the last counts at once (`?quick=1`) with "showing the last counts while they are updated", then the fresh ones; before any, a count slower than 400 ms shows "counting your models" instead of a blank page. "Found on Civitai" opens a list of those models with a box to find one by name (`/anomalous/scan_matched`, read when it opens).
|
||||
- Gallery: generated outputs (pictures and videos; All / Pictures / Videos beside the search
|
||||
and the hidden formats in Settings, `gallery_filter.js` + `ui_gallery_filter.js`) and the
|
||||
full-screen Image Detail Studio Workbench, for pictures only.
|
||||
`ui_gallery_card.js` builds each output's card; its star and the workbench's Keep open
|
||||
`ui_keep_menu.js`, which keeps the image as a Workflow Recipe, a combo or its prompts
|
||||
(`image_keep.js`; `GET /anomalous/kept_images` says what each image was kept as).
|
||||
(`image_keep.js`; `GET /anomalous/kept_images` says what each image was kept as). The
|
||||
gallery's ★ Kept switch (`ui_gallery_filter.js`, `gallery_filter.js`) lists only the kept
|
||||
images (`gallery_images?kept=1`, `kept_images.kept_keys`).
|
||||
`ui_gallery_detail.js` owns its singleton lifecycle and cache, `ui_image_stage.js`
|
||||
owns header/zoom/filmstrip interaction, and `ui_image_inspector.js` owns metadata tabs.
|
||||
- Workspace: Combos (搭配, formerly Prompt Notes) and Workflow Recipes. The combo list, workspace/persistence, editing, and canvas use are separated
|
||||
across `ui_combos.js`, `ui_notebooks.js`, `ui_notebook_editor.js`, and
|
||||
`notebook_canvas.js`; Put on canvas lays a combo out as a new wired group of nodes (Esc while it
|
||||
`notebook_canvas.js`; a default combo's `data.loraNode` ('core', 'rgthree', 'easy'; none is 'core')
|
||||
says which LoRA node it is laid out with (`lora_nodes.js`; one this ComfyUI lacks falls back to
|
||||
'core' and says so after placing), chosen under New or in its editor (`ui_lora_node_choice.js`,
|
||||
shown only when there is a choice). A Power Lora Loader in a node structure keeps its rows as
|
||||
the node's `rows` and each row's LoRA as a slot with `field: 'lora'`; its rows are made again
|
||||
(`addNewLoraWidget`) before the slots are filled. Put on canvas lays a combo out as a new wired group of nodes (Esc while it
|
||||
follows the pointer removes it; the activity log holds back while `owner.placingCombo` is set) and
|
||||
never changes nodes already on the canvas (values of an existing node are changed in
|
||||
Current node).
|
||||
Current node). A combo never changes a value either: what was saved is what goes down
|
||||
(AGENTS.md, 功能原则). A combo with a node structure (`combo_structure.js`, `ui_combo_structure.js`)
|
||||
keeps every picked node but subgraphs, and the links between them. Its slots are the model
|
||||
drop-downs (a drop-down of model files) and text boxes (a multiline text box by the node's
|
||||
definition); a node without slots goes back exactly as saved. A link from a node that was not
|
||||
picked cannot be kept: its input is stored in `structure.open`, and the save dialog, the
|
||||
editor and the card say it needs wiring. Slots are per box, so a node with
|
||||
several model drop-downs has several; a box fed by a link is none. Each slot's models folder
|
||||
is the one whose files hold all the drop-down's options. Putting it down needs every node
|
||||
type installed (missing ones and their packs are named first), restores the other boxes by
|
||||
name in their order, fills the slots by name, then links by port name and type; a slot or
|
||||
link that cannot be restored is reported after placing, never guessed. Values other than
|
||||
the slots cannot be edited: a combo puts down exactly what was saved. The canvas's node menu
|
||||
(`getNodeMenuItems`, browser_entry.js → `canvas_menu.js`) offers "Save as combo" for the picked nodes, or the
|
||||
right-clicked node when it is not among them, when any of them is not a subgraph.
|
||||
Nodes are kept in flow order (`chainOrder`, combo_slots.js) so slot numbers read along the
|
||||
chain; the editor groups slots into models, LoRAs and prompts in that order, renumbers names
|
||||
it made itself, and lists each model node's other saved values read-only (on a node with
|
||||
several models, by the number in the box name); nodes without slots follow under "put back as
|
||||
saved" with their saved values, read-only. Prompts get a read-only translation panel
|
||||
(translation_service.js); empty optional slots start unticked in the save dialog.
|
||||
- Prompts: the rail entry opens Prompt Studio (below); there is no Material Library page.
|
||||
Saved node values are listed, applied and deleted in Current node.
|
||||
- Current node (Node Assistant)/Model Check: selected-node actions, saved parameters, and
|
||||
missing-model recovery. `model_check.js` decides each workflow model's state and
|
||||
applies fixes; `ui_doctor.js` (the page) and `ui_doctor_banner.js` (the bar shown
|
||||
after a workflow opens, unless its "Don't show again" or Settings → Workflows turned the
|
||||
check on opening off) only render it and call it on a press.
|
||||
check on opening off) only render it and call it on a press. A missing model with a
|
||||
certain source gets Download (`ui_model_download.js`; `model_download.js` holds the
|
||||
logic): the dialog names, per file, the type's folder (a choice when there are several,
|
||||
remembered per type in `localStorage`) and the subfolder from Settings → Model downloads
|
||||
— where the workflow says (so the node needs no change) or a set folder, `{base}` there
|
||||
being the model's base model, matched to an existing folder in any spelling — and the
|
||||
file keeps the workflow's name. When a download ends, the model lists reload, nodes
|
||||
still naming that model get the file (one Ctrl+Z step each), and once nothing is
|
||||
downloading the new files are scanned in one targeted scan.
|
||||
`ui_node_assistant.js` owns the current-node panel and model history, `ui_node_model_picker.js`
|
||||
owns the native-widget model replacer, and `ui_node_parameters.js` with
|
||||
`node_parameter_sets.js` own the panel's one list of saved values for the node type;
|
||||
@@ -94,7 +166,7 @@ The main surfaces are:
|
||||
|
||||
`ui_dom.js` owns generic `text` and `jsonResponse` helpers.
|
||||
`material_inspector.js` holds the image workbench's metadata helpers and
|
||||
node-parameter rendering. See `material-library.md` for the material files' API and
|
||||
node-parameter rendering; `png_metadata.js` reads a PNG's workflow in the browser. See `material-library.md` for the material files' API and
|
||||
persistence contract.
|
||||
|
||||
## Entry modes and host integration
|
||||
@@ -104,12 +176,25 @@ button, and `Extensions -> Anomalous Model Browser` command. Entry presentations
|
||||
are mutually exclusive and reuse the same browser instance. The Extensions
|
||||
command remains available as a recovery path in every mode.
|
||||
|
||||
Native commands own the default `Shift + B` browser binding (Ctrl + Shift + M before v1.58.2: Chrome
|
||||
and Edge take it for switching profiles) and the
|
||||
`Ctrl + Shift + L` Prompt Studio binding. Shortcut customization delegates
|
||||
A native command owns the default `Shift + B` browser binding (Ctrl + Shift + M before v1.58.2: Chrome and Edge
|
||||
take it for switching profiles). Prompt Studio has
|
||||
no shortcut of its own (removed 2026-10-07): it opens from the rail, the 7 key, or by opening
|
||||
the browser with a text node selected. Shortcut customization delegates
|
||||
to ComfyUI's command/keybinding panel and recorder; the plugin does not install
|
||||
a parallel global keyboard listener or maintain a second shortcut preference.
|
||||
|
||||
Opening the browser from the trigger, the action-bar button, the menu or its shortcut
|
||||
(`show({ followSelection: true })`) follows the node selected on the canvas unless Settings
|
||||
→ Opening and window turns it off (`shell_open_rules.js`, a localStorage preference): a node
|
||||
with prompt boxes and no model drop-down opens Prompt Studio, which targets that node; any
|
||||
other node opens Current node; nothing selected reopens the page used last. Other callers
|
||||
(Prompt Studio closing, the doctor banner, MCP actions) open the browser as it was.
|
||||
|
||||
While the browser is visible, the plain keys 1–9 go to the rail's pages from the top
|
||||
(`ui_shell_nav.js`; ComfyUI binds no plain digits). They are ignored in text fields, while
|
||||
an input method is composing, and while a dialog is open; the rail's tooltips show each
|
||||
number. This is in-page navigation, not a second shortcut for opening anything.
|
||||
|
||||
Language changes replace the registered Interface setting descriptors with
|
||||
freshly translated copies. ComfyUI's reactive settings tree therefore updates
|
||||
the open category heading, labels, tooltips, custom controls, and combo options
|
||||
@@ -213,9 +298,14 @@ groups, and rolls back everything it created on failure. It never calls
|
||||
until they have a complete remapping path. Complete recipes use
|
||||
`loadGraphData` intentionally so ComfyUI owns new-workflow canvas creation.
|
||||
|
||||
`graph_splice.js` handles deliberate MODEL/CLIP insertion. It analyzes declared
|
||||
port types rather than slot indexes or display names. Ambiguous downstream
|
||||
fan-out is rejected until a user can choose a branch. Picker candidates come
|
||||
`graph_splice.js` decides where a LoRA loader goes (`planLoraInsertion`) and wires it
|
||||
(`spliceLora`), for Current node's insert buttons and the MCP `add_lora` alike. It reads
|
||||
declared port types, never slot indexes or display names. After a node, every link of its
|
||||
MODEL output moves behind the LoRA; before one, only that node's MODEL input. The CLIP
|
||||
line goes through only when the LoRA file trains the text encoder (`api/lora_info.py`
|
||||
reads its header): from the same node, a UNet workflow's one CLIP source, or, before a
|
||||
sampler, the source the prompts wired into it share. Otherwise a model-only LoRA loader
|
||||
is used. A failed connection removes the node and restores every link. Picker candidates come
|
||||
from the target widget's native combo values; metadata may filter or decorate
|
||||
that set but may not introduce foreign category values.
|
||||
|
||||
@@ -225,6 +315,22 @@ skips volatile values, and calls the host's widget hooks with the current
|
||||
four-argument contract. Serialized workflow records are never treated as live
|
||||
widget objects.
|
||||
|
||||
MCP canvas construction uses `canvas_wiring.js` to plan all connections against live sockets
|
||||
before mutation. Automatic pairs require a unique concrete type on both sides, or same-type
|
||||
sockets of the same name; wildcard, union and other ambiguous sockets require explicit selection.
|
||||
An occupied input the new node feeds is spliced when the node takes that type (its old source
|
||||
feeds the node). Insertion splices each type the node passes through, one output per type, and
|
||||
needs an explicit downstream selection when an output branches. Final topology is checked for
|
||||
cycles after planned replacements. `mcp_canvas_edit.js` brackets application in one native
|
||||
beforeChange/afterChange transaction. On failure it removes the added node and restores prior
|
||||
connections; callback failures during restoration are reported as incomplete rollback.
|
||||
Asynchronous node preparation verifies the active graph, workflow key and serialized content
|
||||
before writing. Successful receipts report links and output reachability, not execution success.
|
||||
The activity log's limited undo is separate from ComfyUI's native Ctrl+Z for topology changes.
|
||||
`mcp_canvas_brief.js` collapses unbranched paths within each flow, grouping IMAGE/LATENT into
|
||||
the picture spine so auxiliary VAE/MASK inputs do not split it, keeps positive/negative roles,
|
||||
walks each edge once (including malformed cycles), and bounds the resulting text.
|
||||
|
||||
## Optional capabilities
|
||||
|
||||
`web/hash_resolver.js` keeps the local hash cache and may hook a compatible graph
|
||||
@@ -240,10 +346,14 @@ browsing, editing, or already stored data.
|
||||
|
||||
The studio edits prompts where they are: the prompt boxes of the prompt node last
|
||||
selected on the canvas, or, with no such node, a positive and a negative draft
|
||||
(`owner.promptStudioDraft`, kept while the page stays open). It opens from the rail's
|
||||
Prompts entry, the Prompt Studio shortcut and Current node's "Edit in Prompt Studio";
|
||||
the browser folds away while it is open and comes back when it closes (not when
|
||||
another studio replaces it). There is no assembly board, block draft or plan loading.
|
||||
(`owner.promptStudioDraft`, kept while the page stays open). It is the browser's Prompts
|
||||
page (`owner.promptPanel`, `ui_prompt_composer.js`): the rail's Prompts entry, the 7 key,
|
||||
opening the browser with a text node selected, Current node's "Edit in Prompt Studio" and
|
||||
a kept prompt all go there. "Beside the canvas" moves it into a drawer docked by the canvas
|
||||
(side and width remembered) with the browser folded away; "Back to the window" returns it to
|
||||
the page and Close or Esc closes the drawer. Only one studio is shown at a time; the page's
|
||||
studio stops when another page is shown or the browser closes (its 400 ms poll with it) and
|
||||
is made again when the page is shown. There is no assembly board, block draft or plan loading.
|
||||
|
||||
The target (`ui_prompt_target.js`) is polled every 400 ms. Selecting another node with
|
||||
prompt boxes switches to it; empty canvas or a node without boxes keeps the current
|
||||
@@ -266,8 +376,12 @@ language picked in the top bar (one of `GLOSS_LANGUAGES`, none by default, remem
|
||||
local storage with the view; an older "Chinese on" setting reads as Simplified Chinese).
|
||||
They are asked for in batches, kept for the session per language, and a failed lookup
|
||||
leaves that box's meanings in that language blank instead of retrying. The backend
|
||||
skips DeepL for a target it does not take and lets MyMemory detect a source that is
|
||||
neither English nor Chinese.
|
||||
(`api/translation_routes.py`) asks DeepL first when Settings → Translation has a key
|
||||
(`DEEPL_API_KEY` in config.json, sent in the Authorization header, never in a backup),
|
||||
then Google's free endpoint, then MyMemory; when DeepL failed, the answer carries
|
||||
`deepl_error` (bad_key, quota, busy or the error) beside the other provider's translation,
|
||||
which Settings shows after a key is saved. It skips DeepL for a target it does not take and
|
||||
lets MyMemory detect a source that is neither English nor Chinese.
|
||||
|
||||
The cards are three built-in ones and the saved prompts (prompt-kind material files,
|
||||
`prompt_material_source.js`). The saved list is asked for on open, every 30 seconds
|
||||
@@ -275,7 +389,10 @@ while the page is visible (scheduled after the previous request), on focus/visib
|
||||
and after a save; a newer request cancels the older one and a failed one keeps the
|
||||
current cards. List summaries are not prompt bodies: a prompt's text is read through
|
||||
`material_prompt_data.js` once per filename and timestamp, since a saved prompt's text
|
||||
never changes. A card's role decides its box: a positive card never goes into a
|
||||
never changes. A clicked card opens as a reference (`ui_prompt_reference.js`, read only,
|
||||
one at a time) between the cards and the boxes: a click on one of its tags adds that tag,
|
||||
"Add all" the whole card (as the preview's Add does); the ticks follow the box's text on the
|
||||
same 400 ms poll. A card's role decides its box: a positive card never goes into a
|
||||
negative box or the reverse; boxes of unknown or both roles take either. A saved
|
||||
card's preview renames it (`update_material`, tags kept) or deletes it
|
||||
(`delete_material`, to the Recycle Bin). A card, or a box's handle with the box's text
|
||||
@@ -313,12 +430,19 @@ provider-specific validation belongs to the backend translation route.
|
||||
`styles.css` is the single external entry and ordered import manifest for current
|
||||
visual values. Its `web/styles/00-*.css` through `10-*.css` children preserve the
|
||||
original cascade order; every child import carries the same cache version so an
|
||||
entry-cache hit cannot leave stale child rules. `tests/css_bundle_order.mjs`
|
||||
guards the unique ordered list and byte-for-byte reconstructed bundle. Shared `--amb-*`
|
||||
entry-cache hit cannot leave stale child rules. Shared `--amb-*`
|
||||
tokens express surfaces, text, borders and control shapes; theme overrides must
|
||||
be scoped to `.theme-abyssal-scarlet` rather than changing unrelated surfaces.
|
||||
be scoped to `.theme-abyssal-scarlet` or `html.amb-theme-light` rather than changing
|
||||
unrelated surfaces.
|
||||
|
||||
Colours come from the tokens in `00-foundation-models.css`, which both themes define:
|
||||
Themes (`interface_settings.js`, Settings → Appearance → Theme and the ComfyUI setting
|
||||
`Anomalous.ModelBrowser.Theme`): follow ComfyUI (default), dark, light, or Abyssal Scarlet.
|
||||
Following ComfyUI reads `dark-theme` on `<html>`, which ComfyUI sets for a dark palette and
|
||||
removes for a light one, and repaints when it changes. The light theme is the class
|
||||
`amb-theme-light` on `<html>`; Abyssal Scarlet keeps `theme-abyssal-scarlet`. An older
|
||||
Abyssal Scarlet switch (`anomalous_theme_abyssal_scarlet`) reads as the Abyssal choice.
|
||||
|
||||
Colours come from the tokens in `00-foundation-models.css`, which every theme defines:
|
||||
surfaces (`--amb-bg-page`, `-panel`, `-card`, `-card-hover`, `-input`), text
|
||||
(`--amb-text-main`, `-soft`, `-muted`, `-dim`), borders (`--amb-border`, `-strong`,
|
||||
`-hover`), the primary button (`--amb-btn-primary-*`) and one accent, `--amb-link`, for
|
||||
@@ -327,6 +451,19 @@ transparent)`. CSS injected from JS and inline styles use the same tokens. Liter
|
||||
colours stay only where the colour is the meaning: status (red, green, amber), prompt
|
||||
roles, voice emotions, canvas node colours and translucent shadows or overlays.
|
||||
|
||||
Tokens added for the light theme keep the dark themes exactly as they were:
|
||||
`rgba(var(--amb-ink-rgb), a)` for tints over the page (white on dark, black on light),
|
||||
`--amb-danger/-warn/-ok` and their `-text`/`-soft` shades for status text, `--amb-bg-tooltip`,
|
||||
`--amb-bg-float`, `--amb-bg-dialog`, `--amb-bg-sunken`, `--amb-scrim` and `--amb-note-*`.
|
||||
Older rules and inline styles that wrote a dark colour now write
|
||||
`var(--amb-lt-<role>, <that colour>)`: the `--amb-lt-*` tokens exist only in the light theme,
|
||||
so every other theme falls back to the original colour. Use them when a dark literal cannot
|
||||
become a shared token. Inside the plugin's windows ComfyUI's own palette variables
|
||||
(`--bg-color`, `--fg-color`, `--input-text` …) are mapped to the plugin's tokens whenever the
|
||||
two themes differ. Surfaces over images (card labels, cover pills) stay dark, and the image
|
||||
viewer (`.anomalous-workbench-overlay`, `.anomalous-gallery-viewer`) gets the dark tokens back
|
||||
in the light theme.
|
||||
|
||||
Studio drawer rules keep the source deck at the screen edge and the assembly
|
||||
track next to the canvas. Common geometry is shared between dock directions;
|
||||
direction-specific rules set column order and separators. The removed embedded
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# 踩过的坑(Lessons)
|
||||
|
||||
反复出现的坑和严重故障。每条写清:现象、根因、怎么发现的、以后怎么做。
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-26 · v1.57.2:一条 `:has()` 样式让拖动画布掉帧(严重)
|
||||
|
||||
**现象**:B 站用户反馈装了插件后拖动工作流 10% low 只有二十几帧;和 ComfyUI Prompt Assistant 一起装时,大工作流只剩个位数帧甚至卡死。插件界面关着也一样。
|
||||
|
||||
**根因**:`web/styles/01-shell-tools.css`(b6acd09,2026-09-19,进了 v1.57 / v1.57.1)
|
||||
|
||||
```css
|
||||
body:has(#anomalous-toolbox-modal[style*="display: flex"]) #anomalous-sidebar-tooltip-bubble, …
|
||||
```
|
||||
|
||||
`:has()` 里放属性选择器(这里是 `[style*=…]`),浏览器在页面上任何元素的该属性变化时,都要重新判断 `body:has(...)`。ComfyUI 平移、缩放画布时每一帧都在改每个 DOM 控件(文本框)的 `style`,于是每帧都要付出和页面元素数量成正比的样式计算。Prompt Assistant 给每个文本框加一组按钮,页面元素多十几倍,开销跟着放大。两个插件本身没有冲突。
|
||||
|
||||
**实测**(真 ComfyUI 前端 1.53.6 + Chromium,300 节点工作流平移 120 帧,样式重算合计):
|
||||
|
||||
| 情况 | 修复前 | 修复后 |
|
||||
| --- | --- | --- |
|
||||
| 不装插件 | 60 ms | — |
|
||||
| 只装 AMB | 690 ms | 63 ms |
|
||||
| AMB + Prompt Assistant | 2300 ms(约 19 ms/帧,超过 60 帧的一整帧) | 86 ms |
|
||||
| 只装 Prompt Assistant | 80 ms | — |
|
||||
|
||||
逐条删规则二分:只删掉这 7 条 `:has()` 就回到基线,其中罪魁是带 `[style*=…]` 的那条;`button:has(.x)` 等其他 `:has` 测不出开销。
|
||||
|
||||
**修复**:删掉这条规则。它是多余的:工具箱和设置窗口打开前都会调用 `hideTooltipImmediately()`,`showSharedTooltip` 在窗口开着时也会拒绝显示。
|
||||
|
||||
**以后怎么做**
|
||||
- 不要用 `:has()` 或 `[style*="display: …"]` 去判断界面开关状态。要表示“开着”,由 JS 加减一个 class(作用在自己的容器上),或者直接在 JS 里处理。
|
||||
- 全局生效的昂贵选择器(`body:has`、`html:has`、`*`、未限定作用域的 `button`、`textarea`)会作用在 ComfyUI 的每一帧上。新增前先问:它会不会在画布拖动时被反复判断?
|
||||
- 性能问题要实测,别只看代码猜:
|
||||
- 装一个真的 ComfyUI(`--cpu` 即可),用 Playwright 在空白处拖动画布;
|
||||
- 用 CDP 的 `Performance.getMetrics` 比较 `RecalcStyleDuration`;
|
||||
- 再在页面里删掉可疑规则做二分(遍历 `document.styleSheets`,对插件自己的样式表 `deleteRule`)。
|
||||
- 没有显卡时总帧时间不准,但样式计算的倍数可信。
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-26 · 修复只在本机 main,发布版没带上
|
||||
|
||||
**现象**:素材库“选中节点时卡片错位”(应用按钮重复、图标重复成 ⚡ ⚡、标签日期挤在一起)的修复 01fc925、9e761d0 早在 9 月 24 日就做了,但只进了 `feature/audio-engines`(通过“Merge main … into feature”)。GitHub 上的 `main` 并没有,发布版 v1.57.1 的用户一直带着这个问题。
|
||||
|
||||
**根因**:修复提交在本机 main 上,只把 main 合进了 feature,本机 main 从未推送。
|
||||
|
||||
**以后怎么做**
|
||||
- 发布或回答“修了没有”之前,用 `git branch -r --contains <提交>` 确认修复在远端的哪个分支,别只看本机。
|
||||
- 本机 main 有未推送提交时,先推送或说明,再做合并。
|
||||
- 这次已把两个提交 cherry-pick 进远端 main(e1ac84f、4e8603b),随 v1.57.2 发布。
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-26 · 发布热修复的检查项
|
||||
|
||||
- 改了 CSS 或 JS,要同步改缓存参数:`web/styles.css` 里对应的 `@import …?v=`、`browser_entry.js` 的 `styles.css?v=`、`main.js` 引用 `browser_entry.js` 的 `?v=`。不然用户刷新后浏览器可能还用旧文件。feature 分支已去掉部分 `?v=`,合并时以各分支现状为准。
|
||||
- `CHANGELOG.md` 是 CRLF、LF 混合换行。用脚本编辑时按原字节读写(`newline=''`),否则整份文件都会显示成改动。
|
||||
- 云端会话能推分支,推不了标签,GitHub 工具也不能创建 Release。发布要在网页上完成:Draft a new release → 填新标签 → Target 选 `main` 并核对提交号 → 粘贴说明 → Publish。插件的版本面板只列正式 Release,用 ComfyUI Manager 更新的用户则直接拉 main 最新提交。
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-27 · 拆文件漏了 import,保存工作流在发布版里整个坏掉
|
||||
|
||||
**现象**:v1.57 到 v1.57.2,工作流页“保存当前工作流”只要工作流里有模型加载器就提示“保存工作流配方失败”。B 站用户反馈“就没成功过”。素材库走的是另一条路,所以能存。
|
||||
|
||||
**根因**:`refactor(recipes): separate schema images and storage`(6ad975c)把函数搬进 `recipe_images.py`,但它调用的 `_resolve_exact_model_reference` 和 `hashlib` 留在了旧文件里。同一轮拆分还让 `media_routes.py` 少了 `time`、`tempfile`:卡片缩略图出错后被 `except Exception` 吞掉,静默退回原图。另外 `model_metadata.py` 少了 `_resolve_paths_to_model_info_sync`。Python 只在那一行真正执行时才报 NameError,导入和其他测试都通过。唯一的保存测试关掉了模型预览快照,正好绕开了出错的那一行。
|
||||
|
||||
**以后怎么做**
|
||||
- 拆模块后跑 `tests/test_backend_names.py`:后端文件读到却从没绑定的名字,它都会列出来。
|
||||
- 行为测试要走默认路径。`recipe_payload` 默认关着快照,新测试 `test_saving_with_preview_snapshots_captures_the_model_cover` 专门走默认的“开着快照”。
|
||||
- 真实按钮至少点一次:在隔离的 ComfyUI 里点“保存当前工作流”,一眼就能看出问题。
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-12 · 页面往下滚后,点“返回”点到了下面的模型(第二次)
|
||||
|
||||
**现象**:从画布节点存的搭配(节点搭配),页面往下滚一点后,点“‹ 全部搭配”打开的是被滚到标题下面的模型封面。普通搭配和搭配列表没事。
|
||||
|
||||
**根因**:和 2026-10-09 修过的是同一个 Chrome 行为:滚动框里有 `overflow: hidden` 的元素(这里是模型封面的圆角框)或 `content-visibility: auto` 时,滚出可见区域的部分在点击判断里仍算“在那里”,盖住了上面的标题按钮。上次只给当时有问题的两个滚动框加了 `clip-path: inset(0)`,之后新做的节点搭配页面又放进了带圆角封面的行。
|
||||
|
||||
**怎么发现的**:在隔离的 ComfyUI 里把节点存成搭配,把编辑区分 12 步滚到底,每一步用 `document.elementFromPoint` 取标题里每个按钮中心点的元素,看是不是按钮自己;滚到 140px 起就变成了封面 `IMG`。
|
||||
|
||||
**以后怎么做**
|
||||
- 标题、标签栏下面的滚动框一律加 `clip-path: inset(0)`(它对点击判断也生效)。`overflow` 本身裁不住这种情况。
|
||||
- 新页面或新内容放进滚动框后,用上面的办法扫一遍:每个滚动框分步滚到底,检查每个可见按钮都能拿到自己的点击。
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-08 · ComfyUI-Manager 审核 Anomalous_TTS:三类安全口子
|
||||
|
||||
**现象**:Anomalous_TTS 申请收录进 ComfyUI-Manager(Comfy-Org/ComfyUI-Manager#3342),维护者两轮审核退回,先后指出:
|
||||
|
||||
1. **下载来的东西被当代码执行**:中文多音字模型从个人 ModelScope 账号的 `master` 下载,不核对指纹,解压后执行包里的 `config.py`;英文词典读 pickle。而网页请求还能改“去哪个文件夹找这些文件”,等于网页能决定运行什么代码。
|
||||
2. **网页请求决定删改哪里**:`POST /anomalous_tts/storage` 从请求里取新的存放位置,跨盘移动后对旧位置 `rmtree`。只检查“是不是本机请求”不够:本机网页也不该决定在哪里建、删文件夹。
|
||||
3. **按请求里的路径读任意文件**:导入预览、导入检查会返回请求点名的任何文件内容。
|
||||
|
||||
**根因**:这些接口最初是为了方便(在界面里选文件夹、试听本机音频、一键挪角色),没有把“网页请求是不可信的输入”当成前提;“只接受本机请求”被当成了足够的保护。
|
||||
|
||||
**怎么改的**(接口版本 13):配置文件当数据解析,词典从文本建,不读写 pickle,测试拦住 `pickle.load` / `exec_module` / `eval(`;下载锁定版本并先核对 SHA-256 再解压;存放位置、角色文件夹、底模来源只从用户手写的设置文件读;导入只收上传的文件,没有接口再接受本机路径。
|
||||
|
||||
**以后怎么做**
|
||||
- 两个仓库的规范都写了安全底线(本仓库 AGENTS.md 第 5 节“安全底线”,Anomalous_TTS 的 ARCHITECTURE.md“不能破的规矩”)。新接口按它写,审查时逐条对照。
|
||||
- 本仓库的现状(2026-10-09 统计):55 个会写文件的接口,只有 MCP、模型导入、整理三处检查了本机请求;删除模型、改模型信息、开始下载、导入备份等在 `--listen` 时局域网里的其他设备也能调用。没有执行下载内容、pickle、eval 的代码。2026-10-12 的 v1.58.1 一次补齐:`api/local_guard.py` 一个中间件把关 `/anomalous/` 下所有会改东西的请求,只接受本机(见 backend.md“Requests from other devices”)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-13 · v1.58.0–v1.58.2:同一种 `:has()` 又掉帧(第二次)
|
||||
|
||||
**现象**:#22(Lindemellis):216 个节点的工作流,开着 Prompt Assistant 时一次画布重绘 465–770 ms,拖动只有 2–3 帧。只删掉一条规则就回到 53 ms。
|
||||
|
||||
**根因**:图标栏(46facb9,2026-09-29,随 v1.58.0 发布)在 `web/styles/15-shell-rail.css` 里又写了
|
||||
|
||||
```css
|
||||
#anomalous-container.anomalous-has-rail:has(> .anomalous-nb-modal[style*="display: flex"]) #anomalous-sidebar-wrapper
|
||||
```
|
||||
|
||||
和上面 v1.57.2 那条是同一种写法:`:has()` 里按 `style` 属性匹配。v1.57.2 修好三天后就写回来了,因为经验只写在这份文档里,没有东西拦着;当时的性能实测也只跑了 main 和音频分支,没跑图标栏所在的分支。
|
||||
|
||||
**修复**:工作区开没开由 JS 在容器上加减 `anomalous-workspace-open`,CSS 只看这个 class(#22 的修复)。
|
||||
|
||||
**以后怎么做**
|
||||
- `node tools/check_structure.mjs` 现在会拒绝 `:has()` 里出现 `[style…]`,报出文件和行号。
|
||||
- 同一个坑第二次出现,就把它做成检查,不再只补一段经验。
|
||||
|
||||
## 2026-11-05 · 偶尔打开 ComfyUI,AMB 整个不见了
|
||||
|
||||
**现象**:没有悬浮按钮,没有窗口,控制台里是几十条 `Cannot access 'shared' before initialization`;刷新一下又好了。大约十几次加载碰到一次。
|
||||
|
||||
**原因**:ComfyUI 把 `web/` 下每个 .js 都当成扩展,同时各自导入一遍,谁先跑完全看网络。AMB 的模块有一个很大的循环导入,`ui_ai_workbench.js` 加载时就要写 `ui_ai_results.js` 里的 `const shared`。从 `main.js` 进来顺序是对的;碰巧先从 `ui_ai_results.js` 或 `ui_ai_step_body.js` 进来,`shared` 还没初始化,这个模块就报错,而且错误会被缓存,整个 AMB 跟着起不来。
|
||||
|
||||
**做法**:`shared` 搬进不导入任何东西的 `ui_ai_shared.js`。`tests/module_cycles.mjs` 查“加载时就读同一个循环里的 const”,这种写法以后过不了测试。
|
||||
|
||||
**以后**:会在加载时用到的值,放在循环外面的模块里。别只从 `main.js` 想加载顺序。要逐个模块验证,在浏览器里给每个模块开一个 iframe 单独 import。
|
||||
@@ -80,6 +80,11 @@ The route family is:
|
||||
- `POST /anomalous/update_material`
|
||||
- `POST /anomalous/delete_material`
|
||||
|
||||
`materials/by_node_type` (Current node's saved values) also says where each record's values
|
||||
came from: `origin` (the source type, `generated_image` or `canvas_node`), `source_image` (the
|
||||
kept picture's `filename` and `subfolder`, or null) and `preview` (the saved preview asset's id,
|
||||
for `material_asset`, or "").
|
||||
|
||||
`material_full?include_workflow=0` returns metadata and scoped node blocks (with
|
||||
`workflow_hashes` restricted to them) and no complete workflow; `include_workflow=1`
|
||||
returns the original workflow of a complete snapshot. Selected-node records never
|
||||
@@ -111,10 +116,15 @@ override, which travels into the saved record but never writes back to a Recipe.
|
||||
|
||||
The gallery's star and the image workbench's Keep (`ui_keep_menu.js`) keep an output PNG
|
||||
as one of three things, each recording the image as its source: the whole workflow as a
|
||||
Workflow Recipe (`recipe_save.js`, `source_image`), its main model, LoRAs and positive
|
||||
prompt as a combo (`image_keep.js`; models are looked up among this computer's files with
|
||||
Workflow Recipe (`recipe_save.js`, `source_image`), the nodes that make its sampler's model
|
||||
and prompts as a node-structure combo (`image_keep_nodes.js`, `sampler_inputs.js`: loaders,
|
||||
LoRA chains, CLIP skip, model patches and both prompt nodes, every value as it was; a combo
|
||||
never changes a value) — or, for a workflow with no sampler, its main model, LoRAs and
|
||||
positive prompt as a combo (`image_keep.js`; models are looked up among this computer's files with
|
||||
`/anomalous/resolve_paths_to_previews`, missing ones are left out; the notebook file keeps
|
||||
`source_image`), or its positive and negative prompts as a `prompt_plan` material
|
||||
`source_image`, which the Combos card and the editor's title show; the name is the first
|
||||
LoRA's (else the main model's) · the prompt's first two tags that are neither quality words
|
||||
nor ones nearly every picture has, and the toast lets the user rename it at once), or its positive and negative prompts as a `prompt_plan` material
|
||||
(`/anomalous/save_prompt_plan` stores `source.image`; the same plan saved again answers 409
|
||||
with the existing one). The image's workflow is laid on a detached graph and read with
|
||||
`extractRecipeMetadata`, so prompts follow the wiring as in a recipe. `GET
|
||||
@@ -123,6 +133,35 @@ the star is filled when any exists, and a kept row opens it or removes it: what
|
||||
the Recycle Bin through its own delete route (`delete_recipe`, `delete_notebook`,
|
||||
`delete_material`) after a confirmation, and the star follows.
|
||||
|
||||
## Comparing parameters
|
||||
|
||||
`param_compare.js` reads two sides (`readParams`: each node's named widget values, mode, input
|
||||
wiring and prompt-box role from `prompt_boxes.js`) and compares them (`compareParams`): nodes
|
||||
pair by id and type, then leftover nodes by type when each side has exactly one of it; values
|
||||
equal by their text form are counted, not listed; seeds (`isVolatileWidget`) are flagged.
|
||||
`textDifference` shows a long text as the tags only one side has (each once), or as the
|
||||
differing span with 40 characters around it; the window gives each side its own line and colour,
|
||||
since neither side is older than the other. `ui_param_compare.js` is the window: a side is an output image (its
|
||||
workflow from `inspect_image_material`, laid on a detached graph) or the live canvas. With the
|
||||
canvas on one side, ticked image values are written through `applyNodeMaterialValues` (seeds,
|
||||
missing choices and vanished nodes skipped and counted), all as one Ctrl+Z step, with Undo in
|
||||
the toast. Nothing is stored; the gallery's pick (`owner.comparePick`) lasts until it is used
|
||||
or cancelled.
|
||||
|
||||
An image's workflow is always laid on its graph through `detached_graph.js`, which gives the
|
||||
copy a fresh id: ComfyUI's frontend keeps widget values by root graph id, node and name, and a
|
||||
copy under the open canvas's id (any workflow opened from that image) would share and overwrite
|
||||
the canvas's values.
|
||||
|
||||
Key parameters (`key_params.js`) are starred in the window by node type and widget name, so a
|
||||
star holds in every workflow; they live in the hidden ComfyUI setting
|
||||
`Anomalous.ModelBrowser.KeyParameters` (at most 40) and show first, equal values included.
|
||||
A side can also be a saved workflow (`{ workflow, label }`): a recipe's parameter set beside
|
||||
the recipe (`ui_param_set_keys.js`), whose card in the Parameters tab lists the set's key values
|
||||
and how many values differ. `GET /anomalous/parameters/signatures` gives each recipe's sets by
|
||||
their value signature (files over the save limit skipped, at most 5,000 read); a recipe card counts
|
||||
those unlike the recipe's own, since saving a recipe also keeps a set equal to it.
|
||||
|
||||
## Prompt plans
|
||||
|
||||
`prompt_plan` has no synthetic workflow. `POST save_prompt_plan` accepts a name, tags,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user