Compare commits
807
Commits
v1.57-beta
..
main
@@ -0,0 +1,24 @@
|
||||
name: 报告问题 / Bug report
|
||||
description: 哪里坏了、报错了,或者和你想的不一样 / Something broke or did not do what you expected
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: 遇到了什么问题? / What went wrong?
|
||||
description: 一两句话就行,中文英文都可以。 / A sentence or two is enough, in any language.
|
||||
placeholder: 比如:在模型页点扫描,进度停在 30% 就不动了。 / e.g. The scan on the Models page stops at 30%.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: more
|
||||
attributes:
|
||||
label: 截图或报错(可选) / Screenshots or errors (optional)
|
||||
description: 截图可以直接拖进来。 / Drop screenshots here.
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息(可选) / Environment (optional)
|
||||
description: 在插件里点 设置 → 帮助与反馈 → 复制诊断信息,粘贴到这里。 / In the plugin, Settings → Help and feedback → Copy diagnostics, then paste here.
|
||||
render: text
|
||||
@@ -0,0 +1,2 @@
|
||||
# A blank issue stays possible: the plugin's feedback window opens one with what was written.
|
||||
blank_issues_enabled: true
|
||||
@@ -0,0 +1,13 @@
|
||||
name: 提建议 / Suggestion
|
||||
description: 想要的功能、哪里不顺手、希望往什么方向发展 / A feature you want, something awkward, where the plugin should go
|
||||
title: "[Idea] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: 你希望有什么? / What would you like?
|
||||
description: 随便写,一两句话就行,中文英文都可以。 / Anything goes; a sentence or two is enough, in any language.
|
||||
placeholder: 比如:希望素材库能按标签筛选。 / e.g. Filter the material library by tag.
|
||||
validations:
|
||||
required: true
|
||||
+15
-6
@@ -1,5 +1,6 @@
|
||||
# Local configuration and credentials
|
||||
config.json
|
||||
allow_remote_changes
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -56,22 +57,30 @@ api/notebooks/
|
||||
# AI Dev Scripts & Temporary text dumps
|
||||
scripts/
|
||||
doctor_code.txt
|
||||
# Only the Markdown of these doc folders is tracked: screenshots, logs and raw test
|
||||
# data stay local (put them under .agents/).
|
||||
docs/*
|
||||
!docs/architecture/
|
||||
docs/architecture/*
|
||||
!docs/architecture/*.md
|
||||
!docs/decisions/
|
||||
docs/decisions/*
|
||||
!docs/decisions/*.md
|
||||
!docs/audits/
|
||||
!docs/audits/*.md
|
||||
!docs/guides/
|
||||
docs/guides/*
|
||||
!docs/guides/*.md
|
||||
# The README's pictures (small WebP from the isolated demo ComfyUI).
|
||||
!docs/images/
|
||||
docs/images/*
|
||||
!docs/images/*.webp
|
||||
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
|
||||
|
||||
@@ -3,11 +3,33 @@
|
||||
适用于本插件的所有修改,包括 Gemini、Codex 和人工维护。目标是让 UX 持续改善,同时让代码职责、状态来源和验证方式保持清楚。
|
||||
用户明确的当前要求优先;确需偏离本规范时,采用最小例外,在交付说明中写明原因、影响和验证。常规实现选择自行处理,不因本规范增加无必要的确认步骤。
|
||||
|
||||
## 0. 每次提交的硬性要求
|
||||
|
||||
以下几条每次提交都要做到,没有“这次改动小”的例外;其余各节是怎么做好的指引。
|
||||
|
||||
1. **结构和文档同步。** 按下表更新文档,与代码放在同一次提交里。新增、删除、改名、拆分源码文件一律算改了模块职责:职责清单同时是文件目录,每个源码文件都要有一行。
|
||||
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. **测试产物不进仓库。** 测试代码放 `tests/`;截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略,只留本机),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
|
||||
6. **交付说明**写清:改了什么和为什么、验证结果和没验证到的地方、遗留问题、提交号;用了规范例外就写原因和影响。
|
||||
|
||||
| 改了…… | 更新 |
|
||||
| --- | --- |
|
||||
| 新增 / 删除 / 改名 / 拆分源码文件,模块职责 | `ARCHITECTURE.md` 职责清单,或最窄的 `docs/architecture/` 专题 |
|
||||
| 数据流、接口、持久化格式、安全边界、跨系统规则 | 对应的 `docs/architecture/` 专题;系统图或阅读地图变了才改 `ARCHITECTURE.md` 入口 |
|
||||
| 用户能感觉到的行为 | `CHANGELOG.md`;使用方法变了同步 `README.md` 或 `docs/guides/` |
|
||||
| 长期有效的产品或架构取舍 | `docs/decisions/` |
|
||||
| 反复出现的坑、严重故障 | `docs/architecture/lessons.md` |
|
||||
| 开发规范本身 | 只改本文件 |
|
||||
|
||||
普通修复、样式微调不写进架构文档;没有变化的边界不写“本次未改变”。
|
||||
|
||||
## 1. 修改前先定位责任
|
||||
|
||||
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 请求默认允许调整布局、交互和文案,不代表允许改变文件存储位置、接口协议、工作流语义、模型识别依据或用户数据。
|
||||
@@ -37,7 +59,12 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
|
||||
- 通用文件不能成为杂物箱。不要为了省一个文件,把新领域逻辑塞进 `utils`、素材详情渲染器或浏览器主对象。
|
||||
- 不引入没有当前调用方的管理器、适配层、配置项、事件总线或依赖。一次性的简单逻辑可以留在其责任模块内。
|
||||
|
||||
**规模审查阈值:** 普通逻辑文件超过约 600 行,或单函数超过约 100 行时,新增功能前必须检查能否提取独立职责。继续扩展时,在交付说明中解释为什么仍属于同一职责。已有超大模块的小修复不要求顺手重写全文件;语言目录、数据表和样式表不按这个行数阈值拆分。不能通过空壳包装、压缩代码或碎片化文件绕过审查。
|
||||
**规模阈值(只减不增):** 普通逻辑文件以 600 行为线,单函数以约 100 行为线。
|
||||
- 新文件不超过 600 行;写不下说明有两种以上职责,先拆。
|
||||
- 已经超过 600 行的老文件,每次改动后只能变短或持平,不能变长。要在里面加功能,先把一块独立职责搬出去,腾出行数再加。
|
||||
- 不为了达标专门大拆;改到哪个文件,就顺手从它身上拆走一块,让超线文件随日常修改逐步变少。
|
||||
- 语言目录、数据表和样式表不按这个行数计算。不能通过空壳包装、压缩代码、删注释或碎片化文件绕过。
|
||||
- `tools/check_structure.mjs` 按上次提交比对,新文件超线或老文件变长都会失败。
|
||||
|
||||
## 3. 状态、关闭与异步
|
||||
|
||||
@@ -72,9 +99,36 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
|
||||
- 路径与文件名在后端使用现有边界校验;不能只检查 `..`。前端校验不能替代后端校验。
|
||||
- 修改模型、封面、附属文件或导入包前先完整校验和暂存。失败时保留旧数据,避免先删除旧文件再尝试保存新文件。
|
||||
- 修改多文件发布流程要覆盖中途失败和冲突。单个 JSON 的原子写入不能冒充整组文件的事务。
|
||||
- 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
|
||||
- UI 适配不能偷偷迁移用户数据、修改全局配置或改变现有路由与存储格式。确需改协议时同步调用方、兼容策略和测试。
|
||||
|
||||
### 安全底线
|
||||
|
||||
ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalous_TTS 时逐条指出过)。没有“先这样、以后再补”的例外:
|
||||
|
||||
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
|
||||
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
|
||||
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。模型文件本身可以是链接(云服务器上模型文件夹里全是指向平台模型盘的链接):文件夹部分照样用 `resolve_within`,文件用 `model_file_within` / `model_reference_within` 按名字取,不跟着链接走出去,封面、信息和改动都在链接旁边。
|
||||
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机;ComfyUI 只监听局域网地址时,本机用这个地址打开也算本机)。用 `--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. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
|
||||
|
||||
### 资源占用
|
||||
|
||||
ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应该几乎不占资源:
|
||||
|
||||
1. **用户动了才做事。** 不在后台轮询、定时扫描或预加载;确需定时的(扫描进度、跟随选中的节点)只在对应界面打开期间进行,关掉就停。
|
||||
2. **耗时的活放后台。** 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
|
||||
3. **数据跟着界面走。** 界面打开时才读,关掉就释放;跨次使用的缓存要小、有上限,写清楚何时失效。长列表只渲染看得到的或前几十条。
|
||||
4. **图片用缩略图。** 网格和列表用缓存的缩略图,原图只在看大图或拖到画布时读取;浏览器里不常驻大量原图。
|
||||
|
||||
### 功能原则
|
||||
|
||||
几个功能对用户的承诺。改这些功能时不能破坏;确需例外,先问维护者:
|
||||
|
||||
1. **模型检查(模型医生)从不自己改工作流。** 检查只读;只有用户按了“换上”或“下载”才动节点,而且只换成同一个文件(SHA-256 指纹一致)。只是同名、没法核对的候选,标明“按文件名”由用户决定。下载的来源(C 站按指纹、工作流带的链接、ComfyUI-Manager 模型清单)在界面上写明。完整的判断规则(三档可信度、基础组件只认指纹、下载来源与核对)见 `docs/guides/model-check-rules.md`(英文版 `model-check-rules.en.md`,README 的按钮指向这两份),改模型检查前先读、改了规则两份一起改。
|
||||
2. **搭配只记住,从不修改参数数值。** 存的时候是什么,放回画布就是什么:强度、CLIP 层数、提示词等一律原样,不用节点默认值顶替,也不替用户“调”。想要别的数值,在画布上调好再存一个新搭配。放上画布只新放一组节点,从不改画布上已有的节点。(理由见 `docs/decisions/README.md` AD-021;模型检查的见 AD-005、AD-006、AD-020。)
|
||||
|
||||
## 6. 清理旧实现
|
||||
|
||||
- 替换一条路径时搜索它的导入、导出、调用、字段、事件和选择器;确认无用后一起移除。
|
||||
@@ -90,11 +144,10 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
|
||||
| 修改范围 | 至少验证 |
|
||||
| --- | --- |
|
||||
| 导入、导出、模块拆分 | 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 差异 |
|
||||
@@ -103,15 +156,13 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
|
||||
- 修复故障时,优先补充能复现原故障的行为测试。不能只通过源码字符串“存在某段代码”来证明修复。
|
||||
- UX 明确替换旧交互时,更新测试到新的用户流程,并保留原本要保护的行为;不要仅删除失败断言或重新塞回废弃功能。
|
||||
- 新增应随仓库交付的回归测试要检查 `.gitignore`,确认确实进入版本控制。
|
||||
- 历史本地 Python 测试可能不在 Git 中。使用合适环境运行相关测试,例如 `python -m unittest discover -s tests -p 'test_*.py'`,核对实际发现的数量;零项测试不算通过。
|
||||
- Python 测试要在 ComfyUI 的 Python 环境里跑(`node tools/run_tests.mjs` 会自己找),核对实际发现的数量;零项测试不算通过。
|
||||
- 无法运行真实宿主、网络服务或完整测试时,准确说明缺口;不要将模拟画布、语法检查或已有通过数量称为本轮宿主验证。
|
||||
|
||||
## 8. 文档与交付
|
||||
|
||||
- 架构文档只记录职责、数据流、契约和关键约束。颜色、像素值及审美描述以 CSS 为准,不每次 UX 更新都往架构入口追加长篇设计说明。
|
||||
- 改动架构时更新最窄的相关专题。修改规范时以本文件为唯一正文,`GEMINI.md` 只保留阅读入口,避免两套要求漂移。
|
||||
- 该更新哪份文档见第 0 节的表。规范以本文件为唯一正文,避免几份要求各自漂移。
|
||||
- 完成前审查差异、死代码、临时文件和空白错误;保留现有文件编码及换行风格。
|
||||
- 验证后按仓库约定形成一个连贯的本地提交,只包含本任务文件;没有明确授权不推送 GitHub。
|
||||
- 交付说明包含:改了什么与原因、验证结果与限制、剩余问题、本地提交。仅在触发规模阈值或规则例外时额外解释取舍。
|
||||
|
||||
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?测试验证了实际行为吗?
|
||||
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?新接口会动文件或联网吗,只接受本机请求了吗?接口收了路径吗,读用户文件有上限吗?插件闲着时还占着 CPU、内存或显存吗?测试验证了实际行为吗?
|
||||
|
||||
+1125
-102
File diff suppressed because it is too large
Load Diff
+1208
-173
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
# Gemini 工作入口
|
||||
|
||||
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构。
|
||||
|
||||
随后阅读 [ARCHITECTURE.md](ARCHITECTURE.md),按其中的阅读地图加载与当前任务有关的专题。
|
||||
|
||||
不要只凭当前任务描述直接追加实现。先确认现有责任模块、状态来源、关闭清理和样式规则;完成后按规范验证并报告结果。
|
||||
|
||||
如果当前工具没有加载本文件,任务发起者应明确要求 Gemini 阅读 `GEMINI.md` 和 `AGENTS.md`。文件的存在不代表工具已实际读取,更不代表检查已自动执行。
|
||||
|
||||
前端设计任务另读 [前端设计与协作方案](docs/plans/frontend-design-brief.md);具体功能计划按该文链接进入,不使用历史视觉建议作为当前任务单。
|
||||
@@ -2,20 +2,27 @@
|
||||
|
||||
# 🚀 Anomalous Model Browser
|
||||
|
||||
**A Comprehensive Creative Workspace & Model Manager for ComfyUI**
|
||||
*零依赖 C 站元数据抓取 · 智能模型医生 · 可视化节点助手 · 工作流配方与参数笔记*
|
||||
**A model browser, workflow studio and AI workbench for ComfyUI**
|
||||
*模型库 · 出图图库 · 工作流与预设 · 画布存档 · 搭配 · 提示词工坊 · 模型检查 · AI 工作台 · 角色配音 · 操作记录*
|
||||
|
||||
<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/>
|
||||
|
||||
[**English**](#-english) | [**中文说明**](#-中文)
|
||||
[**English**](#english) | [**中文说明**](#中文)
|
||||
|
||||
<sub>Installed but can't find it? <a href="#where-en">Here is where it is</a> · 装好了找不到入口?<a href="#where-zh">看这里</a></sub>
|
||||
|
||||
<br/>
|
||||
|
||||
<img src="docs/images/en-models.webp" width="900" alt="Model library with Civitai covers, base model badges and multi-version cards">
|
||||
|
||||
</div>
|
||||
|
||||
@@ -23,89 +30,157 @@
|
||||
|
||||
<h2 id="english">🇬🇧 English</h2>
|
||||
|
||||
> **Anomalous Model Browser** is a comprehensive creative workspace and model management suite for ComfyUI. It integrates hash-based metadata indexing, workflow healing (Model Doctor), canvas visual model swapping (Node Assistant), a unified Material Library with polymorphic canvas drag-and-drop, and immutable Workflow Recipes.
|
||||
> **Anomalous Model Browser** takes care of the chores around ComfyUI in one window: browse your models by their Civitai covers and trigger words, recover workflows from past outputs, keep tuned workflows with visual presets, snapshot canvas saves to compare fairly and jump back to, reuse model + LoRA combos and tag-based prompts, find missing models again (for sure when the workflow carries this plugin's fingerprints), let an AI app run and refine workflows on a copy tab while you steer each step, and—with the optional companion pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)—give your characters a voice. Everything the plugin changes is recorded in **Activity** with **Undo**.
|
||||
|
||||
### 🎬 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/)
|
||||
### 🧭 The idea
|
||||
|
||||
### 🌟 Core Capabilities
|
||||
**Not another WebUI, not a replacement for ComfyUI: just ComfyUI made easier to use.**
|
||||
|
||||
| 🚀 Primary View | 💡 Description |
|
||||
| :--- | :--- |
|
||||
| **Model Browser (📦 Models)** | Browse models categorized by folder (checkpoints, unet, loras). Displays Civitai metadata, triggers, architectures, and high-res covers. Supports visual swapping, renaming, and notes. |
|
||||
| **History Gallery (🖼️ Gallery)** | Native viewer for the ComfyUI `output` directory. Features multi-dimensional search (prompt, model, seed, hash) with removable filter blocks, mouse-wheel zoom, and drag-to-canvas workflow reconstruction. |
|
||||
| **Workflow Recipes (🪡 Workflows)** | Save full workflows or partial subgraphs. Displays live model readiness status (e.g. all models ready). Open recipes in a new canvas or drag cards directly onto the canvas to load. |
|
||||
| **Toolbox & Bottom Dock (🧰)** | Lower-left navigation bar featuring the Toolbox as the first icon. Directly hosts 4 frequent tools (Scan, Doctor, Assistant, Material Library) plus Settings. Clicking the Toolbox opens a panel containing 5 secondary utilities (Workflow Transfer, Prompt Studio, Prompt Translator, Model Source Hub, Prompt Notes). |
|
||||
| **Workflow Repair (Model Doctor 🩺)** | Automatically detects broken nodes in external workflows and reconnects them to local models by comparing SHA-256 hashes and file sizes. |
|
||||
| **Node Assistant & Presets (🤖)** | Canvas-linked sidebar. Select any node on the canvas to inspect covers/triggers, visually swap models, or apply recipe parameters transactionally. |
|
||||
| **Material Library & Polymorphic Drag (✨)** | Save curated bundles from output PNGs (workflow, provenance, node parameters). Drop onto canvas to open workflow, drop prompts to auto-create native CLIP nodes, or drop onto existing nodes to inject parameters. |
|
||||
- **Fewer needless chores.** Telling cryptic model filenames apart, fixing red missing-model nodes, digging out how an old picture was made, rewiring the same Checkpoint + LoRAs every session: the plugin handles these.
|
||||
- **Still your ComfyUI.** The canvas and nodes are ComfyUI's own. The plugin adds **zero custom nodes**: what it places on the canvas are standard ComfyUI nodes (or packs you already installed), so the PNGs and workflows you save open cleanly even on a computer without this plugin.
|
||||
- **A grounded way to use AI with ComfyUI.** You say what you want in plain words; **Anomalous** opens your workflows and wires tested node blocks on a **copy tab**; **your own ComfyUI and local models** draw the pictures. You see every step, pick which preview continues, and undo any canvas change in one click.
|
||||
|
||||
### 📦 Quick Installation
|
||||
### ✨ Highlights in v1.63
|
||||
|
||||
1. Open your terminal in the ComfyUI `custom_nodes` directory:
|
||||
- **AI Workbench — staged creation instead of blind node-guessing**: run fast first-pass base previews and pick your favorite before spending time on a second-pass upscale, inspect results with a before/after slider, snap in tested **Ways** & model-aware **Skills**, and keep your open tab untouched via **AI edits a copy**.
|
||||
- **Workflows — structure flow diagram, orange preset diffs & Official examples**: every workflow card now draws its stages, models and LoRAs as a compact structure diagram; pick a preset and what it changes lights up in **orange**, or start immediately on a fresh install with **Official examples**.
|
||||
- **Canvas Saves & locked-seed comparison**: save states of a workflow as `1 · 2 · 3` on a bar under the canvas, batch-run them with **Locked** or **Random** seeds, compare exact node/value diffs beside each save's pictures, and click a save number to restore both the graph and that save's output node previews.
|
||||
- **Grouped multi-version model cards & numbered multi-LoRA insertion**: files from the same Civitai model page fold into one card (slide out versions under an orange bar, pin your default, or download other Civitai versions); **Insert LoRA** chains several LoRAs in numbered `1 · 2 · 3` order in one `Ctrl+Z` step; the docked sidebar edge drags smoothly from `480px` wide.
|
||||
|
||||
### 🎬 Videos and discussion
|
||||
* 📺 **Bilibili** (Chinese): [Feature demo](https://www.bilibili.com/video/BV1boHx61Enc/) · [Character voices demo](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
|
||||
* 💬 **Reddit**: [Discussion on r/comfyui](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
|
||||
|
||||
---
|
||||
|
||||
### ✨ AI Workbench: You direct in plain words, ComfyUI draws with your models
|
||||
|
||||
Most "AI + image" tools either hide ComfyUI behind a cloud generator or ask an LLM to guess raw node connections from scratch—often breaking your open workflow. **AI Workbench** takes a practical middle route built around your own ComfyUI library:
|
||||
|
||||
- **Your open workflow stays safe.** By default (**AI edits a copy**), the first canvas action duplicates your workflow into a dedicated task tab. Your own tab is never touched, and every step on the timeline has **Undo** and **Find on canvas**.
|
||||
- **Tested building blocks instead of guesswork.** Rather than inventing fragile graphs, the AI opens **your saved workflows**, **any gallery picture's embedded workflow**, or **Official examples**, and extends them with **Ways** tested here (two-pass Tile upscale, mid-way model switch, crop-and-stitch or LanPaint area repaint, hand repair, background swap, pose/depth control…) plus on-demand **Skills** (such as matching pose complexity to your model's parameter scale, or planning multi-stage passes). You can also click **Use it this time** on any Way to lock the AI to your preferred method.
|
||||
- **Steer before committing GPU time.** On multi-pass workflows, the AI can run a few fast first-pass **base previews** first; **Pick a base** opens on the right-hand **Stage**, you click **Take this one**, and the second pass sharpens that exact image without redrawing the base. Finished runs appear with a **before/after comparison slider**, **Turned out well / Not quite** feedback buttons, and a `✎` brush tool to mark regions for local repainting.
|
||||
- **Clear boundaries (`Gates`).** You decide what the AI may **Go ahead** with, must **Ask me first** about, or may **Never** do: changing the canvas, using the GPU, downloading models, scanning models online, moving model files. Two more settings cover nodes it places by itself: any custom node pack or only those of the Ways, and whether an untried route of its own waits for your OK (with an optional per-task `🔒` privacy switch when you want prompt text and pictures kept local as `[P1]` / `[img1]` tokens instead of sent to the AI). Works locally with Claude, Cursor, Antigravity, Cherry Studio, VS Code and other MCP-capable apps—copy one sentence from **Connect an AI** to set it up, or see [docs/guides/mcp.md](docs/guides/mcp.md).
|
||||
|
||||
<p align="center"><img src="docs/images/en-ai-workbench.webp" width="840" alt="AI Workbench: pick one of three fast first-pass base previews, then inspect the 2nd-pass upscale with a before/after slider and choose the next step"></p>
|
||||
|
||||
---
|
||||
|
||||
### 💡 Everyday ComfyUI chores it solves (with or without AI)
|
||||
|
||||
**1. "Which LoRA was `download (3).safetensors` again?"**
|
||||
Press **Scan** once: every model is matched on Civitai by its SHA-256 file hash and gets its real title, cover, trigger words and base model badge. Multiple versions of the same Civitai model fold neatly into one card—click its numbered side tab to slide all versions out under an orange bar, pin the version you use most, or check and download other versions from Civitai.
|
||||
|
||||
<p align="center"><img src="docs/images/en-scan.webp" width="560" alt="Scan results matching cryptic local filenames to their real Civitai titles"></p>
|
||||
|
||||
**2. Someone else's workflow opens with red missing-model nodes.**
|
||||
**Model Check** looks as soon as a workflow opens, without changing it. How far it gets depends on what the workflow carries:
|
||||
- **With fingerprints** (its author has this plugin too, which records each model's SHA-256 in the workflows and pictures it saves, unless turned off): the same file on your computer under another name or in another folder is found for certain and goes back in with one press of **Put in**; a model you really lack is looked up on Civitai by its fingerprint and checked against it after downloading.
|
||||
- **Without fingerprints** (its author does not have this plugin): nothing proves which file it was, so only names are left. A file of the same name in another folder is offered for you to confirm, row by row; VAEs, text encoders and other parts that often share a name are never guessed by name.
|
||||
- **Downloads** also use the links a workflow carries (ComfyUI's own templates have them) and ComfyUI-Manager's model list. A file that only has the same name is marked **Download by name** and comes only when you press it.
|
||||
|
||||
<p align="center"><a href="docs/guides/model-check-rules.en.md"><img src="https://img.shields.io/badge/📖_How_Model_Check_decides-Full_rules-blueviolet?style=for-the-badge" alt="How Model Check decides: the full rules"></a></p>
|
||||
|
||||
<p align="center"><img src="docs/images/en-model-check.webp" width="820" alt="Model Check replacing three renamed/moved missing models in one click"></p>
|
||||
|
||||
**3. Keep tuned workflows, see their stages at a glance, and switch presets.**
|
||||
**Official examples** (text-to-image, two-pass upscale, img2img, inpaint) open ready to run on a model you already own. On any saved workflow card, a compact **structure flow diagram** maps out models, LoRAs and control bars across each sampling stage (`① Base picture` → `② Enlarge and redraw`). Click a **preset tab** and every stage or LoRA it changes turns **orange**; click any box in the flow to see `default → preset` parameter diffs right underneath, apply it to the canvas, or export a `.zip` recipe package / short `⇅` share code.
|
||||
|
||||
<p align="center"><img src="docs/images/en-workflows.webp" width="840" alt="Workflows page: Official examples, structure flow diagram, orange preset highlights and node diff"></p>
|
||||
|
||||
**4. Try another idea without losing this one — Canvas Saves.**
|
||||
Right-click the canvas → **Save this state** snapshots the whole workflow onto a compact bar under the canvas; tweak anything and press `+` to save again. Run every save in batch with **Random** or **Locked** seeds for a fair side-by-side test. **Saves** (at the top of Workflows) lists what each save changed beside the pictures it made; click `1`, `2` or `3` on the bottom bar to jump back on the same tab—bringing that save's output node previews back too (`Ctrl+Z` returns; **Keep as a workflow** turns a favorite into a workflow card).
|
||||
|
||||
<p align="center"><img src="docs/images/en-saves.webp" width="840" alt="Canvas Saves: side-by-side diffs with locked-seed renders, and switching saves 1, 2, 3 on the bottom bar"></p>
|
||||
|
||||
**5. "How did I make this picture?"**
|
||||
Search your `output` folder by prompt words, checkpoint, LoRA, seed or model hash (even hundred-megapixel upscales stay smooth via background thumbnails), then drag the picture straight onto the canvas to bring its full workflow back.
|
||||
|
||||
<p align="center"><img src="docs/images/en-gallery.webp" width="820" alt="Gallery search by tag chip, then dragging an output image onto the canvas to restore its workflow"></p>
|
||||
|
||||
**6. Stop rewiring the same Checkpoint, LoRAs and prompt.**
|
||||
Select those nodes on the canvas, right-click → **AMB** → **Save as combo**, and keep them as a card beside the canvas. Press **Put on canvas** (or drag the card) to lay out the wired node group wherever you click without altering existing nodes. Need several LoRAs on an existing loader? **Current node → Insert LoRA** lets you click multiple LoRA cards in `1 · 2 · 3` order and wires the whole chain (or an rgthree / Easy-Use LoRA stack) in one `Ctrl+Z` step.
|
||||
|
||||
<p align="center"><img src="docs/images/en-combo.webp" width="820" alt="Placing a saved Checkpoint + LoRA + Prompt combo onto the canvas as a wired group"></p>
|
||||
|
||||
**7. Edit long prompts as clickable tags — Prompt Studio.**
|
||||
Dock **Prompt Studio** beside the canvas, select a prompt node, and edit its positive and negative text live as tag pills: click `+` / `−` to adjust weights, double-click to edit, drag to reorder, type in Chinese, Japanese or other languages to insert English tags automatically, and drag saved prompt cards straight onto canvas prompt boxes.
|
||||
|
||||
<p align="center"><img src="docs/images/en-prompt-studio.webp" width="820" alt="Prompt Studio docked beside the canvas, weighting tags live on a selected CLIP Text Encode node"></p>
|
||||
|
||||
**8. Characters that talk.**
|
||||
With the optional companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS): manage GPT-SoVITS characters with a reference clip per emotion, fix pronunciations, and generate dialogue lines from a script page.
|
||||
|
||||
<p align="center"><img src="docs/images/en-voices.webp" width="760" alt="Character voices page with emotion reference clips"></p>
|
||||
|
||||
**Changed your mind?**
|
||||
Every change made by Anomalous or an AI task is logged in **Activity** with before → after values, **Find on canvas** and one-click **Undo**.
|
||||
|
||||
<p align="center"><img src="docs/images/en-activity.webp" width="760" alt="Activity log showing expanded node changes with Undo and Find on canvas buttons"></p>
|
||||
|
||||
**Light on your computer**: the plugin itself loads no AI models and takes **zero VRAM** away from image generation; grids use cached thumbnails rather than full originals, and browser image memory is released 90 seconds after closing the window. Voice models in `Anomalous_TTS` yield VRAM automatically whenever ComfyUI generates images, or completely on **Unload models**.
|
||||
|
||||
### 📦 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. Restart ComfyUI. *(Alternatively, install via **ComfyUI Manager** by searching for `Anomalous Model Browser`)*
|
||||
2. <a id="where-en"></a>**Where to find it**: after the restart a small cube button sits at the top left of the canvas (ringed below). Click it, or press **Shift + B**. On **Home**, **Scan model folders** fetches covers and trigger words, and **Tour the interface** walks through each area of the window.
|
||||
|
||||
Open the browser via **Ctrl + Shift + M**, the floating canvas orb (**📦**), or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** lets you customize the shortcut, configure the UI language, and select an entry mode (floating button, action-bar button, or Extensions menu only).
|
||||
<p align="center"><img src="docs/images/en-entry.webp" width="640" alt="The cube button at the canvas's top left, and ComfyUI's menu C, Extensions, Anomalous Model Browser, Open"></p>
|
||||
|
||||
<br/>
|
||||
Can't see the cube? It may have been dragged away, or another entry chosen: ComfyUI's **C** menu → **Extensions → Anomalous Model Browser** has **Open Anomalous Model Browser** and **Reset floating entry position**. To make it easier to spot, go to **ComfyUI Settings → Anomalous Model Browser → Interface**: **Floating entry style** → **Labeled pill** adds the words "Model Browser", **Floating entry size** → **Large**, or **Browser entry mode** → **Top action bar** puts it beside the Run button.
|
||||
3. Optional: for character voices, install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) (`Anomalous TTS` in ComfyUI Manager). Nothing else depends on it.
|
||||
|
||||
Scanning reaches Civitai by file hash (no extra Python dependencies). Without internet access, choose **Offline** scan: base models are read directly from file headers, and a later online scan fills in covers and trigger words (see **Network and backups** below).
|
||||
|
||||
<details>
|
||||
<summary><b>📖 Click to Expand: Step-by-Step Operating Guide</b></summary>
|
||||
<summary><b>📖 Every page and tool, in detail</b></summary>
|
||||
|
||||
<br/>
|
||||
Every page has an icon on the left rail; when docked beside the canvas, drag the window's right edge (`480px` minimum, double-click to reset to `660px`) to resize.
|
||||
|
||||
#### 1. Top Navigation Views (三大顶栏视图)
|
||||
The header provides 3 primary workspaces:
|
||||
* **Models (📦)**: Main model catalog. The left panel shows folder trees (`checkpoints`, `unet`, `loras`), while the center grid displays model cards with previews, trigger tags, and metadata.
|
||||
* **Gallery (🖼️)**: Historical output browser. The top search bar filters outputs by prompt, model name, seed, filename, or model hash; each search keyword forms an independent block with a `×` button for instant removal. Dragging an image onto the canvas reconstructs its embedded workflow.
|
||||
* **Workflows (🪡)**: Workflow Recipe studio. Filter by `[All]`, `[Full Workflows]`, or `[Subgraphs]`. Click `Save Current Workflow` to save the active canvas, or drag any recipe card directly onto the canvas to load it.
|
||||
| Page | What you do there |
|
||||
| :--- | :--- |
|
||||
| **🏠 Home** | Task cards for common jobs, first-run steps (**Scan model folders**, **Tour the interface**), recent AI tasks and latest activity. |
|
||||
| **🕘 Activity** | Everything Anomalous changed, grouped by day: canvas edits (before → after node values, added/removed nodes, opened workflows, links to AI tasks) and file operations (models, covers, workflows, combos, prompts, images, audio). **Find on canvas** jumps straight to the node; canvas changes offer **Undo** while still current. Your manual canvas edits are not listed. |
|
||||
| **📦 Models** | Browse models with Civitai covers, trigger words and base model chips. Switch types at the top (Checkpoint, LoRA, VAE…) or narrow to a subfolder on the left; **+** drops a loader node onto the canvas. Files sharing a Civitai model ID fold into one card with a numbered side arrow (slide out under an orange bar, pin a default version, or use **Open all versions**); model details also fetch **All versions on Civitai** for SHA-256-checked downloads. Includes **Tidy** (spot misplaced or duplicate files) and **Sources**. |
|
||||
| **🖼️ Gallery** | Your ComfyUI `output` folder (including background-rendered previews for ultra-large upscales). Search by prompt, model, LoRA, seed, filename or model hash; inspect full generation parameters, compare two images, drag onto the canvas to restore the workflow, or click **☆** to keep as a workflow, combo or saved prompt. |
|
||||
| **✨ AI workbench** | Task-by-task workspace for MCP AI apps: live step timeline with **Undo**, copy-tab controls (**AI edits a copy** / **Show the copy**), **Stage** panels (first-pass base picks, before/after comparison slider, choices, `✎` brush repaint markup, **Turned out well / Not quite**), **Node map**, **Decisions**, **Materials**, **Results**, **Ways** (with **Use it this time**) & **Skills**, and **Gates** (including optional per-task `🔒` privacy mode). Follow-up tasks indent cleanly under their parent task. |
|
||||
| **🪡 Workflows** | **Official examples** and **Saves** at the top, followed by saved workflow cards. Details show a compact **structure flow diagram**, sticky **preset tabs** (highlighting changed stages in orange, with inline node diffs and **+ New preset**), **Hand to AI**, version comparison/restore, `.zip` **recipe packages**, and `⇅` share codes / drag-and-drop import. |
|
||||
| **🧩 Combos** | Keep a main model, several LoRAs (with exact strengths) and prompts together as one card. **Put on canvas** (or dragging the card) places them as a newly wired node group without touching existing canvas nodes. |
|
||||
| **✍️ Prompts** | Prompt Studio—full page or docked **Beside the canvas**. Edits selected prompt nodes live as tags: click to weight, double-click to edit, drag to reorder, type in Chinese/Japanese/etc. to translate into English tags, display per-tag translations underneath, or drag prompt cards onto positive (green) / negative (red) boxes. Works as a scratchpad when no prompt node is selected. |
|
||||
| **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS): import GPT-SoVITS characters, assign reference clips per emotion, tune pronunciations, write multi-line scripts under Voice-over, and browse generated clips in the audio gallery. |
|
||||
|
||||
#### 2. Bottom Navigation & Toolbox Catalog (🧰 第一个图标:实用工具箱)
|
||||
* **Dock Layout**: The lower-left navigation bar hosts 6 permanent buttons in a fixed layout:
|
||||
1. **Toolbox (🧰)**: First anchor on the left. Opens the Toolbox panel for secondary tools.
|
||||
2. **Scan Wizard (🔄)**: Direct shortcut to launch model library scanning.
|
||||
3. **Model Doctor (🩺)**: Direct shortcut to diagnose and repair missing workflow nodes.
|
||||
4. **Node Assistant (🤖)**: Direct shortcut to inspect canvas node models and inject parameter presets.
|
||||
5. **Material Library (✨)**: Direct shortcut to manage curated bundles and drag assets to the canvas.
|
||||
6. **Settings (⚙️)**: Fixed anchor on the far right for global preferences.
|
||||
* **Toolbox Catalog**: Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel containing 5 secondary utilities:
|
||||
1. **Workflow Transfer Center (⇄ 导入导出)**: Lossless AMB format workflow share code import and export. Allows importing workflows from text codes or exporting the current canvas with embedded hashes.
|
||||
2. **Prompt Studio (🎛️ 提示词工坊)**: Modular prompt mixer deck. Extract prompt cards from history or materials, reorder segments, and assemble positive/negative prompts.
|
||||
3. **Prompt Translator (🌐 翻译助手)**: Built-in bilingual prompt translation between Chinese and English without leaving the interface.
|
||||
4. **Model Source Hub (🔗 模型来源)**: Dual-scope (workflow and library) download URL inspector. Navigates to Civitai, HuggingFace, Liblib, or ModelScope, and generates non-intrusive canvas Note nodes.
|
||||
5. **Prompt Notes (📑 提示词笔记)**: Lightweight notebook for drafting, editing, and managing reusable prompt snippets saved locally in the user directory (`workflows/anomalous_notebooks`).
|
||||
Tools at the bottom of the rail (ordered by frequency of use):
|
||||
|
||||
#### 3. Scan Wizard (🔄 扫描向导)
|
||||
* **Location**: Direct shortcut on the bottom dock (2nd icon).
|
||||
* **Operation**: Scan configured model directories to compute file hashes, download Civitai covers, tags, and architectures, and build the local offline database.
|
||||
* **Current node** (formerly Node Assistant): inspects the selected canvas node's model, swaps models from visual cards, inserts one or **multiple numbered LoRAs** at once (as standard chained nodes or into an rgthree Power Lora Loader / Easy-Use stack), and compares/applies saved values from workflows, combos, gallery pictures or node presets (`⇄ Compare` checks that node alone).
|
||||
* **Model Check** (formerly Model Doctor): checks opened workflows read-only; offers a one-click banner when renamed or moved local files match the workflow's SHA-256 fingerprints (written by this plugin: **Settings → Record model fingerprints in workflows**, on by default); without fingerprints, offers a file of the same name for you to confirm. Downloads come from Civitai by fingerprint, the workflow's own links or ComfyUI-Manager's model list. [The full rules](docs/guides/model-check-rules.en.md).
|
||||
* **Scan**: hashes model files locally, reads headers (including offline base-model & architecture detection checked against 300+ model layouts), and fetches Civitai covers, names and trigger words.
|
||||
* **Settings**: language (switches live across all panels, Stage and Saves bar), interface scale, theme, thumbnails, video covers, model download mirrors, backups, and **Report a problem** (pre-filled with version & GPU info).
|
||||
|
||||
#### 4. Model Doctor (🩺 模型医生)
|
||||
* **Location**: Direct shortcut on the bottom dock (3rd icon).
|
||||
* **Operation**: When loading an external workflow with missing red nodes, Model Doctor inspects embedded provenance hashes and file sizes to reconnect local matches automatically. The "View Hash" button opens a side-by-side SHA256 comparison modal.
|
||||
**Connecting AI apps (MCP)**: Claude, Cursor, Antigravity, Cherry Studio, VS Code and other MCP clients connect locally via `http://127.0.0.1:8188/anomalous/mcp` (localhost only). Full setup and tool reference in [docs/guides/mcp.md](docs/guides/mcp.md).
|
||||
|
||||
#### 5. Node Assistant & Parameter Presets (🤖 节点助手)
|
||||
* **Location**: Direct shortcut on the bottom dock (4th icon).
|
||||
* **Actions**: Select a model node on the canvas to view high-res previews and trigger words, or swap models visually. Switch to **Parameter Presets** to inject recipe-saved node values (e.g., KSampler steps, CFG, denoise) into matching canvas nodes with safe seed preservation.
|
||||
</details>
|
||||
|
||||
#### 6. Material Library & Polymorphic Canvas Drag (✨ 素材库与多态拖拽)
|
||||
* **Location**: Direct shortcut on the bottom dock (5th icon).
|
||||
* **Capture**: In Gallery or Workflows, open an image's parameter inspector to save its image, workflow, and node blocks as a curated material bundle (`workflows/anomalous_materials`).
|
||||
* **Polymorphic Canvas Drag**:
|
||||
- Dragging a workflow material to an empty canvas area opens the full workflow.
|
||||
- Dragging a prompt-only material to an empty canvas auto-creates native `CLIPTextEncode` nodes with distinct negative (`#532323`) and positive (`#235327`) styling.
|
||||
- Dragging materials onto existing canvas nodes injects matching parameter values directly.
|
||||
<details>
|
||||
<summary><b>⚙️ Opening, shortcuts and updating</b></summary>
|
||||
|
||||
#### 7. Global Settings (⚙️ 设置)
|
||||
* **Location**: Direct shortcut on the far right of the lower-left navigation bar (6th icon).
|
||||
* **Options**: Interface language, font scale, thumbnail rendering, video cover hover behavior, and model folder blacklist.
|
||||
Open the browser with **Shift + B** (press again to close), the cube button at the canvas's top left, or **Extensions → Anomalous Model Browser** in ComfyUI's menu. Customize the shortcut, language and launcher style under **ComfyUI Settings → Anomalous Model Browser → Interface**.
|
||||
|
||||
> [!WARNING]
|
||||
> **Beta Data Protection:** Workflow Recipes, Material Library, and Parameter Presets are currently in active preview. Please back up `workflows/anomalous_recipes`, `workflows/anomalous_materials`, and `workflows/anomalous_parameters` inside your ComfyUI user directory before updating.
|
||||
**Updating**: click the **!** button at the top right of the browser, then **Check for updates / switch version** to update to the newest release or roll back to an earlier tag, and restart ComfyUI. The plugin never phones home or checks for updates in the background.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🌐 Network and backups</b></summary>
|
||||
|
||||
**On a cloud server (AutoDL, RunPod…):** overseas servers work out of the box; servers in mainland China only in part (Civitai is mostly out of reach there; see the `civitai.red` fallback and `hf-mirror.com` below). Tested on one AutoDL server so far. Opened from another machine, the plugin defaults to read-only for safety; connect via an SSH tunnel so ComfyUI sees `127.0.0.1`, or read [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
|
||||
|
||||
**Network:** Civitai scanning and Google prompt translation require network access (when `civitai.com` is unreachable, the plugin tries `civitai.red` automatically; **Offline** scan works without internet and reads base models from file headers). For Hugging Face downloads in mainland China, enable **Settings → Model downloads → Hugging Face mirror (`hf-mirror.com`)**. Daily browsing of models, gallery, workflows, saves and combos requires **no network**.
|
||||
|
||||
**Backups:** **Settings → Backup → Export** packs your saved workflows, presets, canvas saves, combos, prompts, node values, and custom model names/notes/covers into a single `.zip` for cloud drives or a move to another PC. **Import** never deletes anything: a file that differs from the backup's stays unless you choose to take the backup's (yours then goes to the Recycle Bin). A backup also lists your models with their fingerprints, so on a new computer the missing ones can be looked up and downloaded again. Data lives under `workflows/anomalous_*` inside your ComfyUI `user` folder.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -113,96 +188,181 @@ The header provides 3 primary workspaces:
|
||||
|
||||
<h2 id="中文">🇨🇳 中文说明</h2>
|
||||
|
||||
> **Anomalous Model Browser** 是为 ComfyUI 设计的综合创作工作台与模型管理套件。系统集成了基于文件哈希的元数据提取、工作流缺失模型自愈(模型医生)、画布节点可视化换模与 LoRA 插入(节点助手)、支持多态画布拖拽的统一素材库,以及工作流配方体系。
|
||||
> **Anomalous Model Browser** 在一个窗口里帮你打理 ComfyUI 周边的琐事:
|
||||
> - 带 C 站封面、触发词和多版本折叠卡浏览模型;
|
||||
> - 翻看历史出图,一拖还原当时的完整工作流;
|
||||
> - 用结构流程图和预设管理调好的工作流,随时给画布存几个存档锁种对比、一键回退;
|
||||
> - 存下常用的模型 + LoRA 搭配,用词块直观编辑长提示词;
|
||||
> - 打开别人的工作流缺模型时帮你找回或下载(工作流带着本插件记的指纹时最准);
|
||||
> - 在 **AI 工作台** 里动嘴提要求,让 AI 在副本标签页里调用你的工作流和实测“做法”跑图、先挑底图再放大精修;
|
||||
> - 配合可选节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 给角色配音。
|
||||
>
|
||||
> 插件和 AI 改动过的每一处画布设置,都在“记录”里写得明明白白,随时定位或撤销。
|
||||
|
||||
### 🎬 视频演示与教程
|
||||
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
|
||||
<p align="center"><img src="docs/images/models.webp" width="880" alt="中文模型库界面:Civitai 封面、底模标签与多版本同卡展开"></p>
|
||||
|
||||
### 🌟 核心特性速览
|
||||
### 🧭 理念
|
||||
|
||||
| 🚀 核心视图与工具 | 💡 详细说明 |
|
||||
| :--- | :--- |
|
||||
| **模型浏览 (📦 模型)** | 本地模型管理网格。左侧树状目录分类(checkpoints、unet、loras),卡片展示 C 站元数据、触发词、基础架构与高清封面,支持看图换模与自定义备注。 |
|
||||
| **出图图库 (🖼️ 图库)** | 原生读取本地 `output` 文件夹,支持滚轮缩放与安全删除;顶部搜索栏支持按提示词、模型名、LoRA、seed、文件名或模型哈希多维检索,生成可点击独立移除的搜索标签块;拖拽图片原地还原工作流。 |
|
||||
| **工作流配方 (🪡 工作流)** | 工作流配方工坊。支持保存完整工作流或局部子图,实时显示模型就绪状态(如“全部模型就绪”);支持一键在新画布打开或直接拖拽卡片到画布空白处载入。 |
|
||||
| **实用工具箱与底栏 (🧰)** | 侧边栏左下角第一个图标为工具箱,底栏直达 4 大常用工具(扫描向导、模型医生、节点助手、素材库)与全局设置;点击工具箱弹出浮窗面板,提供 5 大扩展工具(导入导出、提示词工坊、翻译助手、模型来源、提示词笔记)。 |
|
||||
| **节点智能修复 (模型医生 🩺)** | 导入他人工作流发生节点爆红时,自动比对本地模型 SHA256 哈希与字节大小,一键替换为有效路径,支持哈希透视比对。 |
|
||||
| **节点助手与参数预设 (🤖)** | 画布选中节点即可可视化选图换模型、向兼容链路插入 LoRA,或一键应用工作流配方中沉淀的节点参数(自动跳过易变种子)。 |
|
||||
| **统一素材库与多态拖拽 (✨)** | 原生 PNG 资产一键打包保存(完整工作流、模型血缘与节点参数);多态拖拽:拖至空白画布还原工作流或生成原生红绿提示词节点,拖至已有节点智能注入参数。 |
|
||||
**不再造一个套壳 WebUI,也不取代 ComfyUI,只是把 ComfyUI 身边最磨人的杂活接过去。**
|
||||
|
||||
### 📦 快速安装
|
||||
- **少做没必要的体力活。** 认乱码文件名、修一片红框的缺模型节点、翻旧图找参数、每次重连同一串大模型和 LoRA——这些交给插件做。
|
||||
- **用的还是你自己的 ComfyUI。** 画布和节点全是 ComfyUI 原生的。插件本身**一个自定义节点都不往工作流里塞**,放到画布上的都是官方节点或你本来就装了的节点包;出的图、另存的工作流,发给没装本插件的朋友照样直接打开。
|
||||
- **接 AI 也不走“黑盒盲猜”路线。** 你只管用人话提要求;**AMB** 负责在**副本标签页**里打开你的工作流、接好实测过的节点块;真正出图的还是**你本机的 ComfyUI 和你挑的模型**。每一步改了什么都在眼前,先挑构图再跑慢速放大,不满意随时一步撤销。
|
||||
|
||||
1. 在 ComfyUI 的 `custom_nodes` 目录下打开终端执行:
|
||||
### ✨ 1.63 重点更新
|
||||
|
||||
- **AI 工作台走通“分阶段导演式出图”**:多段流程支持**先快速跑几张一阶段底图让你挑**(点“就选这张”锁种进入二段放大,不重画底图),右侧展台提供**前后拉帘对比**、局部涂抹重绘与“效果好 / 不太行”一键反馈;内置按用途整理的实测**做法**与按需 **Skill**,默认在**副本标签页**干活,不碰你正在改的画布。
|
||||
- **工作流详情升级“结构流程图 + 橙色预设差异 + 官方示例”**:点开工作流就能看到按出图阶段排布的结构图(底模、LoRA、控制模型横跨在它们作用的阶段上方);切换**预设**时改动过的阶段直接标成**橙色**,点格子即看 `默认 → 当前预设` 参数对比;顶部新增**官方示例**,新装环境打开就能跑。
|
||||
- **画布存档与锁种公平对比**:右键画布存下当前状态,底部存档条随时切回 `1 · 2 · 3`(连输出节点上的预览图也跟着一起切回对应存档!),支持**锁种**或**随机**批量跑多组存档,并在存档页并排对比每版改了什么和各自出的图。
|
||||
- **同模型多版本合卡 & 一次按顺序插入多个 LoRA**:来自同一个 C 站模型页的多个版本自动收成一张卡(点右侧数字展开橙边抽屉、图钉设默认版本、按 SHA-256 下载该模型其他版本);**插入 LoRA** 支持点选多个按 `1 · 2 · 3` 编号自动串好线(或放进 rgthree / Easy-Use 堆叠节点),一次 `Ctrl+Z` 全退回;侧边停靠支持拖拽右边缘自由调宽(最窄 `480px`)。
|
||||
|
||||
### 🎬 视频与讨论
|
||||
* 📺 **哔哩哔哩 (Bilibili)**:[功能演示](https://www.bilibili.com/video/BV1boHx61Enc/) · [角色配音演示](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
|
||||
* 💬 **Reddit**:[r/comfyui 上的介绍帖](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
|
||||
|
||||
---
|
||||
|
||||
### ✨ AI 工作台:你用人话当导演,AMB 搭线,ComfyUI 用你的模型出图
|
||||
|
||||
很多人一听“在 ComfyUI 里接 AI”,第一反应往往是两个顾虑:要么怕它像云端生图软件一样绕开自己的本地模型库,要么怕大模型瞎猜节点连线,把自己辛辛苦苦调好的画布搅得一团糟。**AI 工作台**走的是另一条路——**让 AI 当你的操作助手,规矩由 AMB 守住**:
|
||||
|
||||
- **默认只改“副本”,绝不弄乱你开着的画布。** 任务默认开启 **AI 改副本**:AI 第一次动手前,AMB 自动把工作流复制到专属的任务标签页里操作,你原来的标签页一笔不动;时间线上的每一步都能点 **撤销** 或 **在画布上找到**。
|
||||
- **不靠瞎猜连线,优先用你的工作流和实测“做法”。** AI 会直接调用**你存好的工作流及预设**、**图库某张图自带的工作流**,或是开箱即用的 **官方示例**;需要加功能时,由 AMB 按你已装的节点包搭上实测过的 **做法**(Tile 锁图二段放大、半途换模型接着画、裁切缝合 / LanPaint 局部重绘、修手先放大再修、换背景、姿势/深度控制……),并配合按需 **Skill**(按主模型参数量给动作建议、分阶段出图思路)。你还能在做法面板点 **这次用它**,直接指定本次任务必须走哪套做法。
|
||||
- **先挑快底图,再跑慢精修,展台拉帘对比。** 跑两段放大这类耗时流程时,AI 可以先快速跑几张第一段底图预览(不污染正式输出目录);右侧**展台**弹出挑选面板,你点 **就选这张**,第二段直接接着这张图做细节重绘,省掉重跑底图的时间。出图后展台提供**前后拉帘对比**、**效果好 / 不太行** 按钮,哪里不满意还能点图片右上角 `✎` 用画笔涂出区域让它只重画那一块。
|
||||
- **权限全由“关卡”把关。** 改画布、用显卡出图、下载模型、联网扫描模型、挪动模型文件,都能在 **关卡** 里设为“直接做 / 先问我 / 不许做”;AI 自己往画布上加节点时,能不能用任意第三方节点包、没验证过的新路线要不要先问你,也在这里设(任务标题栏还带可选的 `🔒` 隐私开关,开启后提示词与图片名会换成 `[P1]`、`[img1]` 代号,不把原图和原文发给 AI)。支持连接 Antigravity、Claude、Cursor、Cherry Studio、VS Code 等支持 MCP 的本机软件——点工作台里的 **连接 AI** 复制一句话发给它就能配好,详见 [docs/guides/mcp.md](docs/guides/mcp.md)。
|
||||
|
||||
<p align="center"><img src="docs/images/ai-workbench.webp" width="840" alt="AI 工作台:先在右侧展台三选一挑中快速底图,再锁种完成二段精修并用拉帘滑块对比前后细节"></p>
|
||||
|
||||
---
|
||||
|
||||
### 💡 日常玩 ComfyUI,它帮你省掉哪些麻烦(不接 AI 也一样主力)
|
||||
|
||||
**1. 模型塞满硬盘,文件名全是 `download (3).safetensors`。**
|
||||
点一次 **扫描**,每个模型按文件 SHA-256 哈希去 C 站认领真名、高清封面、触发词和底模角标。同一个 C 站模型的不同版本还会自动收进一张卡片,点右侧数字滑出全部版本(顶部橙边相连),点图钉设默认版本,详情里还能直接核对下载该模型在 C 站上的其他版本。
|
||||
|
||||
<p align="center"><img src="docs/images/scan.webp" width="560" alt="扫描明细:把乱七八糟的本地文件名精准匹配回 C 站模型名"></p>
|
||||
|
||||
**2. 打开别人分享的工作流,满屏红框缺模型。**
|
||||
**模型检查** 在打开工作流时自动只读体检,能做到哪一步,看工作流里带了什么:
|
||||
- **带指纹的**(作者也装了本插件,出图、存工作流时默认记下每个模型的 SHA-256):你电脑里其实有、只是改了名或挪了文件夹的同一个文件,能确定认出来,横幅上点一下 **换上** 就全部就位;真缺的按指纹去 C 站找,下载完核对一致才换上。
|
||||
- **没带指纹的**(作者没装本插件):没法证明是哪一个文件,只能按名字找。别的文件夹里恰好有一个同名文件,会列出来让你逐个确认;VAE、文本编码器这类经常同名的组件不按名字猜。
|
||||
- **下载**时还会查工作流自带的下载链接(ComfyUI 官方模板就带着)和 ComfyUI-Manager 的模型清单,Hugging Face 的文件可以走国内镜像。只是名字对上的会标成 **按文件名下载**,你点了才下。
|
||||
|
||||
<p align="center"><a href="docs/guides/model-check-rules.md"><img src="https://img.shields.io/badge/📖_模型检查怎么判断-完整规则-blueviolet?style=for-the-badge" alt="模型检查怎么判断:完整规则"></a></p>
|
||||
|
||||
<p align="center"><img src="docs/images/model-check.webp" width="820" alt="模型检查:一键把 3 个改名或挪过文件夹的红框缺失节点换回正确模型"></p>
|
||||
|
||||
**3. 存下调好的工作流,结构流程图和橙色预设差异一眼看清。**
|
||||
顶部自带 **官方示例**(文生图、两段放大、图生图、局部重绘,自动配好你本机已有的模型),新手装上就能出图。存下的每张工作流卡点开就是清爽的 **结构流程图**:底模、LoRA、控制模型横跨在它们管辖的采样阶段(`① 出底图` → `② 放大重画`)上方。切换下方的 **预设标签**,和默认参数不同的阶段直接亮起 **橙色**,点任意方格就能在下方展开 `默认 → 当前预设` 的逐项对比;还能打成 `.zip` **配方包** 或生成短短一截 `⇅` 汉字/字母分享码发给别人。
|
||||
|
||||
<p align="center"><img src="docs/images/workflows.webp" width="840" alt="工作流页:官方示例、结构流程图、切换预设后橙色高亮改动阶段及节点参数对比"></p>
|
||||
|
||||
**4. 想试几种新参数或加个 LoRA,又怕改坏现在这版 —— 画布存档。**
|
||||
在画布上右键 → **存档当前工作流**,当前状态就存进画布底部的存档条;接着随便改参数、加节点,点 `+` 再存几版。存档条支持选 **锁种**(同一组种子公平对比)或 **随机**,一键把几个存档按顺序排进队列。“工作流”页顶部的 **存档** 把每个存档改了什么和它跑出的图并排摆在一起;点底部存档条的数字 `1`、`2`、`3` 就能在当前标签页秒回那一版——**连输出节点上的预览图也会跟着切回那个存档跑出来的图**(`Ctrl+Z` 可撤回,满意的随时点 **收藏为工作流**)。
|
||||
|
||||
<p align="center"><img src="docs/images/saves.webp" width="840" alt="画布存档:并排查看三个存档的参数节点改动与锁种出图,并在底部存档条一键切换回退"></p>
|
||||
|
||||
**5. 翻到一张老图:“当时到底怎么调出来的?”**
|
||||
在 **图库** 里按提示词、大模型、LoRA、种子或模型哈希秒搜(哪怕几亿像素的超大图也在后台独立进程生成缩略图,绝不卡死页面),把图往画布上一拖,当时的完整工作流直接回来。
|
||||
|
||||
<p align="center"><img src="docs/images/gallery.webp" width="820" alt="图库按标签词块搜索图片,拖入右侧画布立即还原完整工作流"></p>
|
||||
|
||||
**6. 每次开新图都要重连一遍大模型、LoRA 和起手提示词。**
|
||||
框选画布上的这几个节点,右键 → **AMB** → **存为搭配**,就存成一张搭配卡。下次点 **放到画布**(或直接拖卡片),点哪里就在哪里放出一组连好线的节点,画布原有的东西分毫不动。想在已有节点后面加几个 LoRA?到 **当前节点 → 插入 LoRA** 里连点几张卡片,卡片上的数字 `1 · 2 · 3` 就是串联顺序(装了 rgthree 或 Easy-Use 还能一键塞进同一个 LoRA 堆叠节点),一次 `Ctrl+Z` 整串退回。
|
||||
|
||||
<p align="center"><img src="docs/images/combo.webp" width="820" alt="把存好的模型 + LoRA + 提示词搭配一键放到画布上自动连好线"></p>
|
||||
|
||||
**7. 提示词一长串逗号,改权重、调顺序眼都花了 —— 提示词工坊。**
|
||||
点 **停到画布旁** 把工坊挂在画布左侧,选中任意提示词节点,正负面文本立刻拆成可视化词块:点 `+` / `−` 调权重,双击改字,拖拽换顺序,直接敲中文回车自动译成英文词块加进去,常用词卡还能直接拖进画布的正向(绿框)/ 反向(红框)输入框里。
|
||||
|
||||
<p align="center"><img src="docs/images/prompt-studio.webp" width="820" alt="提示词工坊停靠在画布旁,按词块实时调整选中节点的提示词权重"></p>
|
||||
|
||||
**8. 想让画出来的角色开口说话。**
|
||||
配合可选节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS):导入 GPT-SoVITS 角色,按情绪绑定参考音频、修正多音字读音,在配音页写好台词直接生成语音。
|
||||
|
||||
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面:按情绪管理参考音频与台词配音"></p>
|
||||
|
||||
**改错了也不怕。**
|
||||
插件和 AI 在画布上改过的每一个参数、连过的每一根线,都按天记在 **记录** 里,展开就能看修改前后的数值,点 **在画布上找到** 直接跳过去,点 **撤销** 一键退回。
|
||||
|
||||
<p align="center"><img src="docs/images/activity.webp" width="760" alt="操作记录页面:展开查看节点修改前后数值,一键在画布上找到或撤销"></p>
|
||||
|
||||
**不抢你的显存和内存**:
|
||||
- 插件自身不加载任何生图模型,**零显存占用**,绝不和 ComfyUI 出图抢资源。
|
||||
- 浏览网格全程走小缩略图缓存,不把原图塞进浏览器;关闭窗口一分半后自动释放图片内存。
|
||||
- `Anomalous_TTS` 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 则彻底清空。
|
||||
|
||||
### 📦 安装
|
||||
|
||||
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` 点击安装*)
|
||||
2. <a id="where-zh"></a>**装好后去哪找**:重启后,画布左上角会多一个小立方体按钮(下图圈出来的那个),点它或按 **Shift + B** 打开窗口。第一次用可以在 **主页** 点 **扫描模型文件夹** 补齐封面和触发词,点 **界面导览** 跟着认识一圈各个区域。
|
||||
|
||||
可以按 **Ctrl + Shift + M**、点击画布上的悬浮 **📦**,或从 ComfyUI 顶部菜单 **扩展 → Anomalous Model Browser** 打开。**ComfyUI 设置 → Anomalous Model Browser → 界面** 会显示当前快捷键,并可直接打开 ComfyUI 原生录入窗口修改,因此冲突与保留按键检查仍然只有一套。该页面还可以让插件语言跟随 ComfyUI 或单独固定为中文/English,并严格三选一显示悬浮入口、运行按钮旁的原生顶部入口或仅使用扩展菜单。只有选择悬浮入口时才显示大小和样式选项。顶部栏重绘不会擅自改变所选模式,扩展菜单命令则始终保留,便于恢复设置。
|
||||
<p align="center"><img src="docs/images/entry.webp" width="640" alt="画布左上角的立方体按钮,以及 ComfyUI 菜单 C → Extensions → Anomalous Model Browser → 打开"></p>
|
||||
|
||||
<br/>
|
||||
看不到这个立方体?可能被拖走了,或者换成了别的入口:点左上角 ComfyUI 的 **C** 菜单 → **Extensions → Anomalous Model Browser**,里面有 **打开 Anomalous 模型浏览器** 和 **重置悬浮入口位置**。想让它更显眼,去 **ComfyUI 设置 → Anomalous Model Browser → 界面**:**悬浮入口样式** 选 **胶囊文字**(会带上“模型浏览器”几个字),**悬浮入口大小** 选 **大**;或者 **浏览器入口模式** 选 **顶部动作栏**,放到运行按钮旁边。
|
||||
3. 需要角色配音的话,再安装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)(ComfyUI Manager 搜索 `Anomalous TTS`);不装完全不影响其他功能。
|
||||
|
||||
扫描模型默认联网查 C 站(纯内置实现,无需额外装 Python 包)。没开代理或断网时选 **离线扫描** 也能直接从模型文件头识别底模与架构,等以后连上网再扫一次会自动补齐封面和触发词(详见下方“网络和备份”)。
|
||||
|
||||
<details>
|
||||
<summary><b>📖 点击展开:标准操作指南(图文步骤)</b></summary>
|
||||
<summary><b>📖 每个页面和工具的详细说明</b></summary>
|
||||
|
||||
<br/>
|
||||
窗口左侧是页面图标栏;切换为“侧边停靠”时,按住窗口右边缘即可左右拖宽窄(最窄 `480px`,双击边缘恢复默认 `660px`)。
|
||||
|
||||
#### 1. 顶部主工作区导航 (三大顶栏视图)
|
||||
插件顶部居中提供 3 个核心工作区入口:
|
||||
* **模型 (📦)**:主模型浏览器。左侧为模型文件夹分类树(`checkpoints`、`unet`、`loras` 等),中间展示模型卡片网格、封面图预览与触发词。
|
||||
* **图库 (🖼️)**:出图历史浏览器。原生读取本地 `output` 目录。顶部搜索栏支持输入提示词、模型名、LoRA、seed、文件名或模型哈希,回车即可生成独立的搜索标签块,点击标签块上的 `×` 可快速移除该过滤项;直接将图片拖拽至 ComfyUI 画布可原地恢复内嵌工作流。
|
||||
* **工作流 (🪡)**:工作流配方工坊。提供 `[全部]`、`[完整工作流]`、`[局部子图]` 子过滤器与标签筛选;点击右上角 `保存当前工作流` 即可将当前画布打包为配方;直接按住配方卡片拖拽到 ComfyUI 画布空白处释放即可立即载入。
|
||||
| 页面 | 在这里做什么 |
|
||||
| :--- | :--- |
|
||||
| **🏠 主页** | 按你想做的事分好入口卡片,包含新手第一步(扫描模型文件夹、界面导览)、AI 最近做的任务和最新操作记录。 |
|
||||
| **🕘 记录** | 按天列出插件和 AI 做过的全部改动:画布改动(具体哪个节点的什么参数从多少改成多少、增删了哪些节点、关联哪个 AI 任务)与文件操作(模型、封面、工作流、搭配、提示词、图片、音频)。支持 **在画布上找到** 定位节点,没被后续覆盖的画布改动可随时 **撤销**。 |
|
||||
| **📦 模型** | 带封面、触发词和底模分类条浏览模型,左侧可按子文件夹筛选,点 **+** 一键往画布放加载器。来自同一 C 站模型页的多个版本自动收成一张卡(点右侧数字展开橙边版本排、图钉固定默认显示版本、右上可 **展开全部版本**);详情页点 **Civitai 上的全部版本** 可按需联网查看并按 SHA-256 核对下载其他版本。另含 **整理**(排查放错目录或重复文件)与 **模型来源**。 |
|
||||
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹(几亿像素超大图由后台子进程做缩略图,浏览不卡)。支持按提示词、模型、LoRA、种子、文件名或模型哈希组合搜索;看大图与完整参数、勾选两张对比工作流差异、拖进画布还原工作流,或点 **☆** 存为工作流、搭配、提示词。 |
|
||||
| **✨ AI 工作台** | 按任务记录并操控接入的 AI:左侧任务树(后续任务自动缩进挂在主任务下),中间实时步骤时间线(每步可撤销,顶部带 **AI 改副本** 与 **切到副本**),右侧可切换 **展台**(一阶段底图挑选、前后拉帘滑块、选择题、`✎` 涂抹局部重绘、**效果好 / 不太行**)、**节点结构**、**决策**、**素材**、**成果**、**做法**(支持点“这次用它”)与 **Skill**,以及权限 **关卡**(含可选单任务 `🔒` 隐私开关)。 |
|
||||
| **🪡 工作流** | 顶部是 **官方示例** 与 **存档**,下方是保存的工作流卡。详情页从上到下依次为 **结构流程图**、吸顶 **预设标签栏**(橙色标出与默认不同的阶段,点方格看逐项差异,支持 **+ 新预设**)、**交给 AI**、历史版本对比恢复,支持导出 `.zip` **配方包** 或通过 `⇅` 收发短分享码、拖入图片/压缩包导入。 |
|
||||
| **🧩 搭配** | 把顺手的主模型、几个 LoRA(连同各自权重)和正反提示词存成一张搭配卡。点 **放到画布** 或拖入画布即生成连好线的独立节点组,绝不改动画布上原有的节点。 |
|
||||
| **✍️ 提示词** | 提示词工坊,可全窗口用,也可点 **停到画布旁** 边看节点边改。选中提示词节点后自动拆成词块:单击调权重、双击改字、拖拽排顺序、输入中文/日文回车自动译成英文词块,还能在词块下显示多语种释义;左侧词卡支持一键加入或拖进画布正/反向输入框,未选节点时可当草稿本写。 |
|
||||
| **🎙️ 角色语音 / 🎧 音频库** | 安装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后启用:导入 GPT-SoVITS 角色、按情绪选参考音频、修读音,在“配音”页写台词直接生成,并在音频库回放管理。 |
|
||||
|
||||
#### 2. 底部导航与实用工具箱 (🧰 第一个图标:实用工具箱)
|
||||
* **布局结构**:侧边栏左下角常驻 6 个固定图标按钮:
|
||||
1. **实用工具箱 (🧰)**:左侧首个图标,点击展开工具箱浮窗面板。
|
||||
2. **扫描向导 (🔄)**:直达本地模型扫描与哈希/元数据建立。
|
||||
3. **模型医生 (🩺)**:直达工作流爆红诊断与缺失模型自动自愈。
|
||||
4. **节点助手 (🤖)**:直达画布选中节点模型信息查看、可视化换模与参数预设注入。
|
||||
5. **素材库 (✨)**:直达素材资产管理与多态画布拖拽。
|
||||
6. **全局设置 (⚙️)**:最右侧齿轮图标,直达全局偏好设置。
|
||||
* **实用工具箱浮窗**:点击首个图标(**🧰 实用工具箱**)打开工具箱窗口,集中收纳了未在底栏常驻的 5 大扩展工具:
|
||||
1. **导入导出 (⇄)**:无损 AMB 格式工作流分享码导入与导出,支持从文本分享码原地解析还原画布,或将当前画布打包生成带校验哈希的分享码。
|
||||
2. **提示词工坊 (🎛️)**:模块化提示词积木组装台,支持从出图历史或素材中抽取词卡并分块混音、正负词排序与组合。
|
||||
3. **翻译助手 (🌐)**:内置中英双向提示词互译工具,无需离开 ComfyUI 即可完成提示词中英翻译。
|
||||
4. **模型来源 (🔗)**:检视当前工作流或模型库中模型的在线下载来源(Civitai、HuggingFace、Liblib、ModelScope),支持一键在画布生成包含模型来源信息的 Note 说明节点。
|
||||
5. **提示词笔记 (📑)**:保存在 ComfyUI 用户目录(`workflows/anomalous_notebooks`)下的轻量提示词草稿本,用于随时记录、整理与复用常用提示词片段。
|
||||
左侧栏底部的常用工具(按日常使用频率从上到下排列):
|
||||
|
||||
#### 3. 扫描向导 (🔄 扫描向导)
|
||||
* **入口位置**:底部快捷栏第 2 个图标。
|
||||
* **操作步骤**:首次使用或添加新模型后打开,选择扫描范围(全部路径、仅缺失元数据或自定义目录)并执行,系统将自动建立本地模型库并拉取 C 站封面、标签与架构信息。
|
||||
* **当前节点**(原“节点助手”):跟随画布当前选中的节点,看封面换模型,或点 **插入 LoRA** 一次勾选多个 LoRA 按 `1 · 2 · 3` 顺序自动串接(装了 rgthree / Easy-Use 可选塞入单个堆叠节点);下方按来源小标签(工作流、搭配、图片、本节点存的)列出同类节点参数,点 `⇄ 对比` 只看当前节点差异并一键套用。
|
||||
* **模型检查**(原“模型医生”):打开工作流自动只读检查;本机改过名或换过目录的同一文件凭 SHA-256 指纹在画布顶部横幅一键换上(指纹由本插件写进工作流:**设置 → 在工作流里记下模型指纹**,默认开启);没有指纹时列出同名文件让你确认。缺的模型从 C 站(按指纹)、工作流自带的链接或 Manager 清单下载。[完整规则](docs/guides/model-check-rules.md)。
|
||||
* **模型扫描**:按文件哈希向 C 站匹配模型信息与封面;离线时也已针对 Manager 清单 300+ 真实模型文件头校准过底模与类型识别,不乱下误判结论。
|
||||
* **设置**(最下方):中英文实时切换(工作台展台、留言框与底部存档条立即生效,无需刷新页面)、界面缩放、深浅主题、缩略图与视频封面、Hugging Face 国内镜像、数据备份导出/导入,以及自动附带版本与显卡信息的 **报告问题**。
|
||||
|
||||
#### 4. 拯救爆红 (🩺 模型医生)
|
||||
* **入口位置**:底部快捷栏第 3 个图标。
|
||||
* **操作步骤**:载入他人工作流或图片出现红框缺失报错时,点击模型医生即可自动比对本地模型哈希与字节大小,一键批量映射为本地正确路径。卡片上的“查看哈希”可展开内嵌指纹与磁盘文件的 SHA256 逐项比对面板。
|
||||
**接入 AI 软件(MCP)**:Antigravity、Claude、Cursor、Cherry Studio、VS Code 等支持 MCP 的客户端可通过本机地址 `http://127.0.0.1:8188/anomalous/mcp` 连接(仅接受本机请求)。完整配置与工具清单见 [docs/guides/mcp.md](docs/guides/mcp.md)。
|
||||
|
||||
#### 5. 选中交互与预设 (🤖 节点助手)
|
||||
* **入口位置**:底部快捷栏第 4 个图标。
|
||||
* **动作功能**:在画布选中模型节点后,可在“动作”页查看高清预览图与触发词,支持看图一键替换模型或在兼容链路前后插入 LoRA。
|
||||
* **参数预设**:切换至“参数预设”页,可读取工作流配方中同类型节点的保存参数并一键注入画布(自动保留种子等易变数值)。
|
||||
</details>
|
||||
|
||||
#### 6. 统一素材库与多态画布拖拽 (✨ 素材库与多态拖拽)
|
||||
* **入口位置**:底部快捷栏第 5 个图标。
|
||||
* **保存素材**:在出图图库或配方详情中打开任意生成 PNG 的参数面板,可一键将图片、完整工作流、模型依赖与可复用节点参数打包收藏。
|
||||
* **多态画布拖拽**:
|
||||
- 将工作流素材直接拖拽至空白画布,即可原地恢复并打开完整工作流。
|
||||
- 将纯提示词素材拖拽至空白画布,会自动创建带有正负区分色(深红 `#532323` 为负向 / 墨绿 `#235327` 为正向)的原生 `CLIPTextEncode` 节点。
|
||||
- 将素材拖拽至画布已有节点上时,会自动通过语义探针将匹配参数注入目标节点中。
|
||||
* **本地存储**:素材存储于 ComfyUI 用户目录下的 `workflows/anomalous_materials`,支持原子写入。
|
||||
<details>
|
||||
<summary><b>⚙️ 打开方式、快捷键和更新</b></summary>
|
||||
|
||||
#### 7. 全局设置面板 (⚙️ 设置)
|
||||
* **入口位置**:侧边栏左下角最右侧第 6 个图标(**齿轮图标 ⚙️**)。
|
||||
* **个性调节**:支持中英文界面切换、字体大小缩放、视频封面悬停/常开播放、缩略图优化与目录黑名单管理。
|
||||
打开方式有三种:按 **Shift + B**(再按一次关闭)、点画布左上角的立方体按钮,或从 ComfyUI 菜单 **Extensions → Anomalous Model Browser** 进入(中文界面下这一项也叫 Extensions)。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 中可自定义快捷键、语言及入口按钮样式。
|
||||
|
||||
> [!WARNING]
|
||||
> **测试功能数据安全提醒:** 工作流配方、素材库与参数预设目前属于活跃测试阶段,更新插件前建议备份 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。
|
||||
**更新与版本切换**:点击窗口右上角的 **!** → **检查更新 / 切换版本**,可一键升级到最新正式版或回退到历史版本,完成后重启 ComfyUI 生效。插件绝不会在后台悄悄联网检查更新。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🌐 网络和备份</b></summary>
|
||||
|
||||
**云服务器(AutoDL、RunPod 等):** 海外云服务器完整支持;国内云服务器只能部分支持:多半连不上 Civitai,有的能连上 `civitai.red` 认出模型名与触发词(封面较难下载)。目前只实测过一台 AutoDL 服务器。从非本机浏览器直接访问时插件默认只读保护,建议通过 SSH 隧道映射到本地打开,详见 [docs/guides/cloud-server.md](docs/guides/cloud-server.md)(内附可直接丢给 AI 助手照着配的步骤)。
|
||||
|
||||
**关于网络:** 扫描封面与谷歌提示词翻译需要联网(连不上 `civitai.com` 时会自动尝试 `civitai.red`;无代理时选 **离线** 扫描同样能识别底模)。下载 Hugging Face 模型可在 **设置 → 模型下载** 开启 `hf-mirror.com` 国内镜像。日常浏览模型、图库、工作流、存档和搭配**完全不需要联网**。
|
||||
|
||||
**备份自己的数据:** **设置 → 备份 → 导出** 会把工作流、预设、画布存档、搭配、提示词库、节点参数以及你自定义的模型名/备注/封面打包成一个 `.zip`,换电脑或重装后点 **导入** 即可恢复。导入从不删东西:和备份里不一样的文件默认留着你电脑上的,选了“换成备份里的”才替换,原来的进回收站。备份里还记着你有哪些模型和它们的指纹,换了新电脑,缺的模型可以照着清单查来源补下载。(数据保存在 ComfyUI 用户目录下的 `workflows/anomalous_*` 文件夹中。)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 🧪 Tests (测试)
|
||||
|
||||
The plugin's tests live in `tests/`; run the full suite 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 许可证开源,可自由使用、修改与分发。
|
||||
|
||||
+5
-1
@@ -1,11 +1,15 @@
|
||||
import os
|
||||
from .api import setup_routes
|
||||
from .api.usage_ledger import note_prompt
|
||||
from server import PromptServer
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
# 注册 API 路由
|
||||
setup_routes(PromptServer.instance.app)
|
||||
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 = {}
|
||||
|
||||
+165
-99
@@ -1,99 +1,165 @@
|
||||
from . import (
|
||||
folder_types, gallery_routes, materials, media_routes, model_catalog,
|
||||
model_media, model_metadata, model_resolution, recipe_packages, recipes,
|
||||
translation_routes,
|
||||
)
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
from .notebooks import *
|
||||
from .parameters import *
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@web.middleware
|
||||
async def no_cache_extension_middleware(request, handler):
|
||||
response = await handler(request)
|
||||
if request.path.startswith('/extensions/Anomalous_Model_Browser'):
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
def setup_routes(app):
|
||||
if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares:
|
||||
app.middlewares.append(no_cache_extension_middleware)
|
||||
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/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_post('/anomalous/scan', api_scan_folder)
|
||||
app.router.add_get('/anomalous/scan_status', api_scan_status)
|
||||
app.router.add_get('/anomalous/find_model', model_catalog.api_find_model)
|
||||
app.router.add_get('/anomalous/config', api_get_config)
|
||||
app.router.add_post('/anomalous/save_config', api_save_config)
|
||||
app.router.add_post('/anomalous/delete_model', model_metadata.api_delete_model)
|
||||
app.router.add_post('/anomalous/clean_civitai_info', api_clean_civitai_info)
|
||||
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
|
||||
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
|
||||
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
|
||||
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
|
||||
|
||||
# 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_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)
|
||||
|
||||
# Recipe Routes
|
||||
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
|
||||
app.router.add_get('/anomalous/recipe_full', recipes.api_get_recipe_full)
|
||||
app.router.add_get('/anomalous/recipe_asset', recipes.api_get_recipe_asset)
|
||||
app.router.add_get('/anomalous/recipe_gallery', recipes.api_get_recipe_gallery)
|
||||
app.router.add_get('/anomalous/recipe_parameter_gallery', recipes.api_get_recipe_parameter_gallery)
|
||||
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/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)
|
||||
app.router.add_get('/anomalous/recipe_version', recipes.api_get_recipe_version)
|
||||
app.router.add_post('/anomalous/restore_recipe_version', recipes.api_restore_recipe_version)
|
||||
app.router.add_post('/anomalous/refresh_recipe_identity', recipes.api_refresh_recipe_identity)
|
||||
app.router.add_post('/anomalous/export_recipe_package', recipe_packages.api_export_recipe_package)
|
||||
app.router.add_post('/anomalous/import_recipe_package_inspect', recipe_packages.api_import_recipe_package_inspect)
|
||||
app.router.add_post('/anomalous/import_recipe_package_commit', recipe_packages.api_import_recipe_package_commit)
|
||||
|
||||
# Curated Material Library Routes
|
||||
app.router.add_get('/anomalous/materials', materials.api_get_materials)
|
||||
app.router.add_get('/anomalous/material_full', materials.api_get_material_full)
|
||||
app.router.add_get('/anomalous/material_asset', materials.api_get_material_asset)
|
||||
app.router.add_get('/anomalous/materials/by_node_type', materials.api_get_materials_by_node_type)
|
||||
app.router.add_post('/anomalous/inspect_image_material', materials.api_inspect_image_material)
|
||||
app.router.add_post('/anomalous/save_image_material', materials.api_save_image_material)
|
||||
app.router.add_post('/anomalous/save_parameter_material', materials.api_save_parameter_material)
|
||||
app.router.add_post('/anomalous/save_prompt_note_material', materials.api_save_prompt_note_material)
|
||||
app.router.add_post('/anomalous/save_prompt_plan', materials.api_save_prompt_plan)
|
||||
app.router.add_post('/anomalous/delete_material', materials.api_delete_material)
|
||||
app.router.add_post('/anomalous/update_material', materials.api_update_material)
|
||||
|
||||
app.router.add_post('/anomalous/translate', translation_routes.api_translate)
|
||||
app.router.add_get('/anomalous/base_models', model_catalog.api_base_models)
|
||||
app.router.add_get('/anomalous/gallery_images', gallery_routes.api_get_gallery_images)
|
||||
app.router.add_post('/anomalous/delete_gallery_image', gallery_routes.api_delete_gallery_image)
|
||||
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)
|
||||
app.router.add_post('/anomalous/scan_all', api_scan_all)
|
||||
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
|
||||
app.router.add_get('/anomalous/scan_missing_models_status', api_scan_missing_models_status)
|
||||
app.router.add_post('/anomalous/clear_cache', media_routes.api_clear_cache)
|
||||
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_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_post('/anomalous/scan_missing_models', api_scan_missing_models)
|
||||
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, 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, snapshot_routes, workflow_saves, example_workflows,
|
||||
)
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
from .notebooks import *
|
||||
from .parameters import *
|
||||
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
# 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
|
||||
async def no_cache_extension_middleware(request, handler):
|
||||
"""Make browsers revalidate this plugin's JS/CSS so a normal refresh picks up updates.
|
||||
|
||||
`no-cache` still allows 304 responses via ETag/Last-Modified. This replaces
|
||||
per-import `?v=` query strings, which load a second module instance whenever
|
||||
two files import the same module with different URLs.
|
||||
"""
|
||||
response = await handler(request)
|
||||
if request.path.startswith(EXTENSION_STATIC_PREFIX):
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
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)
|
||||
app.router.add_post('/anomalous/snapshot_check', snapshot_routes.api_snapshot_check)
|
||||
app.router.add_get('/anomalous/saves', workflow_saves.api_list)
|
||||
app.router.add_get('/anomalous/saves/group', workflow_saves.api_group)
|
||||
app.router.add_get('/anomalous/saves/workflow', workflow_saves.api_workflow)
|
||||
app.router.add_post('/anomalous/saves/add', workflow_saves.api_add)
|
||||
app.router.add_post('/anomalous/saves/update', workflow_saves.api_update)
|
||||
app.router.add_post('/anomalous/saves/ran', workflow_saves.api_ran)
|
||||
app.router.add_post('/anomalous/saves/remove', workflow_saves.api_remove)
|
||||
app.router.add_post('/anomalous/saves/remove_group', workflow_saves.api_remove_group)
|
||||
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/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)
|
||||
app.router.add_post('/anomalous/scan', api_scan_folder)
|
||||
app.router.add_get('/anomalous/scan_status', api_scan_status)
|
||||
app.router.add_get('/anomalous/find_model', model_catalog.api_find_model)
|
||||
app.router.add_get('/anomalous/config', api_get_config)
|
||||
app.router.add_post('/anomalous/save_config', api_save_config)
|
||||
app.router.add_post('/anomalous/delete_model', model_metadata.api_delete_model)
|
||||
app.router.add_post('/anomalous/open_model_folder', model_folder.api_open_model_folder)
|
||||
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
|
||||
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
|
||||
app.router.add_get('/anomalous/notebooks/by_node_type', api_get_notebooks_by_node_type)
|
||||
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
|
||||
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
|
||||
|
||||
# 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)
|
||||
app.router.add_get('/anomalous/example_workflows', example_workflows.api_examples)
|
||||
app.router.add_get('/anomalous/recipe_full', recipes.api_get_recipe_full)
|
||||
app.router.add_get('/anomalous/recipe_asset', recipes.api_get_recipe_asset)
|
||||
app.router.add_get('/anomalous/recipe_gallery', recipes.api_get_recipe_gallery)
|
||||
app.router.add_get('/anomalous/recipe_parameter_gallery', recipes.api_get_recipe_parameter_gallery)
|
||||
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)
|
||||
app.router.add_get('/anomalous/recipe_version', recipes.api_get_recipe_version)
|
||||
app.router.add_post('/anomalous/restore_recipe_version', recipes.api_restore_recipe_version)
|
||||
app.router.add_post('/anomalous/refresh_recipe_identity', recipes.api_refresh_recipe_identity)
|
||||
app.router.add_post('/anomalous/export_recipe_package', recipe_packages.api_export_recipe_package)
|
||||
app.router.add_post('/anomalous/import_recipe_package_inspect', recipe_packages.api_import_recipe_package_inspect)
|
||||
app.router.add_post('/anomalous/import_recipe_package_commit', recipe_packages.api_import_recipe_package_commit)
|
||||
|
||||
# Curated Material Library Routes
|
||||
app.router.add_get('/anomalous/materials', materials.api_get_materials)
|
||||
app.router.add_get('/anomalous/material_full', materials.api_get_material_full)
|
||||
app.router.add_get('/anomalous/material_asset', materials.api_get_material_asset)
|
||||
app.router.add_get('/anomalous/materials/by_node_type', materials.api_get_materials_by_node_type)
|
||||
app.router.add_post('/anomalous/inspect_image_material', materials.api_inspect_image_material)
|
||||
app.router.add_post('/anomalous/save_image_material', materials.api_save_image_material)
|
||||
app.router.add_post('/anomalous/save_prompt_plan', materials.api_save_prompt_plan)
|
||||
app.router.add_post('/anomalous/save_node_material', node_material.api_save_node_material)
|
||||
app.router.add_post('/anomalous/delete_material', materials.api_delete_material)
|
||||
app.router.add_post('/anomalous/update_material', materials.api_update_material)
|
||||
app.router.add_post('/anomalous/mark_material_moved', materials.api_mark_material_moved)
|
||||
|
||||
app.router.add_post('/anomalous/translate', translation_routes.api_translate)
|
||||
app.router.add_get('/anomalous/base_models', model_catalog.api_base_models)
|
||||
app.router.add_get('/anomalous/gallery_images', gallery_routes.api_get_gallery_images)
|
||||
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/output_preview', gallery_routes.api_output_preview)
|
||||
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)
|
||||
app.router.add_post('/anomalous/scan_all', api_scan_all)
|
||||
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
|
||||
app.router.add_post('/anomalous/clear_cache', media_routes.api_clear_cache)
|
||||
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)
|
||||
app.router.add_get('/anomalous/audio_gallery', audio_catalog.api_get_audio_gallery)
|
||||
app.router.add_post('/anomalous/delete_audio_gallery', audio_catalog.api_delete_audio_gallery)
|
||||
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", "prompts", "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
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Activity log: what Anomalous changed, newest first, kept in the ComfyUI user folder.
|
||||
|
||||
Two sources feed it. The middleware records this plugin's own write requests
|
||||
(and Anomalous_TTS's) after they succeed: files such as models, covers, recipes,
|
||||
materials, notes, presets, images and audio. The browser posts the canvas changes
|
||||
it made (`POST /anomalous/activity`). Only the newest MAX_ENTRIES are kept and long
|
||||
values are shortened, so the file stays small. Recording never fails a request:
|
||||
the log is a convenience, not part of the action.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
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
|
||||
|
||||
# Write routes worth a line in the log → the action id the browser has words for. Scans are
|
||||
# recorded when they end, with what they found (api/scan_report.py), not here.
|
||||
ROUTE_ACTIONS = {
|
||||
'/anomalous/delete_model': 'model_delete',
|
||||
'/anomalous/update_metadata': 'model_edit',
|
||||
'/anomalous/set_custom_cover': 'model_cover',
|
||||
'/anomalous/upload_custom_cover': 'model_cover',
|
||||
'/anomalous/save_recipe': 'recipe_save',
|
||||
'/anomalous/update_recipe': 'recipe_edit',
|
||||
'/anomalous/delete_recipe': 'recipe_delete',
|
||||
'/anomalous/restore_recipe_version': 'recipe_restore',
|
||||
'/anomalous/set_recipe_gallery_cover': 'recipe_cover',
|
||||
'/anomalous/import_recipe_package_commit': 'recipe_import',
|
||||
'/anomalous/save_image_material': 'material_save',
|
||||
'/anomalous/save_prompt_plan': 'material_save',
|
||||
'/anomalous/update_material': 'material_edit',
|
||||
'/anomalous/delete_material': 'material_delete',
|
||||
'/anomalous/save_notebook': 'note_save',
|
||||
'/anomalous/delete_notebook': 'note_delete',
|
||||
'/anomalous/save_parameter': 'preset_save',
|
||||
'/anomalous/rename_parameter': 'preset_rename',
|
||||
'/anomalous/delete_parameter': 'preset_delete',
|
||||
'/anomalous/delete_gallery_image': 'image_delete',
|
||||
'/anomalous/delete_audio_gallery': 'audio_delete',
|
||||
'/anomalous/save_audio_preview': 'audio_save',
|
||||
'/anomalous/clear_cache': 'cache_clear',
|
||||
'/anomalous_tts/import/commit': 'voice_import',
|
||||
'/anomalous_tts/settings': 'voice_settings',
|
||||
}
|
||||
# Request fields that name what was changed, in order of preference.
|
||||
TARGET_FIELDS = ('name', 'character', 'filename', 'target_filename', 'recipe_filename',
|
||||
'notebook_filename', 'new_name', 'path', 'file', 'target')
|
||||
|
||||
# What a handler attaches to its request for the log: request[ACTIVITY_DETAIL] = {fields, files}.
|
||||
# web.RequestKey is newer than the aiohttp some ComfyUI installs have; a plain key works there.
|
||||
ACTIVITY_DETAIL = web.RequestKey("activity_detail", dict) if hasattr(web, "RequestKey") else "activity_detail"
|
||||
|
||||
_lock = threading.RLock()
|
||||
_entries = None
|
||||
_listeners = [] # called with each new entry once it is saved (ai_tasks.note_activity)
|
||||
|
||||
|
||||
def log_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, 'activity_log.json')
|
||||
|
||||
|
||||
def _load():
|
||||
global _entries
|
||||
if _entries is None:
|
||||
try:
|
||||
with open(log_path(), encoding='utf-8') as source:
|
||||
data = json.load(source)
|
||||
_entries = [entry for entry in data if isinstance(entry, dict)] if isinstance(data, list) else []
|
||||
except (OSError, ValueError):
|
||||
_entries = []
|
||||
return _entries
|
||||
|
||||
|
||||
def _encoded_size(entries):
|
||||
return len(json.dumps(entries, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
|
||||
|
||||
|
||||
def _trim(entries):
|
||||
"""Drop the oldest entries until the list fits MAX_ENTRIES and MAX_LOG_BYTES."""
|
||||
del entries[MAX_ENTRIES:]
|
||||
while len(entries) > 1 and _encoded_size(entries) > MAX_LOG_BYTES:
|
||||
del entries[max(1, len(entries) * 9 // 10):]
|
||||
|
||||
|
||||
def _text(value):
|
||||
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _short(value, limit=MAX_VALUE_CHARS):
|
||||
text = _text(value)
|
||||
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], 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_CHANGE_CHARS and len(new) <= MAX_CHANGE_CHARS:
|
||||
return out
|
||||
same = 0
|
||||
while same < min(len(old), len(new)) and old[same] == new[same]:
|
||||
same += 1
|
||||
start = max(0, same - DIFF_CONTEXT_CHARS)
|
||||
|
||||
def cut(text):
|
||||
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
|
||||
|
||||
|
||||
def add_entry(source, action, target='', detail=None):
|
||||
"""Append one entry (newest first) and save. Returns the entry."""
|
||||
entry = {
|
||||
'id': uuid.uuid4().hex[:12],
|
||||
'time': time.time(),
|
||||
'source': source,
|
||||
'action': action,
|
||||
'target': _short(target or ''),
|
||||
}
|
||||
if detail:
|
||||
entry['detail'] = detail
|
||||
with _lock:
|
||||
entries = _load()
|
||||
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()
|
||||
picked = [entry for entry in entries if not source or entry.get('source') == source]
|
||||
return picked[:limit]
|
||||
|
||||
|
||||
def clear():
|
||||
global _entries
|
||||
with _lock:
|
||||
_entries = []
|
||||
atomic_write_json(log_path(), [])
|
||||
|
||||
|
||||
def _target_of(body):
|
||||
if not isinstance(body, dict):
|
||||
return ''
|
||||
for field in TARGET_FIELDS:
|
||||
value = body.get(field)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return os.path.basename(value.strip().rstrip('/\\')) or value.strip()
|
||||
return ''
|
||||
|
||||
|
||||
def _failed(response):
|
||||
"""True when a response reports failure, including the 200 + {"status": "error"} style."""
|
||||
if response.status >= 300:
|
||||
return True
|
||||
if getattr(response, 'content_type', '') != 'application/json' or not getattr(response, 'body', None):
|
||||
return False
|
||||
try:
|
||||
data = json.loads(response.body)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return isinstance(data, dict) and (data.get('success') is False or data.get('ok') is False
|
||||
or data.get('status') in ('error', 'failed'))
|
||||
|
||||
|
||||
async def _request_body(request):
|
||||
"""The body the handler already read (aiohttp caches it); {} when it was not JSON or a form."""
|
||||
try:
|
||||
if request.content_type == 'application/json':
|
||||
return await request.json()
|
||||
if request.content_type in ('multipart/form-data', 'application/x-www-form-urlencoded'):
|
||||
return dict(await request.post())
|
||||
except (ValueError, UnicodeDecodeError, RuntimeError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _clean_detail(detail):
|
||||
"""What a handler attached for the log (request[ACTIVITY_DETAIL]): the fields it changed
|
||||
({field, before, after}) and the files it moved, values shortened."""
|
||||
if not isinstance(detail, dict):
|
||||
return None
|
||||
cleaned = {}
|
||||
if isinstance(detail.get('fields'), list):
|
||||
cleaned['fields'] = [_short_values(item, ('field', 'before', 'after'))
|
||||
for item in detail['fields'][:MAX_CHANGES] if isinstance(item, dict)]
|
||||
if isinstance(detail.get('files'), list):
|
||||
cleaned['files'] = [_short(name) for name in detail['files'][:MAX_CHANGES]]
|
||||
return {key: value for key, value in cleaned.items() if value} or None
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def activity_middleware(request, handler):
|
||||
response = await handler(request)
|
||||
action = ROUTE_ACTIONS.get(request.path) if request.method == 'POST' else None
|
||||
if action and not _failed(response):
|
||||
try:
|
||||
target = _target_of(await _request_body(request))
|
||||
detail = _clean_detail(request.get(ACTIVITY_DETAIL))
|
||||
await asyncio.get_running_loop().run_in_executor(None, add_entry, 'file', action, target, detail)
|
||||
except Exception: # noqa: BLE001 - the log must never fail the action it records
|
||||
logging.getLogger(__name__).warning('Anomalous activity log: could not record %s', request.path, exc_info=True)
|
||||
return response
|
||||
|
||||
|
||||
def _clean_changes(changes):
|
||||
cleaned = []
|
||||
for change in changes[:MAX_CHANGES]:
|
||||
if not isinstance(change, dict):
|
||||
continue
|
||||
item = _short_values(change, ('kind', 'node', 'type', 'widget', 'before', 'after'))
|
||||
if item.get('kind') in ('changed', 'wired', 'added', 'removed', 'opened'):
|
||||
cleaned.append(item)
|
||||
return cleaned
|
||||
|
||||
|
||||
async def api_get_activity(request):
|
||||
try:
|
||||
limit = max(1, min(MAX_ENTRIES, int(request.query.get('limit', '100'))))
|
||||
except ValueError:
|
||||
limit = 100
|
||||
source = request.query.get('source') or None
|
||||
entries = await asyncio.get_running_loop().run_in_executor(None, list_entries, limit, source)
|
||||
return web.json_response({'entries': entries})
|
||||
|
||||
|
||||
async def api_post_activity(request):
|
||||
"""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:
|
||||
return web.json_response({'error': 'Invalid JSON body'}, status=400)
|
||||
if not isinstance(body, dict) or not isinstance(body.get('changes'), list):
|
||||
return web.json_response({'error': 'changes must be a list'}, status=400)
|
||||
changes = _clean_changes(body['changes'])
|
||||
if not changes:
|
||||
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})
|
||||
|
||||
|
||||
async def api_clear_activity(request):
|
||||
await asyncio.get_running_loop().run_in_executor(None, clear)
|
||||
return web.json_response({'ok': True})
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
if hasattr(app, 'middlewares') and activity_middleware not in app.middlewares:
|
||||
app.middlewares.append(activity_middleware)
|
||||
app.router.add_get('/anomalous/activity', api_get_activity)
|
||||
app.router.add_post('/anomalous/activity', api_post_activity)
|
||||
app.router.add_post('/anomalous/activity/clear', api_clear_activity)
|
||||
@@ -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 or picks block 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 or picks block 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 block.get("items") or [] if option.get("recommended")]
|
||||
if block.get("type") not in ("choices", "picks") 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
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
"""The bases the user picks from (run_workflow base_only; the cut: web/modules/base_only.js). When
|
||||
such a run ends its previews, in ComfyUI's temp folder (emptied whenever ComfyUI starts or stops),
|
||||
become a picks panel. The one the user picks is copied into the output folder, beside where the
|
||||
canvas saves its base, and its seed is held: the task's next run_workflow puts it on that sampler,
|
||||
"fixed", kept under every try (web/modules/mcp_bridge.js). Where the rest of the workflow takes the
|
||||
base's picture, it takes the kept picture and the base is not drawn again: drawn again, even from the
|
||||
same seed and settings, it came out grainy once on a 6 GB card (2026-11-04). A base handed on as a
|
||||
latent (the mid-way switch) is drawn again from its seed; when that run saves its base, the picture is
|
||||
compared with the kept one, so the user and the AI know whether the second pass grew from it."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from PIL import Image, ImageChops, ImageStat
|
||||
|
||||
from . import ai_outside, ai_panels, ai_pictures, ai_tasks
|
||||
|
||||
SAME_BELOW = 1.0 # mean difference per channel (0-255); the same seed and settings drew the same picture
|
||||
PREFIX = "amb_base/base" # where a picked base goes when the canvas saves none of its own
|
||||
TITLE = "挑一张底图 / Pick a base"
|
||||
QUESTION = ("挑中的那张会存进输出文件夹,下一步用它的种子接着画 / "
|
||||
"The one you pick is kept in the output folder; the next pass grows from its seed")
|
||||
|
||||
|
||||
def _step(task, step_id):
|
||||
return next((item for item in (task or {}).get("steps") or [] if item.get("id") == step_id), None)
|
||||
|
||||
|
||||
def hold_of(task_id):
|
||||
"""The base the user picked in task `task_id`, for run_workflow to hold, or None."""
|
||||
task = ai_tasks.get_task(task_id) if task_id else None
|
||||
return dict((task or {}).get("base_hold") or {}) or None
|
||||
|
||||
|
||||
def forget(task_id):
|
||||
"""New bases are coming: the earlier pick no longer holds."""
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id) if task_id else None
|
||||
if task and task.pop("base_hold", None) is not None:
|
||||
ai_tasks._save(task)
|
||||
|
||||
|
||||
def _seed(prompt, base):
|
||||
inputs = (prompt.get(str(base["node"])) or {}).get("inputs") or {}
|
||||
return next((inputs[name] for name in base.get("names") or [] if isinstance(inputs.get(name), int)), None)
|
||||
|
||||
|
||||
def offer(task_id, step_id, prompts, history):
|
||||
"""A finished base_only run: its previews on a picks panel, each with the seed that drew it."""
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = _step(task, step_id)
|
||||
info = (step or {}).get("bases")
|
||||
if not info or step.get("state") != "done" or step.get("offered"):
|
||||
return None
|
||||
step["offered"] = True
|
||||
client = task.get("client") or "AI"
|
||||
ai_tasks._save(task)
|
||||
items, picks = [], {}
|
||||
for prompt_id in prompts:
|
||||
record = history.get(prompt_id) or {}
|
||||
prompt = (record.get("prompt") or [None, None, {}])[2] or {}
|
||||
base = next((base for base in info["bases"] if _seed(prompt, base) is not None), info["bases"][0])
|
||||
for show in info["shows"]:
|
||||
for image in ((record.get("outputs") or {}).get(str(show)) or {}).get("images") or []:
|
||||
if image.get("type") != "temp" or len(items) >= ai_panels.MAX_ITEMS:
|
||||
continue
|
||||
rel = "/".join(part for part in (image.get("subfolder"), image.get("filename")) if part)
|
||||
seed, item_id = _seed(prompt, base), f"b{len(items) + 1}"
|
||||
items.append({"id": item_id, "image": rel, "folder": "temp", "label": f"{len(items) + 1} · seed {seed}"})
|
||||
picks[item_id] = {"image": rel, "seed": seed, "node": str(base["node"]), "type": base["type"],
|
||||
"names": base.get("names") or [], "show": str(show) if show in info.get("saved", []) else None}
|
||||
if not items:
|
||||
return None
|
||||
block = {"type": "picks", "id": "base", "question": QUESTION, "question_key": "aiBaseQuestion", "items": items}
|
||||
return ai_panels.show(client, TITLE, [block], task_id=task_id, tool="pick_base", title_key="aiBaseTitle",
|
||||
extra={"bases": picks, "prefix": info.get("prefix") or PREFIX})
|
||||
|
||||
|
||||
def prepare(task_id, step_id, choice, skip=False):
|
||||
"""The user's pick on a bases panel, made before it is recorded: the picture copied into the output
|
||||
folder; {} for other panels."""
|
||||
step = _step(ai_tasks.get_task(task_id), step_id)
|
||||
bases = (step or {}).get("bases")
|
||||
if not bases or skip:
|
||||
return {}
|
||||
picked = bases.get(str((choice if isinstance(choice, list) else [choice])[0] if choice else ""))
|
||||
if not picked:
|
||||
return {}
|
||||
import folder_paths
|
||||
|
||||
source = os.path.join(folder_paths.get_temp_directory(), *picked["image"].split("/"))
|
||||
if not os.path.isfile(source):
|
||||
raise ValueError(f"这张预览已经没了:ComfyUI 重启时会清空临时文件夹。让 AI 用种子 {picked['seed']} 再画一次 / "
|
||||
f"This preview is gone: ComfyUI empties its temp folder when it restarts. Ask the AI to draw it "
|
||||
f"again with seed {picked['seed']}")
|
||||
with Image.open(source) as picture:
|
||||
width, height = picture.size
|
||||
folder, name, counter, subfolder, _prefix = folder_paths.get_save_image_path(
|
||||
step.get("prefix") or PREFIX, folder_paths.get_output_directory(), width, height)
|
||||
target = f"{name}_{counter:05}_.png"
|
||||
ai_outside.own(target)
|
||||
shutil.copyfile(source, os.path.join(folder, target)) # its prompt and workflow come with it
|
||||
kept = "/".join(part for part in (subfolder, target) if part)
|
||||
picked = {**picked, "kept": kept, "kept_input": ai_pictures.to_input(kept)} # what LoadImage reads in the next run
|
||||
return {"base": picked, "kept": kept, "seed": picked["seed"],
|
||||
"note": f"Anomalous keeps this base as {kept} and holds seed {picked['seed']} on node {picked['node']} "
|
||||
"for your next run_workflow: run the whole workflow now; do not change that seed."}
|
||||
|
||||
|
||||
def discard(prepared):
|
||||
"""The copy `prepare` made, when the pick was refused after all (answered already)."""
|
||||
if (prepared or {}).get("base"):
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
os.remove(os.path.join(folder_paths.get_output_directory(), *prepared["kept"].split("/")))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def hold(task_id, extra):
|
||||
"""Holds the base the user just picked (prepare's answer) for the task's next runs."""
|
||||
base = (extra or {}).get("base")
|
||||
if not base:
|
||||
return
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if task:
|
||||
task["base_hold"] = {key: base[key] for key in ("node", "type", "names", "seed", "kept", "kept_input", "show")}
|
||||
ai_tasks._save(task)
|
||||
|
||||
|
||||
def same(first, second):
|
||||
"""The mean difference of two pictures per channel (0-255), or None when they differ in size."""
|
||||
with Image.open(first) as a, Image.open(second) as b:
|
||||
if a.size != b.size:
|
||||
return None
|
||||
return sum(ImageStat.Stat(ImageChops.difference(a.convert("RGB"), b.convert("RGB"))).mean) / 3
|
||||
|
||||
|
||||
def check(task_id, step_id, prompts, history):
|
||||
"""A run that held a picked base and saved its base again: is it the picked picture?"""
|
||||
import folder_paths
|
||||
|
||||
with ai_tasks._lock:
|
||||
step = _step(ai_tasks._load().get(task_id), step_id)
|
||||
asked = dict((step or {}).get("base_check") or {})
|
||||
if not asked or "same" in asked:
|
||||
return
|
||||
output = folder_paths.get_output_directory()
|
||||
record = next((history.get(prompt_id) for prompt_id in prompts if history.get(prompt_id)), None) or {}
|
||||
image = next((item for item in ((record.get("outputs") or {}).get(asked["show"]) or {}).get("images") or []
|
||||
if item.get("type") == "output"), None)
|
||||
if not image:
|
||||
return
|
||||
try:
|
||||
diff = same(os.path.join(output, *asked["kept"].split("/")),
|
||||
os.path.join(output, image.get("subfolder") or "", image["filename"]))
|
||||
except OSError:
|
||||
return
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = _step(task, step_id)
|
||||
if step is None:
|
||||
return
|
||||
step["base_check"] = {**asked, "same": diff is not None and diff < SAME_BELOW, "diff": None if diff is None else round(diff, 2)}
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
|
||||
|
||||
def words(run):
|
||||
"""What the AI is told of a run's base: the bases offered, or whether the picked one held."""
|
||||
if run.get("bases") and run.get("state") == "done":
|
||||
return ("Anomalous showed the user these bases to pick from (a panel); get_answers waits for their pick, "
|
||||
"then run the whole workflow: the picked base is kept and its seed held.")
|
||||
check_ = run.get("base_check") or {}
|
||||
if check_.get("same") is True:
|
||||
return f"Its base is the one the user picked ({check_['kept']})."
|
||||
if check_.get("same") is False:
|
||||
return (f"Its base is NOT the one the user picked ({check_['kept']}): something before the second pass changed "
|
||||
"(prompt, model, LoRA, size or a setting). Tell the user (report) before going on.")
|
||||
return ""
|
||||
@@ -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,105 @@
|
||||
"""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_privacy":
|
||||
out.append({"type": "privacy", "step": step["id"], "at": step["at"], "on": bool(step.get("privacy"))})
|
||||
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"))
|
||||
elif item["type"] == "privacy":
|
||||
words.append("The user turned privacy mode " + ("on: no pictures reach you" if item["on"] else "off"))
|
||||
else:
|
||||
words.append("The user stopped a run")
|
||||
return words
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
"""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);
|
||||
which custom nodes the AI may add itself (node_rule): any, or only those of the ready-made ways;
|
||||
and a new route (route_rule): an AI placing nodes by itself rather than through a way, card or route
|
||||
builds something nobody tried. "allow" goes on with one reminder in the task that a stronger model
|
||||
suits that better; "ask" waits for the user's OK, once a task.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import action_catalog, ai_tasks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
POLICIES = ("auto", "ask", "never")
|
||||
RUN_WAITS = ("call", "wait")
|
||||
NODE_RULES = ("any", "ways")
|
||||
ROUTE_RULES = ("allow", "ask")
|
||||
NEW_ROUTE_NOTE = ("AI 在自己搭一条新路线:现成的卡、做法里没有,还没验证过。跑完看看效果,好的话存成卡,以后日常用;也可以让它用最接近的现成路线。 / "
|
||||
"The AI is building a new route of its own: no card or way has it, so it is untried. See how the run turns out; "
|
||||
"if you like it, save it as a card for daily use. Or have it use the nearest route there is.")
|
||||
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 node_rule():
|
||||
"""Which custom nodes an AI adds itself: "any", or "ways": only those the ready-made ways build
|
||||
(reference_blocks.node_types) and those the user picked for the task (mcp_canvas_edit._allowed)."""
|
||||
chosen = _tool_settings().get("nodes")
|
||||
return chosen if chosen in NODE_RULES else "any"
|
||||
|
||||
|
||||
def set_node_rule(mode):
|
||||
if mode not in NODE_RULES:
|
||||
raise ValueError("nodes is any or ways")
|
||||
return _set_tool_setting("nodes", mode)
|
||||
|
||||
|
||||
def route_rule():
|
||||
"""An AI placing nodes by itself: "allow" (with a reminder) or "ask" (the user's OK, once a task)."""
|
||||
chosen = _tool_settings().get("routes")
|
||||
return chosen if chosen in ROUTE_RULES else "allow"
|
||||
|
||||
|
||||
def set_route_rule(mode):
|
||||
if mode not in ROUTE_RULES:
|
||||
raise ValueError("routes is allow or ask")
|
||||
return _set_tool_setting("routes", mode)
|
||||
|
||||
|
||||
def _new_route(tool, arguments):
|
||||
"""Whether this call places nodes the AI chose itself: not a way, a card or a route."""
|
||||
return tool == "add_node" or (tool in ("add_nodes", "new_workflow") and bool((arguments or {}).get("nodes")))
|
||||
|
||||
|
||||
def _route_gate(tool, arguments, step_ref, now):
|
||||
"""True when this call must wait for the user's OK on a new route; else the task's one reminder is left there."""
|
||||
if not _new_route(tool, arguments):
|
||||
return False
|
||||
with _decided:
|
||||
task, _step_now = _step(step_ref)
|
||||
steps = (task or {}).get("steps") or []
|
||||
if route_rule() == "ask":
|
||||
return not any((step.get("gate") or {}).get("gate") == "route" and (step.get("gate") or {}).get("decision") == "allow" for step in steps)
|
||||
if task and not any(step.get("tool") == "new_route" for step in steps):
|
||||
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "new_route", "kind": "note", "ok": True,
|
||||
"state": "done", "summary": "new_route", "text": NEW_ROUTE_NOTE, "results": []})
|
||||
ai_tasks._save(task)
|
||||
return False
|
||||
|
||||
|
||||
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"
|
||||
now = time.time() if now is None else now
|
||||
if policy == "auto" and _route_gate(tool, arguments, step_ref, now):
|
||||
policy, gate = "ask", "route"
|
||||
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).")
|
||||
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_route_rule("allow") if asked["gate"] == "route" else 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,203 @@
|
||||
"""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, deque
|
||||
|
||||
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)}}
|
||||
_own = deque(maxlen=200) # pictures Anomalous itself put at the top of the output folder
|
||||
|
||||
|
||||
def own(name):
|
||||
"""Picture `name` was put there by Anomalous (a base the user picked, ai_bases.py): no news."""
|
||||
_own.append(name)
|
||||
|
||||
|
||||
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 or name in _own):
|
||||
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,133 @@
|
||||
"""The parts of the blocks the AI sends to a panel (ai_panels.py), each checked to one plain shape:
|
||||
text and lists of bounded size, fractions of a picture, marks (circles or boxes with a label),
|
||||
the part of the pictures shown enlarged (zoom), pictures only from ComfyUI's output folder,
|
||||
diagram boxes and arrows, and model cards only from the user's folders."""
|
||||
|
||||
import os
|
||||
|
||||
from . import ai_tasks
|
||||
|
||||
MAX_ITEMS = 12
|
||||
COLORS = ("red", "green", "blue", "yellow", "purple")
|
||||
MAX_MARKS = 8
|
||||
MAX_NODES = 24
|
||||
MAX_EDGES = 40
|
||||
|
||||
|
||||
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
|
||||
temp = isinstance(item, dict) and item.get("folder") == "temp" # a run's preview (run_status previews)
|
||||
found = ai_tasks._output_media(str(image or ""), "temp" if temp else "output")
|
||||
if not found:
|
||||
raise PanelError(f"{where}: {image!r} is not a picture in ComfyUI's {'temp' if temp else 'output'} folder "
|
||||
"(subfolder/name.png, as search_images or run_status 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 _zoom(value, where):
|
||||
"""The part of every picture of a slider or picks block shown enlarged: {x, y, w, h} as fractions,
|
||||
x/y its centre (as a mark's box), kept inside the picture: {left, top, w, h}."""
|
||||
try:
|
||||
x, y, w, h = (float(value[key]) for key in ("x", "y", "w", "h"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise PanelError(f"{where} must be {{x, y, w, h}}: fractions 0-1 of the picture, x/y the centre")
|
||||
if not (0 <= x <= 1 and 0 <= y <= 1 and 0.02 <= w <= 1 and 0.02 <= h <= 1):
|
||||
raise PanelError(f"{where}: x and y 0-1, w and h 0.02-1")
|
||||
return {"left": round(min(max(x - w / 2, 0), 1 - w), 4), "top": round(min(max(y - h / 2, 0), 1 - h), 4),
|
||||
"w": round(w, 4), "h": round(h, 4)}
|
||||
|
||||
|
||||
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")}
|
||||
@@ -0,0 +1,515 @@
|
||||
"""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),
|
||||
prompts (the user's prompt box: texts and reference pictures the AI gets as tokens, ai_sealed.py),
|
||||
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 parts: ai_panel_parts.py). 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 re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import ai_marks, ai_tasks
|
||||
from .ai_panel_parts import ( # noqa: F401 - PanelError and the limits are read as ai_panels.*
|
||||
MAX_ITEMS, MAX_NODES, PanelError, _diagram, _image, _list, _model, _text, _zoom,
|
||||
)
|
||||
from .mcp_tools import tool_text
|
||||
|
||||
MAX_BLOCKS = 30
|
||||
MAX_ROWS = 30
|
||||
MAX_COLUMNS = 8
|
||||
MAX_WAIT = 55
|
||||
TONES = ("normal", "muted", "tip", "warning")
|
||||
LAYOUTS = ("row", "grid", "compare")
|
||||
INTERACTIVE = ("choices", "input", "picks", "prompts")
|
||||
MAX_PROMPT_BOXES = 8
|
||||
INPAINT_NOTE = (
|
||||
"inpaint_image is the picture with the area to repaint as its alpha, already in ComfyUI's input folder: do not "
|
||||
"check it with a command (get_image_info(image=inpaint_image, folder='input', include_image=true) shows it). "
|
||||
"Repaint with a workflow, never by painting the file with code. On a workflow that makes this picture (its model "
|
||||
"and prompt: get_image_info on the picture says them), add_nodes(use=\"repaint\", image=inpaint_image) "
|
||||
"builds the repaint in one call, wired into its sampler (the best repaint this ComfyUI can run); set_prompt "
|
||||
"the sampler's prompt to what belongs in the area (\"a hand, five fingers\"), not the whole picture. Keep the block's low denoise (it keeps the thing's size; higher paints a new one filling the whole "
|
||||
"area). run_workflow with batch 4 (each try gets its own seed) and let the user pick in a picks panel zoomed on the "
|
||||
"area (zoom {x, y, w, h} around it), then show the old and new with a slider panel, zoomed the same. Say in "
|
||||
"report what you set and why."
|
||||
)
|
||||
|
||||
_answered = threading.Condition(ai_tasks._lock)
|
||||
|
||||
|
||||
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"))
|
||||
if block.get("zoom"):
|
||||
out["zoom"] = _zoom(block["zoom"], f"{where}.zoom")
|
||||
elif kind == "picks":
|
||||
items, ids = [], set()
|
||||
raw = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
|
||||
if not raw: # one is "take this one" (a single base, ai_bases.py)
|
||||
raise PanelError(f"{where}.items needs 1 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)
|
||||
if _page_word(block.get("question_key")):
|
||||
out["question_key"] = block["question_key"]
|
||||
if block.get("zoom"):
|
||||
out["zoom"] = _zoom(block["zoom"], f"{where}.zoom")
|
||||
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 {}),
|
||||
**({key: option[key] for key in ("label_key", "detail_key") if _page_word(option.get(key))}),
|
||||
**({"values": {str(key): _text(value, 120, f"{where}.options[{i}].values") for key, value in option["values"].items()}}
|
||||
if isinstance(option.get("values"), dict) and _page_word(option.get("label_key")) else {}),
|
||||
**({"sample": option["sample"]} if re.fullmatch(r"[a-z_]{1,40}", str(option.get("sample"))) 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 == "prompts": # filled in Anomalous only: the texts are sealed and the AI gets tokens
|
||||
count = block.get("count", 1)
|
||||
if not isinstance(count, int) or isinstance(count, bool) or not 1 <= count <= MAX_PROMPT_BOXES:
|
||||
raise PanelError(f"{where}.count: 1 to {MAX_PROMPT_BOXES} prompts")
|
||||
out.update(question=_text(block.get("question"), 200, f"{where}.question"), count=count,
|
||||
negative=block.get("negative") is True, pictures=block.get("pictures") is True)
|
||||
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, prompts, 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 _page_word(key):
|
||||
"""A word of the page (locales.js) Anomalous's own panels name, shown in the user's language."""
|
||||
return bool(re.fullmatch(r"ai[A-Z]\w{1,40}", str(key)))
|
||||
|
||||
|
||||
def show(client, title, blocks, now=None, session="", task_id=None, tool="show_panel", extra=None, title_key=None):
|
||||
title = _text(title, 120, "title", True)
|
||||
panel = {"id": uuid.uuid4().hex[:10], "title": title, "blocks": _block_ids(clean_blocks(blocks)), "closed": False,
|
||||
**({"title_key": title_key} if _page_word(title_key) else {})}
|
||||
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"] == "prompts":
|
||||
if not (extra or {}).get("prompts"):
|
||||
raise PanelError("Prompts are written in Anomalous's prompt box")
|
||||
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])
|
||||
if (step.get("routes") or {}).get(picked[0]): # what to call for a route (mcp_routes.py)
|
||||
entry["note"] = step["routes"][picked[0]]
|
||||
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", "prompts", "negative", "pictures") 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, models and ways 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,190 @@
|
||||
"""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 hashlib
|
||||
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 to_input(image):
|
||||
"""Picture `image` as a file in ComfyUI's input folder, where LoadImage reads (a control block's
|
||||
reference, run_workflow's image): its name there. One already there stays itself; an output picture
|
||||
("subfolder/name.png") is copied, its transparency kept (LoadImage's MASK), named by its content so
|
||||
the same picture is one file, never over another. Not "name.png [output]": ComfyUI's page flags that
|
||||
as a missing file once its run history is gone, though LoadImage reads it."""
|
||||
from .mcp_tools import _resolve_folder_image
|
||||
|
||||
rel = str(image or "").replace("\\", "/").strip("/")
|
||||
if rel.endswith(" [output]"): # ComfyUI's mark for an output picture, as Load Image shows it
|
||||
rel = rel[: -len(" [output]")]
|
||||
try:
|
||||
return _resolve_folder_image(rel, "input")[0]
|
||||
except ToolError:
|
||||
pass
|
||||
with open(_resolve_image(rel)[1], "rb") as source:
|
||||
data = source.read()
|
||||
name = f"anomalous_reference_{hashlib.sha1(data).hexdigest()[:12]}.png"
|
||||
target = os.path.join(ai_tasks.folder_paths.get_input_directory(), name)
|
||||
if not os.path.isfile(target):
|
||||
try:
|
||||
with Image.open(io.BytesIO(data)) as picture, open(target, "xb") as out:
|
||||
clear = "A" in picture.getbands() or "transparency" in picture.info
|
||||
picture.convert("RGBA" if clear else "RGB").save(out, "PNG")
|
||||
except FileExistsError:
|
||||
pass # the same picture, copied a moment ago
|
||||
except OSError:
|
||||
raise ToolError(f"{image} cannot be read as a picture.")
|
||||
return name
|
||||
|
||||
|
||||
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,102 @@
|
||||
"""Privacy mode (隐私模式): a task the user keeps private sends the AI none of its pictures and no prompt
|
||||
text. The AI still builds and runs workflows and knows the models; it hears how many pictures came out
|
||||
and whether the run went well, and the user looks at them in Anomalous. Prompts and picture names
|
||||
reach it as tokens it passes on (ai_sealed.py); the user writes prompts in a prompt box. Each task has its switch (task["privacy"],
|
||||
in the task's header); new tasks follow the default the user sets in the Gates window
|
||||
(user/anomalous/ai_privacy.json). Turning it on or off is a step the AI is told of, and one of the
|
||||
task's decisions (ai_decisions.py).
|
||||
|
||||
All of it happens at one place: mcp_server.py puts the texts back into a call's arguments
|
||||
(ai_sealed.expand) and hands every tool result to guard() before it leaves, whatever tool or later
|
||||
step (run pictures, the user's messages) put a picture or a prompt in it. A call that
|
||||
cannot be tied to a task is private while the default is on or any private task is unfinished:
|
||||
when in doubt, nothing is shown. The card in the AI app (mcp_apps.py) shows no pictures either.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import ai_sealed, ai_tasks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
NOTE = ("Privacy mode: no pictures or prompt text reach you, only tokens like [P1] and [img1] to pass on as they "
|
||||
"are. get_toolbox(skill=\"privacy\") first.")
|
||||
|
||||
|
||||
def _path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_privacy.json")
|
||||
|
||||
|
||||
def default_on():
|
||||
"""Whether new tasks are private (the Gates window); off unless the user turned it on."""
|
||||
try:
|
||||
with open(_path(), encoding="utf-8") as source:
|
||||
return json.load(source).get("default") is True
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def set_default(on):
|
||||
"""The default for new tasks. Turned off, the tasks that were private by it stay private."""
|
||||
if not isinstance(on, bool):
|
||||
raise ValueError("privacy is true or false")
|
||||
with ai_tasks._lock:
|
||||
if not on and default_on():
|
||||
for task in ai_tasks._load().values():
|
||||
if "privacy" not in task:
|
||||
task["privacy"] = True
|
||||
ai_tasks._save(task)
|
||||
atomic_write_json(_path(), {"default": on})
|
||||
return on
|
||||
|
||||
|
||||
def is_private(task):
|
||||
"""The task's own choice, else the default."""
|
||||
value = (task or {}).get("privacy")
|
||||
return value if isinstance(value, bool) else default_on()
|
||||
|
||||
|
||||
def set_private(task_id, on):
|
||||
"""The user's choice for one task, told to the AI as a step of theirs. Returns it."""
|
||||
if not isinstance(on, bool):
|
||||
raise ValueError("privacy is true or false")
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if not task:
|
||||
raise ValueError("No such task")
|
||||
if is_private(task) != on:
|
||||
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": time.time(), "ms": 0, "tool": "user_privacy", "kind": "user",
|
||||
"ok": True, "state": "done", "summary": "user_privacy", "privacy": on, "delivered": False, "results": [],
|
||||
"text": "The user turned privacy mode on for this task. " + NOTE if on else
|
||||
"The user turned privacy mode off for this task: you get the pictures again."})
|
||||
task["privacy"] = on
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return on
|
||||
|
||||
|
||||
def applies(task_id):
|
||||
"""Whether a call of task `task_id` (None: none known) is private."""
|
||||
task = ai_tasks.get_task(task_id) if task_id else None
|
||||
if task:
|
||||
return is_private(task)
|
||||
if default_on():
|
||||
return True
|
||||
with ai_tasks._lock:
|
||||
return any(item.get("privacy") is True and not item.get("ended") for item in ai_tasks._load().values())
|
||||
|
||||
|
||||
def guard(result, private, task_id=None, info=None, tool=""):
|
||||
"""`result` as it may leave: a private call's pictures taken out (with a word on why), its prompt
|
||||
text and picture names as tokens (ai_sealed.mask; `info`: ComfyUI's node list)."""
|
||||
if not private or not isinstance(result, dict):
|
||||
return result
|
||||
content = [item for item in result.get("content") or [] if not (isinstance(item, dict) and item.get("type") == "image")]
|
||||
if len(content) != len(result.get("content") or []):
|
||||
content.append({"type": "text", "text": NOTE})
|
||||
result["content"] = content
|
||||
if isinstance(result.get("structuredContent"), dict):
|
||||
result["structuredContent"] = {**result["structuredContent"], "privacy": NOTE}
|
||||
return ai_sealed.mask(result, task_id, info, tool)
|
||||
@@ -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,90 @@
|
||||
"""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.
|
||||
And the user's word on a finished run (set_verdict, the buttons under its pictures): it turned out
|
||||
well, or not, kept on the run's step and told to the AI as their message."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import ai_task_runs, ai_tasks
|
||||
|
||||
|
||||
VERDICTS = ("good", "bad")
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
def set_verdict(task_id, step_id, verdict):
|
||||
"""The user's word on finished run `step_id` of task `task_id`: "good", "bad" or None (taken back);
|
||||
a word given goes to the AI. Returns it."""
|
||||
if verdict not in (*VERDICTS, None):
|
||||
raise RunControlError("verdict is good, bad or null")
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
steps = (task or {}).get("steps", [])
|
||||
step = next((item for item in steps if item["id"] == step_id), None)
|
||||
if not step or step.get("tool") != "run_workflow" or step.get("state") != "done":
|
||||
raise RunControlError("No such finished run")
|
||||
if step.get("verdict") == verdict:
|
||||
return verdict
|
||||
if verdict:
|
||||
step["verdict"] = verdict
|
||||
number = sum(1 for item in steps[:steps.index(step) + 1] if item.get("tool") == "run_workflow")
|
||||
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": time.time(), "ms": 0, "tool": "user_verdict", "kind": "user",
|
||||
"ok": True, "state": "done", "summary": "user_verdict", "verdict": verdict, "run": step_id,
|
||||
"delivered": False, "results": [], "text": (
|
||||
f"The user says run {number} of this task (step {step_id}) turned out well."
|
||||
if verdict == "good" else
|
||||
f"The user says run {number} of this task (step {step_id}) did not turn out well: "
|
||||
"change the way, a setting or the prompt before running again, or ask them what is off.")})
|
||||
else:
|
||||
step.pop("verdict", None)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return verdict
|
||||
@@ -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,239 @@
|
||||
"""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. 8.4:
|
||||
# on 2026-10-29 a 6 GB card (RTX 4050) sampled 4 megapixels (Anima, 10 steps) in about 100 s.
|
||||
COMFORT_MP_12GB = 8.4
|
||||
DECODE_MP_12GB = 8.4 # what a plain VAEDecode takes comfortably on a 12 GB card, likewise scaled
|
||||
BIG_SAVE_MP = 64 # a saved picture past this (8192x8192) is heavy for the gallery and the viewers
|
||||
PNG_BYTES_PER_PIXEL = 1.2 # what a ComfyUI PNG of a generated picture takes, roughly
|
||||
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
|
||||
|
||||
memory = max(0.5, min(3.0, (vram_total_mb or 12288) / 12288))
|
||||
comfort = COMFORT_MP_12GB * memory
|
||||
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 > DECODE_MP_12GB * memory:
|
||||
warnings.append({"kind": "big_decode", "node": str(node_id), "title": title,
|
||||
"width": round(found["width"]), "height": round(found["height"])})
|
||||
elif "Save" in str(node.get("class_type")) and _linked(inputs.get("images")):
|
||||
# What is finally saved: an upscale model after the last sampler can make it far larger.
|
||||
found = size(inputs["images"][0], set())
|
||||
if found and found["width"] * found["height"] / 1048576 > BIG_SAVE_MP:
|
||||
pixels = found["width"] * found["height"]
|
||||
warnings.append({"kind": "big_save", "node": str(node_id), "title": title, "width": round(found["width"]),
|
||||
"height": round(found["height"]), "megapixels": round(pixels / 1048576),
|
||||
"megabytes": round(pixels * PNG_BYTES_PER_PIXEL / 1048576, -1)})
|
||||
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_save":
|
||||
return (f"{warning['title']} (node {warning['node']}) saves a {warning['width']}x{warning['height']} picture "
|
||||
f"({warning['megapixels']} megapixels, about {warning['megabytes']:.0f} MB as PNG): heavy to look at in the "
|
||||
"gallery and image viewers. Unless the user asked for that size, upscale less (2x instead of 4x) or start "
|
||||
"from a smaller picture.")
|
||||
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,185 @@
|
||||
"""What a private task (ai_privacy.py) keeps from the AI besides pictures: prompt text and picture
|
||||
names. Each is sealed: kept in the task (task["sealed"], {token: text}) and given to the AI as a
|
||||
token, "[P3]" for a text and "[img2]" for a picture's name. The AI writes the token where the text
|
||||
goes (a prompt, a picture to load, its own words around it) and expand() puts the text back before
|
||||
the tool runs, so the canvas and the runs get it while the AI never reads it.
|
||||
|
||||
mask() seals what a tool's answer holds, in this order: every text input of a node (ComfyUI's node
|
||||
list says which inputs are STRING: prompts, file name prefixes; model names, numbers and choices
|
||||
stay), the prompt fields of saved prompts, combos, workflows, models and changes (PROMPT_KEYS, and
|
||||
a few per tool), picture file names anywhere, and last any sealed text wherever it still shows up.
|
||||
seal_answer() takes the user's prompt box (a "prompts" panel block, ui_ai_prompt_box.js): the
|
||||
prompts and reference pictures, sealed, with the tokens the AI gets for them.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from . import ai_tasks
|
||||
from .mcp_tools import tool_text
|
||||
|
||||
TOKEN = re.compile(r"\[(?:P|img)\d+\]")
|
||||
WORD = re.compile(r"[^\W\d_]") # a letter of any script
|
||||
PICTURE = re.compile(r"[^\s\"'<>|*?,;()\[\]{}]*\.(?:png|jpe?g|webp|gif|bmp|mp4|webm|mov)\b", re.IGNORECASE)
|
||||
MODEL_FILE = (".safetensors", ".gguf", ".ckpt", ".pt", ".pth", ".bin", ".sft", ".onnx")
|
||||
# Fields whose text is a prompt, or holds one (a workflow's settings, a change's before and after).
|
||||
PROMPT_KEYS = {"prompt", "prompts", "positive", "negative", "positive_prompt", "negative_prompt", "notes",
|
||||
"settings", "before", "after", "filename_prefix", "trigger_words", "trainedWords"}
|
||||
TOOL_KEYS = {"get_model": {"description", "user_notes"}}
|
||||
MIN_SEALED = 3 # shorter texts are not looked for elsewhere (they would match inside other words)
|
||||
MAX_PROMPTS = 8
|
||||
MAX_PROMPT = 4000
|
||||
MAX_PICTURES = 4
|
||||
_loose = {} # tokens of calls tied to no task, while this ComfyUI runs
|
||||
|
||||
|
||||
class Seals:
|
||||
"""The tokens of task `task_id` (None: the calls tied to no task); save() keeps new ones."""
|
||||
|
||||
def __init__(self, task_id):
|
||||
self.task_id = task_id
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id) if task_id else None
|
||||
self.tokens = dict(task.get("sealed") or {}) if task else _loose
|
||||
self.texts = {text: token for token, text in self.tokens.items()}
|
||||
self.changed = False
|
||||
|
||||
def seal(self, text, kind="P"):
|
||||
known = self.texts.get(text)
|
||||
if known and known.startswith(f"[{kind}"):
|
||||
return known
|
||||
token = f"[{kind}{1 + sum(1 for item in self.tokens if item.startswith(f'[{kind}'))}]"
|
||||
self.tokens[token], self.texts[text] = text, token
|
||||
self.changed = True
|
||||
return token
|
||||
|
||||
def save(self):
|
||||
if not self.changed or not self.task_id:
|
||||
return
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(self.task_id)
|
||||
if task:
|
||||
task["sealed"] = self.tokens
|
||||
ai_tasks._save(task)
|
||||
|
||||
|
||||
def expand(task_id, value):
|
||||
"""`value` (a tool's arguments) with every known token replaced by its text (a text sealed with a
|
||||
token in it, its text too)."""
|
||||
tokens = Seals(task_id).tokens
|
||||
|
||||
def text(words):
|
||||
for _ in range(3):
|
||||
words, before = TOKEN.sub(lambda match: tokens.get(match.group(0), match.group(0)), words), words
|
||||
if words == before:
|
||||
break
|
||||
return words
|
||||
return _strings(value, text)
|
||||
|
||||
|
||||
def _sealable(text):
|
||||
"""Whether `text` holds words: not only tokens (sealed already), numbers or ids (a node's "6")."""
|
||||
return bool(WORD.search(TOKEN.sub("", text)))
|
||||
|
||||
|
||||
def _text_input(info, node_type, key, value):
|
||||
"""Whether input `key` of a `node_type` node is text the user wrote (STRING in ComfyUI's node list).
|
||||
A setting the page adds to a known node ("control_after_generate"): only words with a space in them;
|
||||
a node type it does not know: any string but a model file's name."""
|
||||
spec = (info.get(node_type) or {}).get("input")
|
||||
for group in ("required", "optional"):
|
||||
found = ((spec or {}).get(group) or {}).get(key)
|
||||
if found is not None:
|
||||
return isinstance(found, (list, tuple)) and bool(found) and found[0] == "STRING"
|
||||
if spec is not None:
|
||||
return any(mark in value for mark in (" ", ","))
|
||||
return not value.lower().endswith(MODEL_FILE)
|
||||
|
||||
|
||||
def seal_text(seals, value):
|
||||
"""A prompt field's value sealed (a string, or strings in a list); a model file's name stays."""
|
||||
if isinstance(value, str):
|
||||
return value if not _sealable(value) or value.lower().endswith(MODEL_FILE) else seals.seal(value)
|
||||
if isinstance(value, list):
|
||||
return [seal_text(seals, item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _walk(value, seals, info, keys):
|
||||
if isinstance(value, list):
|
||||
return [_walk(item, seals, info, keys) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
out = {}
|
||||
node_type = value.get("class_type") or value.get("type")
|
||||
for key, item in value.items():
|
||||
if key in ("settings", "inputs", "widgets") and isinstance(item, dict) and isinstance(node_type, str):
|
||||
out[key] = {name: seals.seal(text) if isinstance(text, str) and _sealable(text) and _text_input(info, node_type, name, text)
|
||||
else _walk(text, seals, info, keys) for name, text in item.items()}
|
||||
elif key in keys and (isinstance(item, str) or isinstance(item, list) and all(isinstance(text, str) for text in item)):
|
||||
out[key] = seal_text(seals, item)
|
||||
else:
|
||||
out[key] = _walk(item, seals, info, keys)
|
||||
return out
|
||||
|
||||
|
||||
def _strings(value, change):
|
||||
if isinstance(value, dict):
|
||||
return {key: _strings(item, change) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_strings(item, change) for item in value]
|
||||
return change(value) if isinstance(value, str) else value
|
||||
|
||||
|
||||
def _hide(seals, text):
|
||||
"""`text` with every picture name in it, then every sealed text (longest first), as tokens."""
|
||||
text = PICTURE.sub(lambda match: seals.seal(match.group(0), "img"), text)
|
||||
for sealed in sorted((item for item in seals.texts if len(item) >= MIN_SEALED), key=len, reverse=True):
|
||||
if sealed in text:
|
||||
text = text.replace(sealed, seals.texts[sealed])
|
||||
return text
|
||||
|
||||
|
||||
def mask(result, task_id, info, tool=""):
|
||||
"""Tool result `result` of a private task with its prompt text and picture names as tokens."""
|
||||
if not isinstance(result, dict):
|
||||
return result
|
||||
seals = Seals(task_id)
|
||||
data = result.get("structuredContent")
|
||||
masked = None
|
||||
if isinstance(data, (dict, list)):
|
||||
masked = _strings(_walk(data, seals, info or {}, PROMPT_KEYS | TOOL_KEYS.get(tool, set())), lambda text: _hide(seals, text))
|
||||
result["structuredContent"] = masked
|
||||
content = []
|
||||
for item in result.get("content") or []:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
try:
|
||||
same = masked is not None and json.loads(item.get("text") or "") == data
|
||||
except ValueError:
|
||||
same = False
|
||||
item = {**item, "text": tool_text(masked) if same else _hide(seals, str(item.get("text") or ""))}
|
||||
content.append(item)
|
||||
if "content" in result:
|
||||
result["content"] = content
|
||||
seals.save()
|
||||
return result
|
||||
|
||||
|
||||
def seal_answer(task_id, prompts, negative="", pictures=None):
|
||||
"""The user's prompt box: {prompts, negative, pictures} as tokens, the texts and picture names sealed
|
||||
in the task, and a line saying them."""
|
||||
prompts = [str(item).strip() for item in (prompts if isinstance(prompts, list) else []) if str(item or "").strip()]
|
||||
if not prompts or len(prompts) > MAX_PROMPTS or any(len(item) > MAX_PROMPT for item in prompts):
|
||||
raise ValueError(f"Write 1 to {MAX_PROMPTS} prompts of up to {MAX_PROMPT} characters")
|
||||
negative = str(negative or "").strip()[:MAX_PROMPT]
|
||||
named = []
|
||||
for picture in (pictures if isinstance(pictures, list) else [])[:MAX_PICTURES]:
|
||||
if isinstance(picture, dict) and isinstance(picture.get("filename"), str) and PICTURE.fullmatch(picture["filename"]):
|
||||
named.append("/".join(part for part in (str(picture.get("subfolder") or "").strip("/"), picture["filename"]) if part))
|
||||
seals = Seals(task_id)
|
||||
answer = {"prompts": [seals.seal(item) for item in prompts], "negative": seals.seal(negative) if negative else "",
|
||||
"pictures": [seals.seal(item, "img") for item in named]}
|
||||
seals.save()
|
||||
answer["text"] = " ".join(answer["prompts"] + ([f"negative {answer['negative']}"] if negative else [])
|
||||
+ ([f"pictures {' '.join(answer['pictures'])}"] if named else []))
|
||||
return answer
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Skills: advice on how to do a kind of job, which an AI fetches when the job comes up, beside the
|
||||
ready-made blocks (reference_blocks.py), which build nodes. A block is something AMB does; a skill
|
||||
is something the AI reads (how to write a prompt a model can draw). Each skill is a data file in
|
||||
api/skills/ (<name>.json): what the AI is told it is for (short) and the advice itself (text, a
|
||||
line a point), and what the user's Ways panel shows (title, about, in both languages,
|
||||
web/modules/ui_ai_blocks.js). Advice that depends on the canvas's main model (by_model) comes
|
||||
in the form for its size: "large" from `large_from` parameters, "small" under it, "unknown" when the
|
||||
model or its size cannot be told (model_advice.main_params counts them from the file's header).
|
||||
begin_task lists the skills on (name and short); get_toolbox(skill=…)
|
||||
gives one. The tools' own descriptions only point at a skill, so the list every app holds stays
|
||||
small. The user turns a skill off in the panel (user/anomalous/ai_skills.json): the AI is not
|
||||
offered it and get_toolbox refuses it. A skill marked private_only is for private tasks
|
||||
(ai_privacy.py) and is all a private task gets: begin_task points at it there instead of listing
|
||||
the others, and get refuses each kind in the other kind of task. A by_model skill's
|
||||
for_user words say the same to the user, on the prompt box of a private task. Each skill fetched is a step of the AI's task (note_use), with
|
||||
the form its advice came in, so the user sees what the AI went by; the run after it says so too.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
FOLDER = os.path.join(os.path.dirname(__file__), "skills")
|
||||
NOTE = "Advice: get_toolbox(skill=name) when its job comes up: "
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class SkillError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _load():
|
||||
found = {}
|
||||
for file in sorted(os.listdir(FOLDER)):
|
||||
if file.endswith(".json"):
|
||||
with open(os.path.join(FOLDER, file), encoding="utf-8") as source:
|
||||
found[file[:-5]] = json.load(source)
|
||||
return found
|
||||
|
||||
|
||||
SKILLS = _load()
|
||||
|
||||
|
||||
def _off_path():
|
||||
from . import ai_tasks
|
||||
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_skills.json")
|
||||
|
||||
|
||||
def turned_off():
|
||||
"""The skills the user turned off in the panel."""
|
||||
try:
|
||||
with open(_off_path(), encoding="utf-8") as file:
|
||||
return {name for name in json.load(file).get("off", []) if name in SKILLS}
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return set()
|
||||
|
||||
|
||||
def set_on(name, on):
|
||||
"""Turns skill `name` on or off for the AI; the skills now off."""
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
if name not in SKILLS:
|
||||
raise SkillError(f"No skill {name!r}.")
|
||||
with _lock:
|
||||
off = turned_off() - {name} if on else turned_off() | {name}
|
||||
atomic_write_json(_off_path(), {"off": sorted(off)})
|
||||
return off
|
||||
|
||||
|
||||
def status():
|
||||
"""Every skill as the user's panel shows it."""
|
||||
off = turned_off()
|
||||
return [{"name": name, "title": skill["title"], "about": skill["about"], "on": name not in off,
|
||||
"private_only": skill.get("private_only") is True} for name, skill in SKILLS.items()]
|
||||
|
||||
|
||||
def directory():
|
||||
"""The skills on, "name (short)" each after a note on fetching them, a line for begin_task; "" when none is on."""
|
||||
off = turned_off()
|
||||
listed = [f"{name} ({skill['short']})" for name, skill in SKILLS.items() if name not in off and not skill.get("private_only")]
|
||||
return NOTE + "; ".join(listed) if listed else ""
|
||||
|
||||
|
||||
def by_model(name):
|
||||
"""Whether skill `name`'s advice depends on the canvas's main model."""
|
||||
return bool(SKILLS.get(name, {}).get("by_model"))
|
||||
|
||||
|
||||
def _form(forms, model):
|
||||
"""Which form of `forms` (a skill's by_model) is for `model` {name, params}: large, small or unknown."""
|
||||
params = (model or {}).get("params") or 0
|
||||
return "unknown" if not params else "large" if params >= forms["large_from"] else "small"
|
||||
|
||||
|
||||
def _model_name(model):
|
||||
return os.path.splitext(os.path.basename(str((model or {}).get("name") or "").replace("\\", "/")))[0]
|
||||
|
||||
|
||||
def get(name, model=None, private=False):
|
||||
"""The advice of skill `name`, for get_toolbox; `model`: the canvas's main model {name, params},
|
||||
for a skill whose advice depends on it; `private`: the task is private (only its skills)."""
|
||||
if name not in SKILLS:
|
||||
raise SkillError(f"No skill {name!r}; begin_task lists them: {', '.join(SKILLS)}.")
|
||||
if name in turned_off():
|
||||
raise SkillError(f"The user turned the skill {name!r} off: do the job your own way.")
|
||||
if private and not SKILLS[name].get("private_only"):
|
||||
raise SkillError("This task is private: the user writes the prompts. Its skill is get_toolbox(skill=\"privacy\").")
|
||||
if not private and SKILLS[name].get("private_only"):
|
||||
raise SkillError(f"The skill {name!r} is for private tasks; this one is not.")
|
||||
skill = SKILLS[name]
|
||||
lines = list(skill["text"])
|
||||
answer = {"skill": name, "advice": lines}
|
||||
forms = skill.get("by_model")
|
||||
if forms:
|
||||
form = _form(forms, model)
|
||||
size = f"{model['params'] / 1e9:.1f} billion" if form != "unknown" else ""
|
||||
lines[1:1] = [line.format(model=_model_name(model) or "The model", size=size) for line in forms[form]]
|
||||
answer["for_model"] = {"form": form, "model": _model_name(model), "billions": round(model["params"] / 1e9, 1) if size else None}
|
||||
return answer
|
||||
|
||||
|
||||
def for_user(model, name="prompt_pose"):
|
||||
"""Skill `name`'s words for the user on `model` {name, params} (its for_user form, {zh, en}); None when it is off."""
|
||||
skill = SKILLS.get(name) or {}
|
||||
forms = (skill.get("by_model") or {}).get("for_user")
|
||||
if not forms or name in turned_off():
|
||||
return None
|
||||
form = _form(skill["by_model"], model)
|
||||
billions = f"{model['params'] / 1e9:.1f}" if form != "unknown" else ""
|
||||
return {lang: text.format(model=_model_name(model), billions=billions) for lang, text in forms[form].items()}
|
||||
|
||||
|
||||
def note_use(client, answer, session="", task_id=None, now=None):
|
||||
"""Skill `answer` (get's) fetched by the AI, as a step of its task: which skill and, for one that goes
|
||||
by the model, the form it came in. The same skill in the same form again before a run adds nothing."""
|
||||
from . import ai_tasks
|
||||
|
||||
skill = SKILLS[answer["skill"]]
|
||||
now = time.time() if now is None else now
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "use_skill", "kind": "skill", "ok": True, "state": "done",
|
||||
"summary": "use_skill", "skill": answer["skill"], "title": skill["title"], "for_model": answer.get("for_model"), "results": []}
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks.current_task(client, now, session=session, task_id=task_id)
|
||||
for earlier in reversed(task.get("steps") or []):
|
||||
if earlier.get("tool") == "run_workflow":
|
||||
break
|
||||
if earlier.get("tool") == "use_skill" and earlier.get("skill") == step["skill"] and earlier.get("for_model") == step["for_model"]:
|
||||
return earlier
|
||||
ai_tasks._add_step(task, step)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task["id"])
|
||||
return step
|
||||
@@ -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,68 @@
|
||||
"""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. A private task's
|
||||
(ai_privacy.py) are told with what was typed sealed (ai_sealed.py): the AI hears that a prompt
|
||||
changed, not what it says."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import action_catalog, activity_log, ai_privacy, ai_sealed, 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, seal=lambda value: value):
|
||||
"""What the user changed, in words for the AI; `seal(value)` for what was before and after."""
|
||||
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')}: {seal(change.get('before'))} -> {seal(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
|
||||
seals = ai_sealed.Seals(task["id"]) if ai_privacy.is_private(task) else None
|
||||
text = _change_text(entry, (lambda value: ai_sealed.seal_text(seals, value)) if seals else (lambda value: value))
|
||||
if seals:
|
||||
seals.save()
|
||||
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(text, 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,71 @@
|
||||
"""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. A task the user
|
||||
removes (remove) leaves no gap: those that went on from it go on from what it went on from."""
|
||||
|
||||
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 remove(task_id):
|
||||
"""Takes task `task_id` off the list; those that went on from it now go on from what it went on from."""
|
||||
with ai_tasks._lock:
|
||||
tasks = ai_tasks._load()
|
||||
parent = (tasks.get(task_id) or {}).get("from")
|
||||
for task in tasks.values():
|
||||
if task.get("from") == task_id:
|
||||
if parent:
|
||||
task["from"] = parent
|
||||
else:
|
||||
task.pop("from")
|
||||
ai_tasks._save(task)
|
||||
return ai_tasks.remove_task(task_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,471 @@
|
||||
"""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_bases, 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
|
||||
MAX_TEXTS = 4 # what a run's nodes showed as words (a tagger's tags), the last few
|
||||
TEXT_CHARS = 2000
|
||||
|
||||
|
||||
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 texts_of(record_):
|
||||
"""What a history record's nodes showed as words (a tagger's tags, a text display): [{node, text}]."""
|
||||
out = []
|
||||
for node, output in (record_.get("outputs") or {}).items():
|
||||
for key in ("tags", "text", "string"):
|
||||
words = [item for item in output.get(key) or [] if isinstance(item, str) and item.strip()]
|
||||
if words:
|
||||
out.append({"node": str(node), "text": " | ".join(words)[:TEXT_CHARS]})
|
||||
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, texts, 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]
|
||||
texts += [text for text in texts_of(record_) if text not in texts]
|
||||
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, texts = previews[-MAX_PREVIEWS:], texts[-MAX_TEXTS:]
|
||||
if (done < len(prompts) and results == step["results"] and step.get("prompts") == len(prompts)
|
||||
and previews == step.get("previews", []) and texts == step.get("texts", [])
|
||||
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
|
||||
if texts:
|
||||
step["texts"] = texts
|
||||
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)
|
||||
for key, value in (("bases", receipt.get("base_only")), ("base_check", receipt.get("base_check"))):
|
||||
if value:
|
||||
step[key] = value # ai_bases.py: bases to offer when it ends, or the picked one to compare
|
||||
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)):
|
||||
ai_bases.offer(task_id, step_id, prompts, history)
|
||||
ai_bases.check(task_id, step_id, prompts, history)
|
||||
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"]
|
||||
made = f"{len(pictures)} picture(s): {', '.join(pictures[:8])}" if pictures else "no pictures"
|
||||
words = f" and words from {len(run['texts'])} node(s) (texts)" if run.get("texts") else ""
|
||||
base = ai_bases.words(run)
|
||||
return f"Your run finished in {round(run.get('ms', 0) / 1000)} s with {made}{words}.{' ' + base if base else ''}"
|
||||
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("texts"):
|
||||
answer["texts"] = run["texts"] # what nodes showed as words: a tagger's tags
|
||||
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": "get_answers(wait_seconds=55): the user picks a base on Anomalous's panel." if run.get("bases") else
|
||||
("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.") if pictures or not run.get("texts") else
|
||||
("It made no pictures: texts has what its nodes showed (a tagger's tags); use them (set_prompt), then report."),
|
||||
"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
|
||||
+596
@@ -0,0 +1,596 @@
|
||||
"""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":
|
||||
said = next((step["text"] for step in task["steps"] if step.get("tool") == "user_message" and step.get("text")), "")
|
||||
task["title"] = _short(said or title, MAX_TITLE)
|
||||
task["title_from"] = "user" if said else "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", "skill") 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, folder="output"):
|
||||
"""An output-folder file the AI names ("subfolder/name.png"), as an image result, or None; with
|
||||
`folder` "temp", a run's preview there (gone when ComfyUI restarts)."""
|
||||
root = os.path.realpath(folder_paths.get_temp_directory() if folder == "temp" else 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": "temp" if folder == "temp" else "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"]
|
||||
made = {(item.get("folder"), item.get("subfolder"), item.get("filename")) for step in steps if step.get("kind") == "run"
|
||||
for item in step.get("results") or [] if item.get("type") == "image"} # not a picture a report only shows
|
||||
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(made), "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,369 @@
|
||||
"""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 or say how it turned out (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_privacy, 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["private"] = ai_privacy.is_private(task) # the same for privacy mode
|
||||
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, {"privacy": bool}
|
||||
whether its pictures are kept from the AI (ai_privacy.py), {"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_task_links.remove(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 "privacy" in body:
|
||||
try:
|
||||
return web.json_response({"privacy": ai_privacy.set_private(task_id, body["privacy"])})
|
||||
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 | prompts, negative, pictures | skip: true} -
|
||||
the user's answer on a panel; a prompt box's texts are sealed and the AI gets tokens (ai_sealed.py)."""
|
||||
from . import ai_bases, ai_panels, ai_repaint, ai_sealed
|
||||
|
||||
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:
|
||||
if "prompts" in body and not skip:
|
||||
extra = await asyncio.to_thread(ai_sealed.seal_answer, task_id, body["prompts"], body.get("negative"), body.get("pictures"))
|
||||
else:
|
||||
# A repaint area picked: its picture is made first (outside the lock), so the AI gets it with the answer;
|
||||
# a base picked: it is kept in the output folder first (ai_bases.py).
|
||||
extra = (await asyncio.to_thread(ai_repaint.prepare, task_id, step_id, body.get("choice"), body.get("text"), skip)
|
||||
or await asyncio.to_thread(ai_bases.prepare, task_id, step_id, body.get("choice"), skip))
|
||||
except (ai_repaint.RepaintError, ValueError, OSError) 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)
|
||||
ai_bases.discard(extra)
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
await asyncio.to_thread(ai_bases.hold, task_id, extra)
|
||||
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 {privacy: bool} whether new tasks are private (ai_privacy.py),
|
||||
POST {runs: call | wait}
|
||||
how the AI waits for a run (ai_gates.run_wait), POST {answer_wait: 60 | 180 | 600} for the user's answer,
|
||||
POST {no_answer: stop | recommended} what happens when that time runs out,
|
||||
POST {nodes: any | ways} which custom nodes the AI adds itself (ai_gates.node_rule),
|
||||
POST {routes: allow | ask} an AI placing nodes by itself (ai_gates.route_rule)."""
|
||||
def settings():
|
||||
return {"policies": ai_gates.policies(), "canvas_tab": ai_task_canvas.default_mode(), "privacy": ai_privacy.default_on(),
|
||||
"runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer(),
|
||||
"nodes": ai_gates.node_rule(), "routes": ai_gates.route_rule()}
|
||||
|
||||
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 "privacy" in (body or {}):
|
||||
ai_privacy.set_default(body["privacy"])
|
||||
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"])
|
||||
elif "nodes" in (body or {}):
|
||||
ai_gates.set_node_rule(body["nodes"])
|
||||
elif "routes" in (body or {}):
|
||||
ai_gates.set_route_rule(body["routes"])
|
||||
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_way_pick(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/way: {name, on} - a way (做法) the user picks for this task, or takes back."""
|
||||
from . import ai_panels
|
||||
|
||||
body = await _body(request) or {}
|
||||
try:
|
||||
step = ai_toolbox.pick_way(request.match_info["task_id"], str(body.get("name") or ""), bool(body.get("on")))
|
||||
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_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_verdict(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/runs/{step}/verdict {verdict: good | bad | null} - the user's word on a run, told to the AI."""
|
||||
from . import ai_panels
|
||||
|
||||
body = await _body(request) or {}
|
||||
try:
|
||||
verdict = await asyncio.to_thread(ai_run_control.set_verdict, request.match_info["task_id"], request.match_info["step_id"], body.get("verdict"))
|
||||
except ai_run_control.RunControlError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
ai_panels.wake()
|
||||
return web.json_response({"verdict": verdict})
|
||||
|
||||
|
||||
async def api_toolbox(request):
|
||||
"""GET /anomalous/ai_toolbox?kind=all: what the picker lists; GET/POST /anomalous/ai_toolbox/favorites."""
|
||||
if request.path.endswith("/favorites"):
|
||||
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)
|
||||
|
||||
|
||||
async def api_blocks(request):
|
||||
"""GET /anomalous/ai_blocks: every ready-made block and skill as the 做法 panel shows them
|
||||
(reference_blocks.status, ai_skills.status); POST {name, on} or {skill, on}: the user turns one on or off for the AI."""
|
||||
from . import ai_skills, mcp_canvas_edit, reference_blocks
|
||||
from .mcp_tools import ToolError
|
||||
|
||||
if request.method == "POST":
|
||||
body = await _body(request) or {}
|
||||
try:
|
||||
if "skill" in body:
|
||||
ai_skills.set_on(str(body.get("skill") or ""), bool(body.get("on")))
|
||||
else:
|
||||
reference_blocks.set_on(str(body.get("name") or ""), bool(body.get("on")))
|
||||
except (reference_blocks.BlockError, ai_skills.SkillError) as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
try:
|
||||
info = await asyncio.to_thread(mcp_canvas_edit.object_info, {"base_url": f"{request.scheme}://{request.host}"})
|
||||
except ToolError as error:
|
||||
return web.json_response({"error": str(error)}, status=502)
|
||||
return web.json_response({"blocks": reference_blocks.status(info), "jobs": list(reference_blocks.JOBS),
|
||||
"skills": ai_skills.status()})
|
||||
|
||||
|
||||
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}/way", api_way_pick)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/stop", api_stop)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/verdict", api_verdict)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/pictures", api_pictures)
|
||||
app.router.add_post("/anomalous/ai_pictures/upload", api_upload)
|
||||
app.router.add_get("/anomalous/ai_toolbox", api_toolbox)
|
||||
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_blocks", api_blocks)
|
||||
app.router.add_post("/anomalous/ai_blocks", api_blocks)
|
||||
app.router.add_get("/anomalous/ai_gates", api_gates)
|
||||
app.router.add_post("/anomalous/ai_gates", api_gates)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""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). Ways too
|
||||
(reference_blocks.py, picked in the 做法 view: pick_way): add_nodes builds the picked one for its use. 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", "ways")
|
||||
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, ways}")
|
||||
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."""
|
||||
from . import reference_blocks
|
||||
|
||||
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)",
|
||||
"ways": "ways of working (for that use add_nodes builds the one picked)"}
|
||||
for kind in KINDS:
|
||||
if box.get(kind):
|
||||
names = [reference_blocks.call(name) or name for name in box[kind]] if kind == "ways" else box[kind]
|
||||
parts.append(f"{labels[kind]}: {', '.join(names)}")
|
||||
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 pick_way(task_id, name, on):
|
||||
"""Way `name` onto the picks of task `task_id` (`on`) or off them, the rest as they stand. Returns the step."""
|
||||
from . import reference_blocks
|
||||
|
||||
if name not in reference_blocks.BLOCKS:
|
||||
raise ToolboxError("No such way")
|
||||
box = effective(ai_tasks.get_task(task_id)) or {}
|
||||
ways = [item for item in box.get("ways") or [] if item != name] + ([name] if on else [])
|
||||
return pick(task_id, {**box, "ways": ways}, box.get("mode", "prefer"))
|
||||
|
||||
|
||||
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;
|
||||
so do those of a way picked)."""
|
||||
from . import reference_blocks
|
||||
|
||||
box = effective(ai_tasks.get_task(task_id)) if task_id else None
|
||||
if not box or box.get("mode") != "only":
|
||||
return []
|
||||
ways = reference_blocks.node_types(box.get("ways") or [])
|
||||
return sorted({name for name in types if name in info and name not in ways 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,90 @@
|
||||
"""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 and
|
||||
what the Models page groups them by (`metadata`: its version page, its base model),
|
||||
by the names and ids the AI's tools use, and what the ways are called, for a picked way's chip. 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, reference_blocks
|
||||
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"],
|
||||
"metadata": {"model_id": model["page"], "version_id": model["version"], "version_name": model["version_name"],
|
||||
"baseModel": model["base"], "base_guess": model["base_guess"]}})
|
||||
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, ways}, 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,
|
||||
"ways": {name: block["title"] for name, block in reference_blocks.BLOCKS.items()},
|
||||
"prompts": prompt_entries(combos, workflows), "models": _models()})
|
||||
return _cache["data"]
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Generated audio: the audio gallery, Preview Audio results, and streaming them."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from .audio_metadata import generation_info
|
||||
from .path_utils import resolve_within
|
||||
from .trash import move_to_trash, trash_failure
|
||||
|
||||
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.flac', '.ogg', '.opus', '.m4a'}
|
||||
AUDIO_CONTENT_TYPES = {
|
||||
'.wav': 'audio/wav',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.flac': 'audio/flac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.opus': 'audio/ogg',
|
||||
'.m4a': 'audio/mp4',
|
||||
}
|
||||
AUDIO_ROOTS = {
|
||||
"output": lambda: folder_paths.get_output_directory(),
|
||||
"temp": lambda: folder_paths.get_temp_directory(),
|
||||
}
|
||||
PREVIEW_LIMIT = 30
|
||||
SAVED_PREVIEW_SUBFOLDER = "audio"
|
||||
# Revalidate instead of forbidding caches: overwritten files get a new ?v=mtime URL.
|
||||
AUDIO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
|
||||
|
||||
|
||||
def _audio_url(rel_path, dir_type, version):
|
||||
return f"/anomalous/audio_stream?path={quote(rel_path, safe='/')}&type={dir_type}&v={int(version)}"
|
||||
|
||||
|
||||
async def api_serve_audio(request):
|
||||
"""GET /anomalous/audio_stream?path=...&type=output|temp - Stream an audio file."""
|
||||
rel_path = request.query.get("path", "").strip("/\\")
|
||||
dir_type = request.query.get("type", "output")
|
||||
if not rel_path:
|
||||
return web.Response(status=400, text="Missing audio path")
|
||||
if dir_type not in AUDIO_ROOTS:
|
||||
return web.Response(status=400, text="Unknown audio folder")
|
||||
|
||||
base_dir = AUDIO_ROOTS[dir_type]()
|
||||
try:
|
||||
resolved_path = resolve_within(base_dir, rel_path)
|
||||
except ValueError:
|
||||
return web.Response(status=403, text="Forbidden path")
|
||||
|
||||
ext = os.path.splitext(resolved_path)[1].lower()
|
||||
if ext not in AUDIO_EXTENSIONS:
|
||||
return web.Response(status=415, text="Not an audio file")
|
||||
if not os.path.isfile(resolved_path):
|
||||
return web.Response(status=404, text="Audio file not found")
|
||||
|
||||
return web.FileResponse(resolved_path, headers={"Content-Type": AUDIO_CONTENT_TYPES[ext], **AUDIO_CACHE_HEADERS})
|
||||
|
||||
|
||||
def _collect_audio_files(root_dir, dir_type):
|
||||
"""Scan a folder tree for audio files, newest first."""
|
||||
audios = []
|
||||
if not os.path.isdir(root_dir):
|
||||
return audios
|
||||
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for name in files:
|
||||
if os.path.splitext(name)[1].lower() not in AUDIO_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
stat = os.stat(os.path.join(root, name))
|
||||
except OSError:
|
||||
continue
|
||||
subfolder = os.path.relpath(root, root_dir)
|
||||
clean_sub = "" if subfolder == "." else subfolder.replace(os.sep, '/')
|
||||
rel_path = "/".join(part for part in (clean_sub, name) if part)
|
||||
audios.append({
|
||||
"filename": name,
|
||||
"subfolder": clean_sub,
|
||||
"size_bytes": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"audio_url": _audio_url(rel_path, dir_type, stat.st_mtime),
|
||||
})
|
||||
audios.sort(key=lambda a: a["mtime"], reverse=True)
|
||||
return audios
|
||||
|
||||
|
||||
_info_cache = {}
|
||||
|
||||
|
||||
def _cached_generation_info(path, mtime, size):
|
||||
"""Generation info keyed by (mtime, size) so unchanged files are parsed once."""
|
||||
cached = _info_cache.get(path)
|
||||
if cached and cached[0] == (mtime, size):
|
||||
return cached[1]
|
||||
info = generation_info(path)
|
||||
if len(_info_cache) > 5000:
|
||||
_info_cache.clear()
|
||||
_info_cache[path] = ((mtime, size), info)
|
||||
return info
|
||||
|
||||
|
||||
def _attach_generation_info(root_dir, items):
|
||||
for item in items:
|
||||
path = os.path.join(root_dir, item["subfolder"], item["filename"])
|
||||
item["generation"] = _cached_generation_info(path, item["mtime"], item["size_bytes"])
|
||||
return items
|
||||
|
||||
|
||||
def _search_text(item):
|
||||
speech = (item.get("generation") or {}).get("speech") or ""
|
||||
return f"{item['subfolder']}/{item['filename']}\n{speech}".lower()
|
||||
|
||||
|
||||
def _gallery_page(output_dir, page, limit, query):
|
||||
all_audios = _collect_audio_files(output_dir, "output")
|
||||
if query:
|
||||
# Searching reads every file's comments once; the cache keeps later searches cheap.
|
||||
_attach_generation_info(output_dir, all_audios)
|
||||
all_audios = [item for item in all_audios if query in _search_text(item)]
|
||||
total = len(all_audios)
|
||||
start = (page - 1) * limit
|
||||
items = all_audios[start:start + limit]
|
||||
if not query:
|
||||
_attach_generation_info(output_dir, items)
|
||||
return items, total, start + limit < total
|
||||
|
||||
|
||||
def _positive_int(value, default, maximum=None):
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
number = max(1, number)
|
||||
return min(maximum, number) if maximum else number
|
||||
|
||||
|
||||
async def api_get_audio_gallery(request):
|
||||
"""GET /anomalous/audio_gallery?page=1&limit=50&q=text - Generated audio in output, with the speech that produced it."""
|
||||
page = _positive_int(request.query.get("page"), 1)
|
||||
limit = _positive_int(request.query.get("limit"), 40, 100)
|
||||
query = request.query.get("q", "").strip().lower()
|
||||
|
||||
items, total, has_more = await asyncio.to_thread(_gallery_page, folder_paths.get_output_directory(), page, limit, query)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"audios": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"has_more": has_more,
|
||||
})
|
||||
|
||||
|
||||
def _list_previews(temp_dir):
|
||||
items = _collect_audio_files(temp_dir, "temp")[:PREVIEW_LIMIT]
|
||||
return _attach_generation_info(temp_dir, items)
|
||||
|
||||
|
||||
async def api_get_audio_previews(request):
|
||||
"""GET /anomalous/audio_previews - Unsaved preview audio in ComfyUI's temp folder (cleared on restart)."""
|
||||
items = await asyncio.to_thread(_list_previews, folder_paths.get_temp_directory())
|
||||
return web.json_response({"success": True, "audios": items})
|
||||
|
||||
|
||||
def _save_preview(temp_dir, output_dir, subfolder, filename):
|
||||
source = resolve_within(temp_dir, subfolder, filename)
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in AUDIO_EXTENSIONS:
|
||||
raise ValueError("Only audio files can be saved")
|
||||
if not os.path.isfile(source):
|
||||
raise FileNotFoundError(filename)
|
||||
|
||||
dest_dir = resolve_within(output_dir, SAVED_PREVIEW_SUBFOLDER)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(source)))
|
||||
for counter in range(1, 1000):
|
||||
name = f"Preview_{stamp}_{counter:02}{ext}"
|
||||
target = os.path.join(dest_dir, name)
|
||||
try:
|
||||
# O_EXCL: never overwrite an existing file in the library.
|
||||
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0))
|
||||
except FileExistsError:
|
||||
continue
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out, open(source, "rb") as src:
|
||||
shutil.copyfileobj(src, out)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(target)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
stat = os.stat(target)
|
||||
rel_path = f"{SAVED_PREVIEW_SUBFOLDER}/{name}"
|
||||
item = {
|
||||
"filename": name,
|
||||
"subfolder": SAVED_PREVIEW_SUBFOLDER,
|
||||
"size_bytes": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"audio_url": _audio_url(rel_path, "output", stat.st_mtime),
|
||||
}
|
||||
return _attach_generation_info(output_dir, [item])[0]
|
||||
raise FileExistsError("No free file name")
|
||||
|
||||
|
||||
async def api_save_audio_preview(request):
|
||||
"""POST /anomalous/save_audio_preview {filename, subfolder} - Copy a temp preview into output/audio."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
|
||||
filename = str(data.get("filename", "")).strip()
|
||||
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
|
||||
if not filename or os.path.basename(filename) != filename:
|
||||
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
|
||||
try:
|
||||
item = await asyncio.to_thread(
|
||||
_save_preview, folder_paths.get_temp_directory(), folder_paths.get_output_directory(), subfolder, filename)
|
||||
except ValueError as e:
|
||||
return web.json_response({"success": False, "error": str(e)}, status=403)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"success": False, "code": "missing", "error": "Preview no longer exists"}, status=404)
|
||||
except OSError as e:
|
||||
return web.json_response({"success": False, "error": f"Failed to save: {e}"}, status=500)
|
||||
return web.json_response({"success": True, "audio": item})
|
||||
|
||||
|
||||
async def api_delete_audio_gallery(request):
|
||||
"""POST /anomalous/delete_audio_gallery - Delete one generated audio file."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
|
||||
|
||||
filename = str(data.get("filename", "")).strip()
|
||||
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
|
||||
if not filename or os.path.basename(filename) != filename:
|
||||
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
|
||||
if os.path.splitext(filename)[1].lower() not in AUDIO_EXTENSIONS:
|
||||
return web.json_response({"success": False, "error": "Only audio files can be deleted here"}, status=415)
|
||||
|
||||
try:
|
||||
target = resolve_within(folder_paths.get_output_directory(), subfolder, filename)
|
||||
except ValueError:
|
||||
return web.json_response({"success": False, "error": "Forbidden path"}, status=403)
|
||||
|
||||
if not os.path.isfile(target):
|
||||
return web.json_response({"success": False, "error": "File not found"}, status=404)
|
||||
try:
|
||||
await asyncio.to_thread(move_to_trash, target)
|
||||
except OSError as e:
|
||||
return web.json_response({"success": False, "error": trash_failure(e)}, status=409)
|
||||
return web.json_response({"success": True})
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Read ComfyUI generation info embedded in saved audio (FLAC and Ogg/Opus comments).
|
||||
|
||||
ComfyUI's audio save/preview nodes store the API prompt as a `prompt` comment.
|
||||
Only the fields the audio gallery shows are extracted; nothing is written.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
MAX_TAG_BYTES = 16 * 1024 * 1024
|
||||
MAX_OGG_SCAN_BYTES = 32 * 1024 * 1024
|
||||
SPEECH_INPUTS = ("speech", "text", "gen_text")
|
||||
# "character": Anomalous_TTS (GPT-SoVITS) names the voice by character instead of a file.
|
||||
SAMPLE_INPUTS = ("sample", "ref_audio", "audio_prompt", "character")
|
||||
|
||||
|
||||
def _parse_vorbis_comment(data, offset=0):
|
||||
"""Parse a Vorbis comment block into {lowercase key: value}."""
|
||||
tags = {}
|
||||
try:
|
||||
vendor_len = int.from_bytes(data[offset:offset + 4], "little")
|
||||
pos = offset + 4 + vendor_len
|
||||
count = int.from_bytes(data[pos:pos + 4], "little")
|
||||
pos += 4
|
||||
for _ in range(min(count, 1024)):
|
||||
length = int.from_bytes(data[pos:pos + 4], "little")
|
||||
pos += 4
|
||||
entry = data[pos:pos + length].decode("utf-8", "replace")
|
||||
pos += length
|
||||
key, sep, value = entry.partition("=")
|
||||
if sep:
|
||||
tags.setdefault(key.lower(), value)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return tags
|
||||
|
||||
|
||||
def _flac_tags(handle):
|
||||
if handle.read(4) != b"fLaC":
|
||||
return {}
|
||||
while True:
|
||||
header = handle.read(4)
|
||||
if len(header) < 4:
|
||||
return {}
|
||||
is_last = header[0] & 0x80
|
||||
block_type = header[0] & 0x7F
|
||||
length = int.from_bytes(header[1:4], "big")
|
||||
if block_type == 4:
|
||||
if length > MAX_TAG_BYTES:
|
||||
return {}
|
||||
return _parse_vorbis_comment(handle.read(length))
|
||||
handle.seek(length, os.SEEK_CUR)
|
||||
if is_last:
|
||||
return {}
|
||||
|
||||
|
||||
def _ogg_packets(handle, wanted=2):
|
||||
"""Yield the first `wanted` logical packets of an Ogg stream."""
|
||||
packet = b""
|
||||
scanned = 0
|
||||
found = 0
|
||||
while found < wanted and scanned < MAX_OGG_SCAN_BYTES:
|
||||
header = handle.read(27)
|
||||
if len(header) < 27 or header[:4] != b"OggS":
|
||||
return
|
||||
segments = header[26]
|
||||
lacing = handle.read(segments)
|
||||
scanned += 27 + segments
|
||||
for size in lacing:
|
||||
packet += handle.read(size)
|
||||
scanned += size
|
||||
if size < 255:
|
||||
yield packet
|
||||
packet = b""
|
||||
found += 1
|
||||
if found >= wanted:
|
||||
return
|
||||
if len(packet) > MAX_TAG_BYTES:
|
||||
return
|
||||
|
||||
|
||||
def _ogg_tags(handle):
|
||||
packets = list(_ogg_packets(handle, wanted=2))
|
||||
if len(packets) < 2:
|
||||
return {}
|
||||
comment = packets[1]
|
||||
if comment.startswith(b"OpusTags"):
|
||||
return _parse_vorbis_comment(comment, 8)
|
||||
if comment.startswith(b"\x03vorbis"):
|
||||
return _parse_vorbis_comment(comment, 7)
|
||||
return {}
|
||||
|
||||
|
||||
def read_audio_tags(path):
|
||||
"""Return {lowercase key: value} comments for FLAC/Ogg/Opus files, else {}."""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
if ext == ".flac":
|
||||
return _flac_tags(handle)
|
||||
if ext in (".ogg", ".opus"):
|
||||
return _ogg_tags(handle)
|
||||
except OSError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def generation_info(path):
|
||||
"""Speech text, sample voice and seed of the TTS node that produced this file, if recorded."""
|
||||
raw = read_audio_tags(path).get("prompt")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
prompt = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(prompt, dict):
|
||||
return None
|
||||
|
||||
nodes = [node for node in prompt.values() if isinstance(node, dict) and isinstance(node.get("inputs"), dict)]
|
||||
# Prefer TTS nodes; otherwise any node with a speech-like text input.
|
||||
nodes.sort(key=lambda node: "tts" not in str(node.get("class_type", "")).lower())
|
||||
for node in nodes:
|
||||
inputs = node["inputs"]
|
||||
speech = next((inputs[key] for key in SPEECH_INPUTS if isinstance(inputs.get(key), str) and inputs[key].strip()), None)
|
||||
if speech is None:
|
||||
continue
|
||||
sample = next((inputs[key] for key in SAMPLE_INPUTS if isinstance(inputs.get(key), str)), None)
|
||||
seed = inputs.get("seed")
|
||||
return {
|
||||
"node_type": str(node.get("class_type") or ""),
|
||||
"speech": speech[:4000],
|
||||
"sample": sample.replace("\\", "/") if sample else None,
|
||||
"seed": seed if isinstance(seed, (int, float)) else None,
|
||||
}
|
||||
return None
|
||||
+593
@@ -0,0 +1,593 @@
|
||||
"""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 .workflow_saves import saves_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", "saves", "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(),
|
||||
"saves": saves_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)
|
||||
@@ -0,0 +1,300 @@
|
||||
{
|
||||
"2x_animesharp_v4": {
|
||||
"title": "2x-AnimeSharpV4 (RCAN)",
|
||||
"file": "2x-AnimeSharpV4_RCAN.safetensors",
|
||||
"folder": "upscale_models",
|
||||
"subfolder": "",
|
||||
"size": 31053198,
|
||||
"sha256": "6470bb91d6622d6acdff81132c1a8615b961b919ce2b9a01ce993378500cfbe1",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "github",
|
||||
"url": "https://github.com/Kim2091/Kim2091-Models/releases/download/2x-AnimeSharpV4/2x-AnimeSharpV4_RCAN.safetensors",
|
||||
"page": "https://openmodeldb.info/models/2x-AnimeSharpV4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"4x_ultrasharp": {
|
||||
"title": "4x-UltraSharp",
|
||||
"file": "4x-UltraSharp.pth",
|
||||
"folder": "upscale_models",
|
||||
"subfolder": "",
|
||||
"size": 66961958,
|
||||
"sha256": "a5812231fc936b42af08a5edba784195495d303d5b3248c24489ef0c4021fe01",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Kim2091/UltraSharp/resolve/main/4x-UltraSharp.pth",
|
||||
"page": "https://huggingface.co/Kim2091/UltraSharp"
|
||||
},
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/125843?fileId=90900",
|
||||
"page": "https://civitai.com/models/116225"
|
||||
}
|
||||
]
|
||||
},
|
||||
"4x_animesharp": {
|
||||
"title": "4x-AnimeSharp",
|
||||
"file": "4x-AnimeSharp.pth",
|
||||
"folder": "upscale_models",
|
||||
"subfolder": "",
|
||||
"size": 67010245,
|
||||
"sha256": "e7a7de2dafd7331c1992862bbbcd9e9712a9f9f8e6303f0aaa59b4341d359bab",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Kim2091/AnimeSharp/resolve/main/4x-AnimeSharp.pth",
|
||||
"page": "https://huggingface.co/Kim2091/AnimeSharp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"face_yolov8m": {
|
||||
"title": "face_yolov8m (face detector)",
|
||||
"file": "face_yolov8m.pt",
|
||||
"folder": "ultralytics_bbox",
|
||||
"subfolder": "",
|
||||
"size": 52026019,
|
||||
"sha256": "717923c19b3f4bbf5250b728f1fa6b2cb72a33aed1d236ea9caf0e21ad943e5f",
|
||||
"license": "Apache-2.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Bingsu/adetailer/resolve/main/face_yolov8m.pt",
|
||||
"page": "https://huggingface.co/Bingsu/adetailer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hand_yolov8s": {
|
||||
"title": "hand_yolov8s (hand detector)",
|
||||
"file": "hand_yolov8s.pt",
|
||||
"folder": "ultralytics_bbox",
|
||||
"subfolder": "",
|
||||
"size": 22507643,
|
||||
"sha256": "70b540063fbc385736d8258970744a4afbc4cbf7932134bae3b24cdadeadec06",
|
||||
"license": "Apache-2.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Bingsu/adetailer/resolve/main/hand_yolov8s.pt",
|
||||
"page": "https://huggingface.co/Bingsu/adetailer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sam3_1": {
|
||||
"title": "SAM 3.1 (finds things by words)",
|
||||
"file": "sam3.1_multiplex_fp16.safetensors",
|
||||
"folder": "checkpoints",
|
||||
"subfolder": "SAM3",
|
||||
"size": 1745546848,
|
||||
"sha256": "9ba99c92703c2e8b4f47de2d34a539bb8e18923049e238b780d70dbe6368eb03",
|
||||
"license": "SAM License",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors",
|
||||
"page": "https://huggingface.co/Comfy-Org/sam3.1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"union_promax_sdxl": {
|
||||
"title": "xinsir ControlNet Union ProMax (SDXL)",
|
||||
"file": "xinsir-controlnet-union-sdxl-promax.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "SDXL_1.0",
|
||||
"size": 2513342408,
|
||||
"sha256": "9fae2e50cb431bfcbe05822b59ec2228df545ef27f711dea8949e9f4ed9f7cdc",
|
||||
"license": "Apache-2.0",
|
||||
"base": "SDXL 1.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/xinsir/controlnet-union-sdxl-1.0/resolve/main/diffusion_pytorch_model_promax.safetensors",
|
||||
"page": "https://huggingface.co/xinsir/controlnet-union-sdxl-1.0"
|
||||
},
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/655836?fileId=570909",
|
||||
"page": "https://civitai.com/models/136070"
|
||||
}
|
||||
]
|
||||
},
|
||||
"noob_tile": {
|
||||
"title": "NoobAI XL tile ControlNet (Eugeoter)",
|
||||
"file": "noob-sdxl-controlnet-tile.fp16.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "NoobAI",
|
||||
"size": 2502139136,
|
||||
"sha256": "d6d676f29153357ac8d57727e35b8edc090aa4de57e861b878872ff45c432615",
|
||||
"license": "see the model page",
|
||||
"base": "NoobAI",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Eugeoter/noob-sdxl-controlnet-tile/resolve/main/diffusion_pytorch_model.fp16.safetensors",
|
||||
"page": "https://huggingface.co/Eugeoter/noob-sdxl-controlnet-tile"
|
||||
},
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/1290048?fileId=1194650",
|
||||
"page": "https://civitai.com/models/136070"
|
||||
}
|
||||
]
|
||||
},
|
||||
"xinsir_depth_sdxl": {
|
||||
"title": "xinsir ControlNet depth (SDXL)",
|
||||
"file": "controlnetxlCNXL_xinsirDepth.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "SDXL_1.0",
|
||||
"size": 2502139104,
|
||||
"sha256": "ea3770af4c33ea8ba8dccc0106ec5d5c5c6e504eb382119a3ccac3c9d973d344",
|
||||
"license": "Apache-2.0",
|
||||
"base": "SDXL 1.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/657836?fileId=572833",
|
||||
"page": "https://civitai.com/models/136070"
|
||||
}
|
||||
]
|
||||
},
|
||||
"noob_openpose": {
|
||||
"title": "NoobAI XL ControlNet openpose",
|
||||
"file": "noobaiXLControlnet_openposeModel.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "NoobAI",
|
||||
"size": 2502140008,
|
||||
"sha256": "918cfc4d7def165ea7a06bee10841fe525332515f8a0486c5cb2a171ec40b873",
|
||||
"license": "see the model page",
|
||||
"base": "NoobAI",
|
||||
"sources": [
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/1077649?fileId=982755",
|
||||
"page": "https://civitai.com/models/962537"
|
||||
}
|
||||
]
|
||||
},
|
||||
"thibauld_openpose_sdxl": {
|
||||
"title": "thibauld openpose LoRA ControlNet (SDXL)",
|
||||
"file": "controlnetxlCNXL_thibauldOpenposeLora.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "SDXL_1.0",
|
||||
"size": 774423024,
|
||||
"sha256": "8afa079285bf9384eaf8f6322884cb4f24bbe405da490f91f5540d3bff585e75",
|
||||
"license": "see the model page",
|
||||
"base": "SDXL 1.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/255155?fileId=197900",
|
||||
"page": "https://civitai.com/models/136070"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_lllite_depth": {
|
||||
"title": "anima-lllite-depth-1",
|
||||
"file": "anima-lllite-depth-1.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "",
|
||||
"size": 7864128,
|
||||
"sha256": "81357e51cd59874b4fbdda1eb3117aa73eafe36cc7efb9cf80a152d6bc8d0602",
|
||||
"license": "CircleStone Labs Non-Commercial License v1.0",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-depth-1.safetensors",
|
||||
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_lllite_pose": {
|
||||
"title": "anima-lllite-pose-1",
|
||||
"file": "anima-lllite-pose-1.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "",
|
||||
"size": 23310040,
|
||||
"sha256": "ddb543be5e74ce8ca79ae45807aa0c1328a4888e00706727c6fd7100dde7c864",
|
||||
"license": "CircleStone Labs Non-Commercial License v1.0",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-pose-1.safetensors",
|
||||
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_lllite_inpainting": {
|
||||
"title": "anima-lllite-inpainting-v2",
|
||||
"file": "anima-lllite-inpainting-v2.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "",
|
||||
"size": 65772872,
|
||||
"sha256": "5242e677d2be34ee70ca7c97c3b14ff5ee49838c03fc1e60ac4852a180db6ef5",
|
||||
"license": "CircleStone Labs Non-Commercial License v1.0",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-inpainting-v2.safetensors",
|
||||
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_lllite_lineart": {
|
||||
"title": "anima-lllite-lineart-1",
|
||||
"file": "anima-lllite-lineart-1.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "",
|
||||
"size": 7787888,
|
||||
"sha256": "1abfde93493445390632788339627c37c69c7792aef1e8104cf15ea5f2d379e9",
|
||||
"license": "CircleStone Labs Non-Commercial License v1.0",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-lineart-1.safetensors",
|
||||
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_tile_repair": {
|
||||
"title": "Anima Tile & Repair ControlNet-LLLite v2",
|
||||
"file": "animaTileRepair_v20.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "",
|
||||
"size": 94037744,
|
||||
"sha256": "be5a9ead6527329237a66a0eb234ccea4cddb140c1d1e1b7d0776e43f5ff1330",
|
||||
"license": "Civitai: credit not needed, derivatives allowed",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/3068951?fileId=2947687",
|
||||
"page": "https://civitai.com/models/2708551"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_turbo_lora": {
|
||||
"title": "anima-turbo-lora-v0.2 (Anima, fewer steps)",
|
||||
"file": "anima-turbo-lora-v0.2.safetensors",
|
||||
"folder": "loras",
|
||||
"subfolder": "",
|
||||
"size": 148902616,
|
||||
"sha256": "1b55e40bdb1d0e5a78cb498f245fccfdaae97823265db957d2aabdcf4cd3caf1",
|
||||
"license": "CircleStone Labs Non-Commercial License v1.0",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/circlestone-labs/Anima-Official-LoRAs/resolve/main/anima-turbo-lora-v0.2.safetensors",
|
||||
"page": "https://huggingface.co/circlestone-labs/Anima-Official-LoRAs"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"job": "new_background",
|
||||
"rank": 4,
|
||||
"short": "new background",
|
||||
"title": {
|
||||
"zh": "换场景·人物不变",
|
||||
"en": "New scene, same character"
|
||||
},
|
||||
"about": {
|
||||
"zh": "自动抠出人物,只按新场景重画背景。",
|
||||
"en": "Cuts the character out and redraws only the background as the new scene."
|
||||
},
|
||||
"source": {
|
||||
"zh": "常见的“抠图换背景”做法:inspyrenet-rembg 抠人物,反选成背景,用 ComfyUI 自带的局部重绘节点只重画背景",
|
||||
"en": "The common cut-out-and-replace-the-background way: inspyrenet-rembg finds the character, its inverse is the background, ComfyUI's own inpainting nodes redraw only that"
|
||||
},
|
||||
"packs": {
|
||||
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
|
||||
},
|
||||
"takes": "picture",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"torchscript_jit": "default"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"class_type": "InvertMask",
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"cut",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"class_type": "GrowMask",
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"background",
|
||||
0
|
||||
],
|
||||
"expand": 6,
|
||||
"tapered_corners": true
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"edge",
|
||||
0
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"cut",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"after": "Write the new scene in the prompt and keep the character's own words (who, hair, clothes) so its edge fits, then run_workflow. The picture's size is the drawing's: the empty latent is no longer used. The kept character (cut out) is among the run's previews: if part of it is missing or the old background clings to it, say so; a halo at its edge: raise the GrowMask expand (6) a little.",
|
||||
"notes": "Denoise 1.0: the background is drawn anew, not changed. GrowMask lets the background reach 6 px into the character so the seam is redrawn.",
|
||||
"models": [],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-28",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"job": "follow",
|
||||
"want": "lines",
|
||||
"rank": 4,
|
||||
"short": "colour lines",
|
||||
"title": {
|
||||
"zh": "线稿上色",
|
||||
"en": "Colour a line drawing"
|
||||
},
|
||||
"about": {
|
||||
"zh": "给黑白线稿上色,颜色和画风写在提示词里。",
|
||||
"en": "Colours a line drawing; the prompt says the colours and style."
|
||||
},
|
||||
"source": {
|
||||
"zh": "Union 控制网的线稿模式(参考工作流作者的控制网组用的就是 Union)",
|
||||
"en": "The union ControlNet's lineart mode (the reference workflow's ControlNet group uses a union model)"
|
||||
},
|
||||
"packs": {},
|
||||
"takes": "reference",
|
||||
"control": "lineart",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"lines": {
|
||||
"class_type": "ImageInvert",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"controlnet": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {
|
||||
"control_net_name": "$controlnet"
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"control_net": [
|
||||
"controlnet",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"lines",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.9,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.9
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"lines",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "Set the picture's size to the drawing's shape (get_image_info on it; the empty latent's width and height), write the colours and style in the prompt (what is drawn, hair, clothes, background), then run_workflow. Its lines (white on black) are among the run's previews: if they are black on white, the drawing was light lines on dark: remove_nodes the ImageInvert and wire the picture straight in.",
|
||||
"notes": "A drawing is black lines on white; a lineart ControlNet reads white lines on black, hence ImageInvert.",
|
||||
"models": [
|
||||
"union_promax_sdxl",
|
||||
"anima_lllite_lineart"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "1216×832",
|
||||
"seconds": 34
|
||||
}
|
||||
]
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"packs": {
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"control": null,
|
||||
"lllite": "lineart",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"lines": {
|
||||
"class_type": "ImageInvert",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"lines",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"lllite_name": "$lllite",
|
||||
"image": [
|
||||
"lines",
|
||||
0
|
||||
],
|
||||
"strength": 1.0,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 1.0,
|
||||
"preserve_wrapper": true
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model Anima's lineart LLLite steers it (strength 1.0). Set the picture's size to the drawing's shape (get_image_info on it; the empty latent's width and height), write the colours and style in the prompt (what is drawn, hair, clothes, background), then run_workflow. Its lines (white on black) are among the run's previews: if they are black on white, the drawing was light lines on dark: remove_nodes the ImageInvert and wire the picture straight in."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"job": "follow",
|
||||
"want": "layout",
|
||||
"rank": 2,
|
||||
"short": "its layout",
|
||||
"title": {
|
||||
"zh": "照着构图画",
|
||||
"en": "Follow a layout"
|
||||
},
|
||||
"about": {
|
||||
"zh": "读出参考图的深度,照着它的构图画。",
|
||||
"en": "Reads a picture's depth and draws to its layout."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「底图控制网」组(作者默认用深度)",
|
||||
"en": "The reference workflow's ControlNet group (depth, its default)"
|
||||
},
|
||||
"packs": {
|
||||
"DepthAnythingV2Preprocessor": "comfyui_controlnet_aux"
|
||||
},
|
||||
"takes": "reference",
|
||||
"control": "depth",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"depth": {
|
||||
"class_type": "DepthAnythingV2Preprocessor",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"ckpt_name": "depth_anything_v2_vitl.pth",
|
||||
"resolution": 1024
|
||||
}
|
||||
},
|
||||
"controlnet": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {
|
||||
"control_net_name": "$controlnet"
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"control_net": [
|
||||
"controlnet",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"depth",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.7,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.7
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"depth",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "run_workflow: the picture now keeps that layout (strength 0.7, for the first 70% of the steps; lower either to let the model change more). The prompt still says what is drawn. Its depth map is among the run's previews: if it is empty or wrong (a crowded or very stylised picture), the picture is too hard to read.",
|
||||
"variants": {
|
||||
"anima": {
|
||||
"packs": {
|
||||
"DepthAnythingV2Preprocessor": "comfyui_controlnet_aux",
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"control": null,
|
||||
"lllite": "depth",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"depth": {
|
||||
"class_type": "DepthAnythingV2Preprocessor",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"ckpt_name": "depth_anything_v2_vitl.pth",
|
||||
"resolution": 1024
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"lllite_name": "$lllite",
|
||||
"image": [
|
||||
"depth",
|
||||
0
|
||||
],
|
||||
"strength": 1.0,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 1.0,
|
||||
"preserve_wrapper": true
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"depth",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model an Anima ControlNet-LLLite steers it, more gently than an SDXL ControlNet: say in the prompt what the picture shows (how many people, what they do), or the prompt wins. Strength 1.0 for all the steps; up to 1.5 holds it closer. run_workflow. Its depth map is among the run's previews: if it is empty or wrong (a crowded or very stylised picture), the picture is too hard to read."
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
"xinsir_depth_sdxl",
|
||||
"union_promax_sdxl",
|
||||
"anima_lllite_depth"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima",
|
||||
"size": "1216×832",
|
||||
"seconds": 115
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"job": "follow",
|
||||
"want": "pose",
|
||||
"rank": 1,
|
||||
"short": "its pose",
|
||||
"title": {
|
||||
"zh": "照着姿势画",
|
||||
"en": "Follow a pose"
|
||||
},
|
||||
"about": {
|
||||
"zh": "读出参考图的姿势,照着这个姿势画。",
|
||||
"en": "Reads a picture's pose and draws to it."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「底图控制网」组",
|
||||
"en": "The reference workflow's ControlNet group"
|
||||
},
|
||||
"packs": {
|
||||
"DWPreprocessor": "comfyui_controlnet_aux"
|
||||
},
|
||||
"takes": "reference",
|
||||
"control": "pose",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"pose": {
|
||||
"class_type": "DWPreprocessor",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"detect_hand": "enable",
|
||||
"detect_body": "enable",
|
||||
"detect_face": "enable",
|
||||
"resolution": 1024,
|
||||
"bbox_detector": "yolox_l.torchscript.pt",
|
||||
"pose_estimator": "dw-ll_ucoco_384_bs5.torchscript.pt",
|
||||
"scale_stick_for_xinsr_cn": "disable"
|
||||
}
|
||||
},
|
||||
"controlnet": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {
|
||||
"control_net_name": "$controlnet"
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"control_net": [
|
||||
"controlnet",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"pose",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.8,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"pose",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "run_workflow: the picture now takes that pose (strength 0.8, for the first 80% of the steps; lower either for a looser pose). The prompt still says who and what: change it if it fights the pose. Its pose skeleton is among the run's previews: if it is empty or wrong (a crowded or very stylised picture), try control_depth.",
|
||||
"variants": {
|
||||
"anima": {
|
||||
"packs": {
|
||||
"DWPreprocessor": "comfyui_controlnet_aux",
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"control": null,
|
||||
"lllite": "pose",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"pose": {
|
||||
"class_type": "DWPreprocessor",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"detect_hand": "enable",
|
||||
"detect_body": "enable",
|
||||
"detect_face": "enable",
|
||||
"resolution": 1024,
|
||||
"bbox_detector": "yolox_l.torchscript.pt",
|
||||
"pose_estimator": "dw-ll_ucoco_384_bs5.torchscript.pt",
|
||||
"scale_stick_for_xinsr_cn": "disable"
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"lllite_name": "$lllite",
|
||||
"image": [
|
||||
"pose",
|
||||
0
|
||||
],
|
||||
"strength": 1.0,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 1.0,
|
||||
"preserve_wrapper": true
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"pose",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model an Anima ControlNet-LLLite steers it, more gently than an SDXL ControlNet: say in the prompt what the picture shows (how many people, what they do), or the prompt wins. Strength 1.0 for all the steps; up to 1.5 holds it closer. run_workflow. Its pose skeleton is among the run's previews: if it is empty or wrong (a crowded or very stylised picture), try control_depth."
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
"noob_openpose",
|
||||
"thibauld_openpose_sdxl",
|
||||
"union_promax_sdxl",
|
||||
"anima_lllite_pose"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": 64
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"job": "detail",
|
||||
"rank": 1,
|
||||
"short": "after",
|
||||
"title": {
|
||||
"zh": "部位细化(脸和手)",
|
||||
"en": "Face and hand detailing"
|
||||
},
|
||||
"about": {
|
||||
"zh": "出图后找出脸和手,各自放大重画再贴回去。",
|
||||
"en": "After a picture is made, faces and hands are found, redrawn larger and pasted back."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「部位细化」组",
|
||||
"en": "The reference workflow's detailing group"
|
||||
},
|
||||
"packs": {
|
||||
"FaceDetailer": "ComfyUI-Impact-Pack",
|
||||
"UltralyticsDetectorProvider": "ComfyUI-Impact-Subpack"
|
||||
},
|
||||
"detectors": {
|
||||
"face": "face_yolov8",
|
||||
"hand": "hand_yolov8"
|
||||
},
|
||||
"nodes": {
|
||||
"face_finder": {
|
||||
"class_type": "UltralyticsDetectorProvider",
|
||||
"inputs": {
|
||||
"model_name": "$face"
|
||||
}
|
||||
},
|
||||
"hand_finder": {
|
||||
"class_type": "UltralyticsDetectorProvider",
|
||||
"inputs": {
|
||||
"model_name": "$hand"
|
||||
}
|
||||
},
|
||||
"face": {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"bbox_detector": [
|
||||
"face_finder",
|
||||
0
|
||||
],
|
||||
"denoise": 0.4,
|
||||
"guide_size": 512
|
||||
}
|
||||
},
|
||||
"hands": {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"face",
|
||||
"image"
|
||||
],
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"bbox_detector": [
|
||||
"hand_finder",
|
||||
0
|
||||
],
|
||||
"denoise": 0.45,
|
||||
"guide_size": 512
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"hands",
|
||||
"image"
|
||||
],
|
||||
"filename_prefix": "detailed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "run_workflow saves the picture as before and the detailed one beside it (prefix detailed).",
|
||||
"models": [
|
||||
"face_yolov8m",
|
||||
"hand_yolov8s"
|
||||
],
|
||||
"tested": {}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
{
|
||||
"job": "detail",
|
||||
"want": "hands",
|
||||
"rank": 2,
|
||||
"short": "enlarge, then hands (fallback)",
|
||||
"enlarge": {
|
||||
"megapixels": 4.2,
|
||||
"most": 2.0,
|
||||
"unknown": 1.5
|
||||
},
|
||||
"title": {
|
||||
"zh": "修手·先放大再修(备选)",
|
||||
"en": "Fix hands: enlarge, then fix (fallback)"
|
||||
},
|
||||
"about": {
|
||||
"zh": "整张图放大到约 400 万像素(小图 2 倍)轻轻重画,让手变大;再把每只手放大重画后贴回去:有 SAM3 模型时先按文字找出贴着手形的轮廓重画一遍,再用手部检测框补修一遍。",
|
||||
"en": "The whole picture is enlarged to about 4 megapixels (2× for a small one) and redrawn lightly, so the hands grow; then each hand is redrawn larger and pasted back: with the SAM3 model here, first inside the outline it finds by the word, then once more in the box the hand detector finds."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考作者的「先放大重画」和「手部精修」两步合在一起,2026-10-29 在 RTX 4050 上试出这组数;SAM3 按文字找手那一轮照他的连法,2026-11-03 加入",
|
||||
"en": "The reference author's light redraw after enlarging and his hand detailing, put together; these numbers tried on an RTX 4050 on 2026-10-29; the SAM3 pass finding hands by the word follows his wiring, added on 2026-11-03"
|
||||
},
|
||||
"packs": {
|
||||
"FaceDetailer": "ComfyUI-Impact-Pack",
|
||||
"UltralyticsDetectorProvider": "ComfyUI-Impact-Subpack"
|
||||
},
|
||||
"takes": "picture",
|
||||
"detectors": {
|
||||
"hand": "hand_yolov8"
|
||||
},
|
||||
"checkpoints": {
|
||||
"sam3": "sam3"
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 0.4
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"bigger": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"upscale_method": "lanczos",
|
||||
"scale_by": "$enlarge"
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"bigger",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"hand_seeker": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"optional": true,
|
||||
"_meta": {
|
||||
"title": "SAM3"
|
||||
},
|
||||
"inputs": {
|
||||
"ckpt_name": "$sam3"
|
||||
}
|
||||
},
|
||||
"hand_words": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"optional": true,
|
||||
"_meta": {
|
||||
"title": "SAM3: what to find"
|
||||
},
|
||||
"inputs": {
|
||||
"text": "hand:8",
|
||||
"clip": [
|
||||
"hand_seeker",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"hand_shapes": {
|
||||
"class_type": "SAM3_Detect",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"model": [
|
||||
"hand_seeker",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"hand_words",
|
||||
0
|
||||
],
|
||||
"threshold": 0.5,
|
||||
"refine_iterations": 2,
|
||||
"individual_masks": false
|
||||
}
|
||||
},
|
||||
"hand_areas": {
|
||||
"class_type": "MaskToSEGS",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"hand_shapes",
|
||||
0
|
||||
],
|
||||
"combined": false,
|
||||
"crop_factor": 2.0,
|
||||
"bbox_fill": false,
|
||||
"drop_size": 10,
|
||||
"contour_fill": false
|
||||
}
|
||||
},
|
||||
"shaped_hands": {
|
||||
"class_type": "DetailerForEach",
|
||||
"like": "$sampler",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"segs": [
|
||||
"hand_areas",
|
||||
0
|
||||
],
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"guide_size": 768,
|
||||
"max_size": 1024,
|
||||
"denoise": 0.45,
|
||||
"feather": 5,
|
||||
"noise_mask": true,
|
||||
"force_inpaint": true,
|
||||
"noise_mask_feather": 10,
|
||||
"wildcard": "detailed hand"
|
||||
}
|
||||
},
|
||||
"hand_finder": {
|
||||
"class_type": "UltralyticsDetectorProvider",
|
||||
"inputs": {
|
||||
"model_name": "$hand"
|
||||
}
|
||||
},
|
||||
"hands": {
|
||||
"class_type": "FaceDetailer",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"shaped_hands|$decode",
|
||||
0
|
||||
],
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"bbox_detector": [
|
||||
"hand_finder",
|
||||
0
|
||||
],
|
||||
"guide_size": 768,
|
||||
"max_size": 1024,
|
||||
"denoise": 0.45,
|
||||
"bbox_threshold": 0.3,
|
||||
"bbox_crop_factor": 2.0,
|
||||
"feather": 5,
|
||||
"drop_size": 10,
|
||||
"wildcard": "detailed hand"
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"hands",
|
||||
"image"
|
||||
],
|
||||
"filename_prefix": "hands_fixed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "A fallback: a FLUX-Fill redraw of one square crop around each hand did better in the user's tests; use that when you have it. The picture is enlarged to about 4 megapixels (2× for one of about 1 megapixel, at most 2×, not at all for a large one) and drawn again lightly (the sampler now takes it, at denoise 0.4), then the hands are redrawn larger: with SAM3 here, first each hand it finds by the word inside its own outline (shaped_hands), then each hand the detector finds, in its box (hands, denoise 0.45), saved with prefix hands_fixed (without SAM3, which the user can get in the ways panel, only the detector's pass); the enlarged one is saved too, without the hand pass. About 3 tries in 4 gave a good hand in the pictures tried (one or two people, simple poses), no rate for crowds or hard poses: run_workflow with batch 4 (each try its own seed), then check them before the user does: zoom_image each hands_fixed picture on the hand, compare_with the enlarged one (the light redraw alone already changes faces and hands), and drop a hand with the wrong number of fingers, fused or melted fingers, one far bigger or smaller than its arm, a seam at its edge, or a hand that was fine made worse; say if a hand was missed. None good: run_workflow batch 4 again, at most twice. Then a picks panel zoomed on the hand lets the user choose among the good ones.",
|
||||
"notes": "Tried on the Codex test picture (1280×832, three characters, the hand about 110×78 px): the hand pass alone gave a good hand in about half the seeds whatever its size (512 or 768) and denoise (0.45 or 0.55); enlarging 1.5× first gave good hands in 3 of 4, 2× gave sharper fingers in the same seeds (about 105 s instead of 75 s). Denoise 0.4 for the enlarged redraw kept the layout; faces and splashes change a little. The SAM3 pass (2026-11-03): its word says how many it takes, \"hand\" alone only the likeliest hand, \"hand:8\" up to eight; on a 832×1216 picture of a girl waving both hands (enlarged 2×) it outlined both hands closely at threshold 0.5 (0.2 found no more), about 112 s with both passes on Anima with the Turbo LoRA. GPT's check the same day (two people, one hand with fused fingers, two seeds): SAM3 found both hands (one outline took in the wrist), both versions gave acceptable hands; on that one picture the SAM3 pass was not shown better than the detector alone.",
|
||||
"models": [
|
||||
"hand_yolov8s",
|
||||
"sam3_1",
|
||||
"anima_turbo_lora"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-03",
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima (Kirazuri v4, Turbo LoRA)",
|
||||
"size": "1280×832 → 2560×1664",
|
||||
"seconds": 105
|
||||
},
|
||||
{
|
||||
"model": "Anima (MiaoMiao Harem 1.5, Turbo LoRA, SAM3 pass)",
|
||||
"size": "832×1216 → 1664×2432",
|
||||
"seconds": 112
|
||||
}
|
||||
]
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"loras": {
|
||||
"turbo": "anima-turbo-lora"
|
||||
},
|
||||
"without_loras": "On an Anima model the Anima Turbo LoRA (anima-turbo-lora-v0.2, the user can get it in the ways panel) makes this about three times faster: 10 steps at CFG 1.",
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 0.4,
|
||||
"steps": 10,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal"
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"turbo": {
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"lora_name": "$turbo",
|
||||
"strength_model": 0.7
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"bigger": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"upscale_method": "lanczos",
|
||||
"scale_by": "$enlarge"
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"bigger",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"hand_seeker": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"optional": true,
|
||||
"_meta": {
|
||||
"title": "SAM3"
|
||||
},
|
||||
"inputs": {
|
||||
"ckpt_name": "$sam3"
|
||||
}
|
||||
},
|
||||
"hand_words": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"optional": true,
|
||||
"_meta": {
|
||||
"title": "SAM3: what to find"
|
||||
},
|
||||
"inputs": {
|
||||
"text": "hand:8",
|
||||
"clip": [
|
||||
"hand_seeker",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"hand_shapes": {
|
||||
"class_type": "SAM3_Detect",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"model": [
|
||||
"hand_seeker",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"hand_words",
|
||||
0
|
||||
],
|
||||
"threshold": 0.5,
|
||||
"refine_iterations": 2,
|
||||
"individual_masks": false
|
||||
}
|
||||
},
|
||||
"hand_areas": {
|
||||
"class_type": "MaskToSEGS",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"hand_shapes",
|
||||
0
|
||||
],
|
||||
"combined": false,
|
||||
"crop_factor": 2.0,
|
||||
"bbox_fill": false,
|
||||
"drop_size": 10,
|
||||
"contour_fill": false
|
||||
}
|
||||
},
|
||||
"shaped_hands": {
|
||||
"class_type": "DetailerForEach",
|
||||
"like": "$sampler",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"segs": [
|
||||
"hand_areas",
|
||||
0
|
||||
],
|
||||
"model": [
|
||||
"turbo",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"guide_size": 768,
|
||||
"max_size": 1024,
|
||||
"steps": 10,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal",
|
||||
"denoise": 0.45,
|
||||
"feather": 5,
|
||||
"noise_mask": true,
|
||||
"force_inpaint": true,
|
||||
"noise_mask_feather": 10,
|
||||
"wildcard": "detailed hand"
|
||||
}
|
||||
},
|
||||
"hand_finder": {
|
||||
"class_type": "UltralyticsDetectorProvider",
|
||||
"inputs": {
|
||||
"model_name": "$hand"
|
||||
}
|
||||
},
|
||||
"hands": {
|
||||
"class_type": "FaceDetailer",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"shaped_hands|$decode",
|
||||
0
|
||||
],
|
||||
"model": [
|
||||
"turbo",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"bbox_detector": [
|
||||
"hand_finder",
|
||||
0
|
||||
],
|
||||
"guide_size": 768,
|
||||
"max_size": 1024,
|
||||
"steps": 10,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal",
|
||||
"denoise": 0.45,
|
||||
"bbox_threshold": 0.3,
|
||||
"bbox_crop_factor": 2.0,
|
||||
"feather": 5,
|
||||
"drop_size": 10,
|
||||
"wildcard": "detailed hand"
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"hands",
|
||||
"image"
|
||||
],
|
||||
"filename_prefix": "hands_fixed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "A fallback: a FLUX-Fill redraw of one square crop around each hand did better in the user's tests; use that when you have it. On this Anima model the Anima Turbo LoRA (0.7) makes both passes 10 steps at CFG 1, about 105 s for 1280×832 (2×) on a 6 GB card. The picture is enlarged to about 4 megapixels (2× for one of about 1 megapixel, at most 2×, not at all for a large one) and drawn again lightly (the sampler now takes it, at denoise 0.4), then the hands are redrawn larger: with SAM3 here, first each hand it finds by the word inside its own outline (shaped_hands), then each hand the detector finds, in its box (hands, denoise 0.45), saved with prefix hands_fixed (without SAM3, which the user can get in the ways panel, only the detector's pass); the enlarged one is saved too, without the hand pass. About 3 tries in 4 gave a good hand in the pictures tried (one or two people, simple poses), no rate for crowds or hard poses: run_workflow with batch 4 (each try its own seed), then check them before the user does: zoom_image each hands_fixed picture on the hand, compare_with the enlarged one (the light redraw alone already changes faces and hands), and drop a hand with the wrong number of fingers, fused or melted fingers, one far bigger or smaller than its arm, a seam at its edge, or a hand that was fine made worse; say if a hand was missed. None good: run_workflow batch 4 again, at most twice. Then a picks panel zoomed on the hand lets the user choose among the good ones."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"job": "repaint",
|
||||
"rank": 3,
|
||||
"short": "built-in nodes",
|
||||
"title": {
|
||||
"zh": "局部重绘·官方节点",
|
||||
"en": "Repaint: built-in nodes"
|
||||
},
|
||||
"about": {
|
||||
"zh": "只用 ComfyUI 自带的节点重画圈出的地方,没装别的包时用。",
|
||||
"en": "ComfyUI's own nodes only: for when the packs above are missing."
|
||||
},
|
||||
"source": {
|
||||
"zh": "ComfyUI 官方的局部重绘做法",
|
||||
"en": "ComfyUI's own way to inpaint"
|
||||
},
|
||||
"packs": {},
|
||||
"takes": "inpaint",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"picture",
|
||||
1
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 0.5
|
||||
}
|
||||
},
|
||||
"after": "The sampler repaints only the area, at denoise 0.5 so it keeps the picture's look (higher changes more). The marked area's size becomes the size of what is painted there: mark tightly around it (a hand, not the arm beside it). Keep denoise 0.35-0.5 to fix something and keep its size and pose; 0.7 and up paints a new thing that fills the whole mark. Hands and faces vary a lot from seed to seed: run_workflow with batch 4 and let the user pick, rather than judging one. Then run_workflow.",
|
||||
"models": [],
|
||||
"tested": {}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
{
|
||||
"job": "repaint",
|
||||
"rank": 1,
|
||||
"short": "small fixes",
|
||||
"title": {
|
||||
"zh": "局部重绘·裁切缝合",
|
||||
"en": "Repaint: crop and stitch"
|
||||
},
|
||||
"about": {
|
||||
"zh": "把圈出的地方连同周围一圈裁出来放大重画,再贴回去,适合小地方。",
|
||||
"en": "Crops the marked area with a margin, redraws it larger and pastes it back: for small spots."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「局部重绘」组,和节点包自带的示例一致",
|
||||
"en": "The reference workflow's repaint group, as the pack's own example"
|
||||
},
|
||||
"packs": {
|
||||
"InpaintCropImproved": "ComfyUI-Inpaint-CropAndStitch",
|
||||
"InpaintStitchImproved": "ComfyUI-Inpaint-CropAndStitch"
|
||||
},
|
||||
"instead": "inpaint_basic",
|
||||
"takes": "inpaint",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"crop": {
|
||||
"class_type": "InpaintCropImproved",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"picture",
|
||||
1
|
||||
],
|
||||
"mask_blend_pixels": 32,
|
||||
"context_from_mask_extend_factor": 2.0,
|
||||
"output_resize_to_target_size": true,
|
||||
"output_target_width": 1024,
|
||||
"output_target_height": 1024,
|
||||
"downscale_algorithm": "lanczos",
|
||||
"upscale_algorithm": "lanczos"
|
||||
}
|
||||
},
|
||||
"condition": {
|
||||
"class_type": "InpaintModelConditioning",
|
||||
"inputs": {
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"pixels": [
|
||||
"crop",
|
||||
"cropped_image"
|
||||
],
|
||||
"mask": [
|
||||
"crop",
|
||||
"cropped_mask"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"stitch": {
|
||||
"class_type": "InpaintStitchImproved",
|
||||
"inputs": {
|
||||
"stitcher": [
|
||||
"crop",
|
||||
"stitcher"
|
||||
],
|
||||
"inpainted_image": [
|
||||
"$decode",
|
||||
0
|
||||
]
|
||||
},
|
||||
"wire_to": "$save"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 0.45
|
||||
}
|
||||
},
|
||||
"after": "The sampler now repaints only the cropped area, at denoise 0.45 (lower keeps more of the old). Its prompt is still the whole picture's: set_prompt it to what belongs in the area (\"a hand, five fingers\"), or it paints the whole subject there. The marked area's size becomes the size of what is painted there: mark tightly around it (a hand, not the arm beside it). Keep denoise 0.35-0.5 to fix something and keep its size and pose; 0.7 and up paints a new thing that fills the whole mark. Hands and faces vary a lot from seed to seed: run_workflow with batch 4 and let the user pick, rather than judging one. Then run_workflow; the saved picture is the whole one, stitched.",
|
||||
"notes": "Denoise 0.45: a fix. At 0.55-0.75 a hand came out the size of its mark (too big, 2026-10-29 Codex tests A-C), at 1 a model not made for inpainting paints a whole new subject into the crop; 0.4 kept the hand's size. Context factor 2.0 is the source's.",
|
||||
"variants": {
|
||||
"anima": {
|
||||
"packs": {
|
||||
"InpaintCropImproved": "ComfyUI-Inpaint-CropAndStitch",
|
||||
"InpaintStitchImproved": "ComfyUI-Inpaint-CropAndStitch",
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"lllite": "inpaint",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"soft",
|
||||
0
|
||||
],
|
||||
"lllite_name": "$lllite",
|
||||
"image": [
|
||||
"crop",
|
||||
"cropped_image"
|
||||
],
|
||||
"strength": 1.0,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 1.0,
|
||||
"preserve_wrapper": true,
|
||||
"mask": [
|
||||
"crop",
|
||||
"cropped_mask"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"crop": {
|
||||
"class_type": "InpaintCropImproved",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"picture",
|
||||
1
|
||||
],
|
||||
"mask_blend_pixels": 32,
|
||||
"context_from_mask_extend_factor": 2.0,
|
||||
"output_resize_to_target_size": true,
|
||||
"output_target_width": 1024,
|
||||
"output_target_height": 1024,
|
||||
"downscale_algorithm": "lanczos",
|
||||
"upscale_algorithm": "lanczos"
|
||||
}
|
||||
},
|
||||
"condition": {
|
||||
"class_type": "InpaintModelConditioning",
|
||||
"inputs": {
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"pixels": [
|
||||
"crop",
|
||||
"cropped_image"
|
||||
],
|
||||
"mask": [
|
||||
"crop",
|
||||
"cropped_mask"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"stitch": {
|
||||
"class_type": "InpaintStitchImproved",
|
||||
"inputs": {
|
||||
"stitcher": [
|
||||
"crop",
|
||||
"stitcher"
|
||||
],
|
||||
"inpainted_image": [
|
||||
"$decode",
|
||||
0
|
||||
]
|
||||
},
|
||||
"wire_to": "$save"
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model its repaint LLLite guides it too; the sampler now repaints only the cropped area, at denoise 0.45 (lower keeps more of the old). Its prompt is still the whole picture's: set_prompt it to what belongs in the area (\"a hand, five fingers\"), or it paints the whole subject there. The marked area's size becomes the size of what is painted there: mark tightly around it (a hand, not the arm beside it). Keep denoise 0.35-0.5 to fix something and keep its size and pose; 0.7 and up paints a new thing that fills the whole mark. Hands and faces vary a lot from seed to seed: run_workflow with batch 4 and let the user pick, rather than judging one. Then run_workflow; the saved picture is the whole one, stitched."
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
"anima_lllite_inpainting"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": null
|
||||
},
|
||||
{
|
||||
"model": "Anima",
|
||||
"size": "1216×832",
|
||||
"seconds": 130
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"job": "repaint",
|
||||
"want": "best",
|
||||
"rank": 2,
|
||||
"short": "best blend, slow",
|
||||
"title": {
|
||||
"zh": "局部重绘·LanPaint",
|
||||
"en": "Repaint: LanPaint"
|
||||
},
|
||||
"about": {
|
||||
"zh": "换成 LanPaint 的采样器,边缘融合最好,但慢。",
|
||||
"en": "LanPaint's sampler: the best blend at the edges, but slow."
|
||||
},
|
||||
"source": {
|
||||
"zh": "节点包 LanPaint 自带的 SDXL 示例",
|
||||
"en": "LanPaint's own SDXL example"
|
||||
},
|
||||
"packs": {
|
||||
"LanPaint_KSampler": "LanPaint",
|
||||
"LanPaint_ImageEncode": "LanPaint",
|
||||
"LanPaint_ImageDecode": "LanPaint"
|
||||
},
|
||||
"instead": "inpaint_crop_stitch",
|
||||
"takes": "inpaint",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "LanPaint_ImageEncode",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"mask": [
|
||||
"picture",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"sampler": {
|
||||
"class_type": "LanPaint_KSampler",
|
||||
"replaces": "$sampler",
|
||||
"inputs": {
|
||||
"latent_image": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "LanPaint_ImageDecode",
|
||||
"replaces": "$decode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"sampler",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"picture",
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "The KSampler is now LanPaint's (same steps, cfg and seed): it samples the whole picture and changes only the area, blending it in. Keep the prompt about the whole picture and add what the area should show (set_prompt mode append: \"a hand, five fingers\"). Then run_workflow; it takes a few times longer than before.",
|
||||
"models": [],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": 82
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
{
|
||||
"job": "picture",
|
||||
"rank": 1,
|
||||
"short": "a picture from words",
|
||||
"standalone": true,
|
||||
"title": {
|
||||
"zh": "出图",
|
||||
"en": "Make a picture"
|
||||
},
|
||||
"about": {
|
||||
"zh": "在空画布上按模型的家族搭好出图的节点:SDXL 一类是一个 checkpoint,Anima 是模型、文本编码器、VAE 三个文件;设置用验证过的那一套。",
|
||||
"en": "Builds the nodes that make a picture on an empty canvas, by the model's family: one checkpoint for SDXL and its kin, the model, text encoder and VAE files for Anima; with the tested settings."
|
||||
},
|
||||
"source": {
|
||||
"zh": "ComfyUI 的默认出图流程;Anima 的设置来自你验证过的两段放大卡的底图那一段",
|
||||
"en": "ComfyUI's default text-to-image graph; Anima's settings from the base pass of the user's tested two-pass card"
|
||||
},
|
||||
"packs": {},
|
||||
"nodes": {
|
||||
"model": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"_meta": {
|
||||
"title": "Model"
|
||||
},
|
||||
"inputs": {
|
||||
"ckpt_name": "$model"
|
||||
}
|
||||
},
|
||||
"positive": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": [
|
||||
"model",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"negative": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": [
|
||||
"model",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"size": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {
|
||||
"width": 832,
|
||||
"height": 1216,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"sampler": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"model",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"positive",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"negative",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"size",
|
||||
0
|
||||
],
|
||||
"steps": 28,
|
||||
"cfg": 5.5,
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"sampler",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"model",
|
||||
2
|
||||
]
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "picture"
|
||||
}
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"files": {
|
||||
"$text_encoder": [
|
||||
"CLIPLoader",
|
||||
"clip_name",
|
||||
[
|
||||
"qwen_3_06b",
|
||||
"qwen3_06b"
|
||||
]
|
||||
],
|
||||
"$vae": [
|
||||
"VAELoader",
|
||||
"vae_name",
|
||||
[
|
||||
"qwenimage",
|
||||
"qwen_image"
|
||||
]
|
||||
]
|
||||
},
|
||||
"nodes": {
|
||||
"model": {
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "Model"
|
||||
},
|
||||
"inputs": {
|
||||
"unet_name": "$model",
|
||||
"weight_dtype": "default"
|
||||
}
|
||||
},
|
||||
"text_encoder": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "$text_encoder",
|
||||
"type": "stable_diffusion"
|
||||
}
|
||||
},
|
||||
"vae": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {
|
||||
"vae_name": "$vae"
|
||||
}
|
||||
},
|
||||
"positive": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": [
|
||||
"text_encoder",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"negative": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": [
|
||||
"text_encoder",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"size": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {
|
||||
"width": 832,
|
||||
"height": 1216,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"sampler": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"model",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"positive",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"negative",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"size",
|
||||
0
|
||||
],
|
||||
"steps": 35,
|
||||
"cfg": 4.4,
|
||||
"sampler_name": "dpmpp_2m_sde_gpu",
|
||||
"scheduler": "beta",
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"sampler",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"vae",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "picture"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "The picture's nodes are on the canvas, on the Anima model picked (the loader titled Model), with its text encoder and VAE and the tested sampling (35 steps, dpmpp_2m_sde_gpu, beta, cfg 4.4, 832x1216). run_workflow with prompt and negative fills them in. A style LoRA the user picked: add_lora. A second pass after it (enlarge, another model): add_nodes with that use."
|
||||
}
|
||||
},
|
||||
"after": "The picture's nodes are on the canvas, on the checkpoint picked (the loader titled Model), with common SDXL settings (28 steps, euler_ancestral, cfg 5.5, 832x1216; get_model's author_examples has what the model's author used). run_workflow with prompt and negative fills them in. A style LoRA the user picked: add_lora. A second pass after it (enlarge, another model): add_nodes with that use.",
|
||||
"notes": "SDXL's values are common ones, not tested per model here; Anima's are the base pass of the user's tested two-pass card.",
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-03",
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima (MiaoMiao Harem 1.5)",
|
||||
"size": "832×1216",
|
||||
"seconds": 78
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"job": "new_background",
|
||||
"want": "given",
|
||||
"rank": 5,
|
||||
"short": "given place",
|
||||
"title": {
|
||||
"zh": "放进另一张图",
|
||||
"en": "Put a character into a picture"
|
||||
},
|
||||
"about": {
|
||||
"zh": "把人物抠出来贴进你给的背景图,再轻轻重画让它融进去。",
|
||||
"en": "Pastes the cut-out character into a given picture, then lightly redraws so it fits."
|
||||
},
|
||||
"source": {
|
||||
"zh": "常见的“抠图合成再重绘融合”做法:inspyrenet-rembg 抠人物,ComfyUI 自带的节点缩放、贴图和局部重绘",
|
||||
"en": "The common cut-out, composite and blend-by-redrawing way: inspyrenet-rembg finds the character, ComfyUI's own nodes scale, paste and redraw"
|
||||
},
|
||||
"packs": {
|
||||
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
|
||||
},
|
||||
"takes": "picture",
|
||||
"scene": true,
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"place": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$scene"
|
||||
}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"torchscript_jit": "default"
|
||||
}
|
||||
},
|
||||
"size": {
|
||||
"class_type": "GetImageSize",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"fit": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"place",
|
||||
0
|
||||
],
|
||||
"upscale_method": "lanczos",
|
||||
"width": [
|
||||
"size",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"size",
|
||||
1
|
||||
],
|
||||
"crop": "center"
|
||||
}
|
||||
},
|
||||
"paste": {
|
||||
"class_type": "ImageCompositeMasked",
|
||||
"inputs": {
|
||||
"destination": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"source": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"resize_source": false,
|
||||
"mask": [
|
||||
"cut",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"class_type": "InvertMask",
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"cut",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"class_type": "GrowMask",
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"background",
|
||||
0
|
||||
],
|
||||
"expand": 6,
|
||||
"tapered_corners": true
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"paste",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"edge",
|
||||
0
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"paste",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 0.5
|
||||
}
|
||||
},
|
||||
"after": "Write both in the prompt, the character (who, hair, clothes) and the place as it looks, then run_workflow. The size is the character picture's; the place is cropped to it. The pasted picture before the redraw is among the previews. The place changes too much: lower the denoise (0.5); the character still looks pasted on: raise it a little.",
|
||||
"notes": "Denoise 0.5 redraws the place lightly around the character, keeping its look; the character itself is not redrawn but for 6 px of its edge.",
|
||||
"models": [],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-28",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": 40
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"job": "refine",
|
||||
"rank": 6,
|
||||
"short": "other model",
|
||||
"title": {
|
||||
"zh": "换模型精修",
|
||||
"en": "Refine with another model"
|
||||
},
|
||||
"about": {
|
||||
"zh": "换另一个模型把整张图轻轻重画一遍,另存一张(单文件模型和 Anima 都行)。",
|
||||
"en": "Another model lightly redraws the whole picture, saved beside it (a single-file checkpoint or an Anima model)."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「Refiner 管线」:精修时可以换一个底模加载器,配一套独立的 LoRA 堆",
|
||||
"en": "The reference workflow's refiner: a base model loader of its own for refining, with a separate LoRA stack"
|
||||
},
|
||||
"packs": {},
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"second_positive": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"like": "$sampler.positive",
|
||||
"inputs": {
|
||||
"clip": [
|
||||
"second_model",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"second_negative": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"like": "$sampler.negative",
|
||||
"inputs": {
|
||||
"clip": [
|
||||
"second_model",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"second_model",
|
||||
2
|
||||
]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"second_model",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"second_positive",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"second_negative",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"denoise": 0.4
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"redraw",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"second_model",
|
||||
2
|
||||
]
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "refined"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "The refine's loader (second_model) starts as the same checkpoint: set_model it to the one the user wants (ask_user, search_models), add_lora its own LoRAs onto it, and write its prompts (second_positive, second_negative, copied from the first) in that model's style. Another family may want other steps, cfg and sampler on redraw. Then run_workflow; the refined one is saved beside it (prefix refined). Denoise 0.4: lower keeps more of the first, higher takes more of the second.",
|
||||
"notes": "The source keeps a separate loader and LoRA stack for refining. Its own CLIP and VAE, so a checkpoint of another family works. On an Anima model (UNET, text encoder and VAE in separate files) its variant loads only another UNET and reuses the canvas's text encoder and VAE: another Anima model.",
|
||||
"models": [],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-02",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious) → NoobAI",
|
||||
"size": "832×1216",
|
||||
"seconds": 66
|
||||
},
|
||||
{
|
||||
"model": "Anima (Kirazuri → Realskin 1.3, denoise 0.4, with the base)",
|
||||
"size": "1216×832",
|
||||
"seconds": 148
|
||||
}
|
||||
]
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "UNETLoader",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"second_model",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"latent_image": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"denoise": 0.4
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"redraw",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "refined"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model the refine's loader (second_model) is a UNET loader that starts as the canvas's model; it reuses the canvas's text encoder, VAE and prompts. set_model it to the other Anima model the user wants (ask_user, search_models); a LoRA on the first model is not on it: add_lora it onto second_model when the user wants that look kept. Then run_workflow; the refined one is saved beside it (prefix refined). Denoise 0.4: lower keeps more of the first, higher takes more of the second."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"job": "process",
|
||||
"want": "cut",
|
||||
"rank": 1,
|
||||
"short": "cut-out",
|
||||
"title": {
|
||||
"zh": "背景移除",
|
||||
"en": "Remove the background"
|
||||
},
|
||||
"about": {
|
||||
"zh": "抠掉背景,存成透明背景的 PNG。",
|
||||
"en": "Cuts the background away into a transparent PNG."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「背景移除」组,换成你装的 inspyrenet-rembg",
|
||||
"en": "The reference workflow's background removal, with your inspyrenet-rembg"
|
||||
},
|
||||
"packs": {
|
||||
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
|
||||
},
|
||||
"takes": "picture",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"torchscript_jit": "default"
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"cut",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "nobg"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "run_workflow saves it with the background cut away (transparent PNG, prefix nobg). On a canvas that makes pictures those are made too: to cut this one out alone, add the block on a blank tab (new_workflow).",
|
||||
"models": [],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "—",
|
||||
"size": "1280×832",
|
||||
"seconds": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"job": "follow",
|
||||
"want": "style",
|
||||
"rank": 5,
|
||||
"short": "new style",
|
||||
"title": {
|
||||
"zh": "换画风·构图不变",
|
||||
"en": "New style, same layout"
|
||||
},
|
||||
"about": {
|
||||
"zh": "用轮廓线钉住构图,按新画风整张重画。",
|
||||
"en": "Outlines hold the layout while the whole picture is redrawn in a new style."
|
||||
},
|
||||
"source": {
|
||||
"zh": "常见的“控制网 + 以图生图”换风格做法,轮廓用 Union 控制网的 canny 模式",
|
||||
"en": "The common ControlNet-plus-img2img restyle, the outlines through the union ControlNet's canny mode"
|
||||
},
|
||||
"packs": {},
|
||||
"takes": "reference",
|
||||
"control": "lineart",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"edges": {
|
||||
"class_type": "Canny",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"low_threshold": 0.4,
|
||||
"high_threshold": 0.8
|
||||
}
|
||||
},
|
||||
"controlnet": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {
|
||||
"control_net_name": "$controlnet"
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"control_net": [
|
||||
"controlnet",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"edges",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.7,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"edges",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"after": "Write what is in the picture and the new style in the prompt (e.g. watercolor, flat colors, oil painting), or add_lora a style LoRA, then run_workflow. The size is the picture's. Its outlines are among the previews. To keep more of the old colours: lower the denoise to 0.8 (the style then changes less); the layout drifts: raise the ControlNet strength (0.7).",
|
||||
"notes": "Canny outlines through the union ControlNet (its canny/lineart mode); Tried: denoise 0.8 kept the old look (only lighter); 1.0 gave the style, the outlines held the layout.",
|
||||
"models": [
|
||||
"union_promax_sdxl"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-28",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": 41
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"job": "refine",
|
||||
"want": "midway",
|
||||
"rank": 7,
|
||||
"short": "switch mid-way",
|
||||
"title": {
|
||||
"zh": "半途换模型接着画",
|
||||
"en": "Switch models mid-way"
|
||||
},
|
||||
"about": {
|
||||
"zh": "前面的步数用原模型定下构图,后面的步数换另一个模型接着画完,画风和细节跟着它走。",
|
||||
"en": "The first steps keep the model's composition, then another model draws the rest, with its look and detail."
|
||||
},
|
||||
"source": {
|
||||
"zh": "复现 Aaalice 双模型路线时用的接力(两个 KSampler Advanced 分段),本机三组对比验证过",
|
||||
"en": "The relay used to reproduce the Aaalice two-model route (two KSampler Advanced parts), checked in three local comparisons"
|
||||
},
|
||||
"packs": {},
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"first": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"latent_image": [
|
||||
"$sampler.latent_image",
|
||||
"*"
|
||||
],
|
||||
"add_noise": "enable",
|
||||
"start_at_step": 0,
|
||||
"end_at_step": 21,
|
||||
"return_with_leftover_noise": "enable"
|
||||
}
|
||||
},
|
||||
"second": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"replaces": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"second_model",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"first",
|
||||
0
|
||||
],
|
||||
"add_noise": "disable",
|
||||
"start_at_step": 21,
|
||||
"end_at_step": 10000,
|
||||
"return_with_leftover_noise": "disable"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "The canvas's sampler became two: the first runs steps 0 to 21 on the canvas's model, the second (second_model, a new loader that starts as the same model) from step 21 to the end, and the picture goes on to the decode as before. set_model second_model to the other model the user wants, of the same family (it reuses the prompts). A LoRA on the first model (a style) is not on the second: add_lora it onto second_model too when the user wants it kept. 21 is 60% of 35 steps: with other steps set the first sampler's end_at_step and the second's start_at_step both to about 60% (earlier: more of the second model, later: more of the first). er_sde split cleanly on one SDXL model but came out grainy on Anima: on another family's er_sde, dpmpp_2m_sde_gpu or euler is the safe choice for both. To enlarge at the switch too (the composition settled small, the later steps drawing the detail at the larger size, in place of an upscale pass after): add_node LatentUpscaleBy insert_after=first with values {\"upscale_method\": \"nearest-exact\", \"scale_by\": 1.5}, then one set_values: first {\"return_with_leftover_noise\": \"disable\"}, second {\"add_noise\": \"enable\"}. The first part then finishes its picture at the switch and the second noises the enlarged one anew: enlarged with its leftover noise, the picture breaks into coloured blocks. Keep the switch where it was: an earlier one redraws more (colours, clothes). second_model may stay the same model when only the enlargement is wanted. When the workflow already enlarges this picture after it (an upscale pass downstream), ask the user whether to keep that one: two enlargements are rarely wanted, and say the pictures differ in size when comparing. For a picture drawn from an empty latent; then run_workflow.",
|
||||
"notes": "KSampler Advanced: the first part returns its leftover noise, the second adds none and finishes it, so the two parts make one run of the steps. er_sde split, the same model in both parts, one seed each: grainy on two Anima models (Kirazuri all over, Harem 1.5 blotchy), clean on Nova Anime XL (SDXL); dpmpp_2m_sde_gpu and euler split cleanly. Tested on the user's route: Harem 1.5 for steps 0-21, Realskin 1.3 for 21-35, its style LoRA on both, 35 steps dpmpp_2m_sde_gpu beta; on SDXL: Nova Anime XL for steps 0-18, MiaoMiao Harem v2.0 for 18-30, dpmpp_2m_sde_gpu beta. Enlarging 1.5× at the switch (Harem 1.5 both parts, 35 steps, 832×1216 → 1248×1824, about 106 s): with the leftover noise enlarged (nearest-exact, the reference author's wiring) the picture came out covered in coloured blocks at step 21 and blots at step 12, the second part adding noise or not; the first part finishing (return_with_leftover_noise disable) and the second adding noise came out clean, at step 21 keeping the pose and layout, at step 12 changing colours and clothes. GPT's check on the user's Harem 1.5 → Realskin 1.3 card, enlarged at step 21: the AI changed both settings as told, no blocks; pose, hair and colours held, face and hair finer, ornaments and tassels redrawn; about 53 s more than the card's base.",
|
||||
"models": [],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima (Kirazuri → Realskin 1.3 at step 21)",
|
||||
"size": "1216×832",
|
||||
"seconds": 79
|
||||
},
|
||||
{
|
||||
"model": "SDXL (Nova Anime XL → MiaoMiao Harem v2.0 at step 18 of 30)",
|
||||
"size": "832×1216",
|
||||
"seconds": 41
|
||||
},
|
||||
{
|
||||
"model": "Anima (MiaoMiao Harem 1.5 both parts, enlarged 1.5× at step 21)",
|
||||
"size": "832×1216 → 1248×1824",
|
||||
"seconds": 106
|
||||
}
|
||||
],
|
||||
"date": "2026-11-03"
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "UNETLoader",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"first": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"latent_image": [
|
||||
"$sampler.latent_image",
|
||||
"*"
|
||||
],
|
||||
"add_noise": "enable",
|
||||
"start_at_step": 0,
|
||||
"end_at_step": 21,
|
||||
"return_with_leftover_noise": "enable",
|
||||
"steps": 35,
|
||||
"sampler_name": "dpmpp_2m_sde_gpu",
|
||||
"scheduler": "beta"
|
||||
}
|
||||
},
|
||||
"second": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"replaces": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"second_model",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"first",
|
||||
0
|
||||
],
|
||||
"add_noise": "disable",
|
||||
"start_at_step": 21,
|
||||
"end_at_step": 10000,
|
||||
"return_with_leftover_noise": "disable",
|
||||
"steps": 35,
|
||||
"sampler_name": "dpmpp_2m_sde_gpu",
|
||||
"scheduler": "beta"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "The canvas's sampler became two, with the tested Anima sampling (35 steps, dpmpp_2m_sde_gpu, beta): the first runs steps 0 to 21 on the canvas's model, the second (second_model, a new UNET loader that starts as the same model) from step 21 to the end, and the picture goes on to the decode as before. set_model second_model to the other Anima model the user wants (it reuses the text encoder and prompts). A LoRA on the first model (a style) is not on the second: add_lora it onto second_model too when the user wants it kept (the tested route did). Move the switch with the first sampler's end_at_step and the second's start_at_step together (earlier: more of the second model, later: more of the first). Keep dpmpp_2m_sde_gpu: er_sde came out grainy split on two Anima models. To enlarge at the switch too (the composition settled small, the later steps drawing the detail at the larger size, in place of an upscale pass after): add_node LatentUpscaleBy insert_after=first with values {\"upscale_method\": \"nearest-exact\", \"scale_by\": 1.5}, then one set_values: first {\"return_with_leftover_noise\": \"disable\"}, second {\"add_noise\": \"enable\"}. The first part then finishes its picture at the switch and the second noises the enlarged one anew: enlarged with its leftover noise, the picture breaks into coloured blocks. Keep the switch where it was: an earlier one redraws more (colours, clothes). second_model may stay the same model when only the enlargement is wanted. When the workflow already enlarges this picture after it (an upscale pass downstream), ask the user whether to keep that one: two enlargements are rarely wanted, and say the pictures differ in size when comparing. For a picture drawn from an empty latent; then run_workflow."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"job": "process",
|
||||
"want": "tags",
|
||||
"rank": 3,
|
||||
"short": "its tags",
|
||||
"title": {
|
||||
"zh": "读图出标签",
|
||||
"en": "Tags from a picture"
|
||||
},
|
||||
"about": {
|
||||
"zh": "读出一张图的标签,交给 AI 当文字用。",
|
||||
"en": "Reads a picture's tags and hands them to the AI as words."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「图像反推」组",
|
||||
"en": "The reference workflow's interrogation group"
|
||||
},
|
||||
"packs": {
|
||||
"WD14Tagger|pysssss": "ComfyUI-WD14-Tagger"
|
||||
},
|
||||
"takes": "picture",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"class_type": "WD14Tagger|pysssss",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"model": "wd-eva02-large-tagger-v3",
|
||||
"threshold": 0.35,
|
||||
"character_threshold": 0.85,
|
||||
"replace_underscore": false,
|
||||
"trailing_comma": false,
|
||||
"exclude_tags": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "run_workflow (what the canvas makes is made too): run_status's answer has texts, the picture's tags. Write the prompt from them with set_prompt, keeping the quality words the prompt has, then remove_nodes these two so later runs skip them.",
|
||||
"notes": "The source's model and thresholds (wd-eva02-large-tagger-v3, 0.35, 0.85).",
|
||||
"models": [],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "—",
|
||||
"size": "1280×832",
|
||||
"seconds": 166
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"job": "upscale",
|
||||
"rank": 1,
|
||||
"short": "1.25×",
|
||||
"title": {
|
||||
"zh": "放大",
|
||||
"en": "Upscale"
|
||||
},
|
||||
"about": {
|
||||
"zh": "用放大模型放到 1.25 倍,再轻轻重画补细节。",
|
||||
"en": "An upscale model to 1.25×, then a light redraw for detail."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「通用放大」组",
|
||||
"en": "The reference workflow's general upscale group"
|
||||
},
|
||||
"packs": {},
|
||||
"upscaler": true,
|
||||
"after": "run_workflow saves the picture as before and the larger one beside it (prefix upscaled). The redraw sampler has the first one's steps and cfg at denoise 0.35: lower keeps more of the picture, higher adds more (and above 0.5 may change it). For 2× or more use upscale_tiled.",
|
||||
"notes": "1.25× and denoise 0.35: the source used 1.25 and 0.32; its author says more than 1.5× goes wrong, and 1.5× of 832×1216 is too much for a 6 GB card. ColorMatch mkl after the redraw is the source's, optional (KJNodes).",
|
||||
"upscale_by": 1.25,
|
||||
"nodes": {
|
||||
"upscaler": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {
|
||||
"model_name": "$upscaler"
|
||||
}
|
||||
},
|
||||
"enlarge": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {
|
||||
"upscale_model": [
|
||||
"upscaler",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"$save.images",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fit": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"enlarge",
|
||||
0
|
||||
],
|
||||
"upscale_method": "lanczos",
|
||||
"scale_by": "$fit"
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"latent_image": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"denoise": 0.35
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"redraw",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"match": {
|
||||
"class_type": "ColorMatch",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image_ref": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"image_target": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"method": "mkl",
|
||||
"strength": 1.0,
|
||||
"multithread": true
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"match|decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "upscaled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extras": {
|
||||
"ColorMatch": "ComfyUI-KJNodes"
|
||||
},
|
||||
"models": [
|
||||
"2x_animesharp_v4",
|
||||
"4x_ultrasharp",
|
||||
"4x_animesharp"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216 → 1.25×",
|
||||
"seconds": 43
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
{
|
||||
"job": "upscale",
|
||||
"want": "detail",
|
||||
"rank": 2,
|
||||
"short": "held by Tile, own model",
|
||||
"title": {
|
||||
"zh": "Tile 锁图放大",
|
||||
"en": "Upscale held by Tile"
|
||||
},
|
||||
"about": {
|
||||
"zh": "放大时用 Tile 钉住画面,重画得更狠、细节更多。放大这一段有自己的一份模型(默认和底图同一个,LoRA 一起带上),想换第二个模型只要换这一格;Anima 上用 Anima 的 Tile LLLite。",
|
||||
"en": "A Tile holds the picture while it is enlarged and redrawn, so the redraw can go stronger: more detail. The redraw has its own copy of the model (the base's, its LoRAs too); a second model is one swap of that loader. On Anima, Anima's Tile LLLite holds it."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「通用放大」组(平铺控制网 0.6,到 80% 的步数)和它的放大加载器;Anima 是你验证过的两段放大卡(潜空间 1.22 倍 + Tile LLLite,降噪 0.85)",
|
||||
"en": "The reference workflow's general upscale (tile ControlNet 0.6, to 80% of the steps) and its upscale loader; on Anima the user's tested two-pass card (latent 1.22x, Tile LLLite, denoise 0.85)"
|
||||
},
|
||||
"packs": {},
|
||||
"upscaler": true,
|
||||
"upscale_by": 1.25,
|
||||
"control": "tile",
|
||||
"extras": {
|
||||
"ColorMatch": "ComfyUI-KJNodes"
|
||||
},
|
||||
"nodes": {
|
||||
"own": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"copy_of": "$sampler.model",
|
||||
"_meta": {
|
||||
"title": "Upscale model"
|
||||
},
|
||||
"inputs": {}
|
||||
},
|
||||
"upscaler": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {
|
||||
"model_name": "$upscaler"
|
||||
}
|
||||
},
|
||||
"enlarge": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {
|
||||
"upscale_model": [
|
||||
"upscaler",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"$save.images",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fit": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"enlarge",
|
||||
0
|
||||
],
|
||||
"upscale_method": "lanczos",
|
||||
"scale_by": "$fit"
|
||||
}
|
||||
},
|
||||
"controlnet": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {
|
||||
"control_net_name": "$controlnet"
|
||||
}
|
||||
},
|
||||
"hold": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"control_net": [
|
||||
"controlnet",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.6,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"own",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"hold",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"hold",
|
||||
1
|
||||
],
|
||||
"latent_image": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"denoise": 0.5
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"redraw",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"match": {
|
||||
"class_type": "ColorMatch",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image_ref": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"image_target": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"method": "mkl",
|
||||
"strength": 1.0,
|
||||
"multithread": true
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"match|decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "upscaled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"packs": {
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"upscaler": false,
|
||||
"control": null,
|
||||
"lllite": "tile",
|
||||
"nodes": {
|
||||
"own": {
|
||||
"class_type": "UNETLoader",
|
||||
"copy_of": "$sampler.model",
|
||||
"_meta": {
|
||||
"title": "Upscale model"
|
||||
},
|
||||
"inputs": {}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncodeTiled",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"tile_size": 512,
|
||||
"overlap": 64,
|
||||
"temporal_size": 64,
|
||||
"temporal_overlap": 8
|
||||
}
|
||||
},
|
||||
"enlarge": {
|
||||
"class_type": "LatentUpscaleBy",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"upscale_method": "bislerp",
|
||||
"scale_by": 1.22
|
||||
}
|
||||
},
|
||||
"tile": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"own",
|
||||
0
|
||||
],
|
||||
"lllite_name": "$lllite",
|
||||
"image": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.7,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8,
|
||||
"preserve_wrapper": true
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"tile",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"latent_image": [
|
||||
"enlarge",
|
||||
0
|
||||
],
|
||||
"steps": 30,
|
||||
"cfg": 4.4,
|
||||
"sampler_name": "dpmpp_2m_sde_gpu",
|
||||
"scheduler": "sgm_uniform",
|
||||
"denoise": 0.85
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecodeTiled",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"redraw",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"tile_size": 512,
|
||||
"overlap": 64,
|
||||
"temporal_size": 64,
|
||||
"temporal_overlap": 8
|
||||
}
|
||||
},
|
||||
"match": {
|
||||
"class_type": "ColorMatch",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image_ref": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"image_target": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"method": "mkl",
|
||||
"strength": 1.0,
|
||||
"multithread": true
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"match|decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "upscaled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model the picture is enlarged 1.22x in latent space and redrawn at denoise 0.85, held by Anima's Tile LLLite (0.7, to 80% of the steps), with its own copy of the canvas's model and LoRAs (the loader titled Upscale model). For a second model (steadier, other skin or texture), set_model that loader to another Anima model; the LoRAs stay. Then run_workflow; the larger one is saved beside it (prefix upscaled). Compare face, hands and clothes with the base: Tile guides, it does not guarantee."
|
||||
}
|
||||
},
|
||||
"after": "run_workflow saves the picture as before and the 1.25x one beside it (prefix upscaled): a tile ControlNet holds it (strength 0.6, to 80% of the steps) while it is redrawn at denoise 0.5 with its own copy of the canvas's model and LoRAs (the loader titled Upscale model). For a second model (steadier, another look), set_model that loader to one of the same family (it reuses the first model's prompts); the LoRAs stay. Lower the denoise if it changes too much, raise the strength to hold it closer.",
|
||||
"notes": "The source's tile values; denoise 0.5 because the tile holds the picture. The redraw's own model line replaced the separate second-model way (2026-11-04): same model by default, one swap for another. On Anima the user's tested route: Harem 1.5 base, Realskin 1.3 for the Tile upscale, its style LoRA on both, 832x1216 to 1016x1480, 116 s for the upscale on 6 GB.",
|
||||
"models": [
|
||||
"union_promax_sdxl",
|
||||
"noob_tile",
|
||||
"anima_tile_repair",
|
||||
"2x_animesharp_v4",
|
||||
"4x_ultrasharp"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-02",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216 → 1.25×",
|
||||
"seconds": 77
|
||||
},
|
||||
{
|
||||
"model": "Anima (Kirazuri → Realskin 1.3, Tile)",
|
||||
"size": "1216×832 → 1.22×",
|
||||
"seconds": 127
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"job": "upscale",
|
||||
"want": "2x",
|
||||
"rank": 4,
|
||||
"short": "2×, in tiles",
|
||||
"title": {
|
||||
"zh": "分块放大",
|
||||
"en": "Tiled upscale"
|
||||
},
|
||||
"about": {
|
||||
"zh": "放大 2 倍,按小块逐块重画,多大的图都放得下。",
|
||||
"en": "2×, redrawn tile by tile, for any size."
|
||||
},
|
||||
"source": {
|
||||
"zh": "节点包 UltimateSDUpscale 自带的示例",
|
||||
"en": "UltimateSDUpscale's own example"
|
||||
},
|
||||
"packs": {
|
||||
"UltimateSDUpscale": "ComfyUI_UltimateSDUpscale"
|
||||
},
|
||||
"instead": "upscale",
|
||||
"upscaler": true,
|
||||
"after": "run_workflow saves the picture as before and the 2× one beside it (prefix upscaled); each tile is redrawn with the sampler's steps and cfg at denoise 0.3 (higher adds more detail, and may add seams).",
|
||||
"extras": {
|
||||
"ColorMatch": "ComfyUI-KJNodes"
|
||||
},
|
||||
"notes": "ColorMatch mkl after the redraw is the source's, optional (KJNodes).",
|
||||
"upscale_by": 2.0,
|
||||
"nodes": {
|
||||
"upscaler": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {
|
||||
"model_name": "$upscaler"
|
||||
}
|
||||
},
|
||||
"tiles": {
|
||||
"class_type": "UltimateSDUpscale",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"upscale_model": [
|
||||
"upscaler",
|
||||
0
|
||||
],
|
||||
"upscale_by": 2.0,
|
||||
"denoise": 0.3,
|
||||
"tile_width": 1024,
|
||||
"tile_height": 1024
|
||||
}
|
||||
},
|
||||
"match": {
|
||||
"class_type": "ColorMatch",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image_ref": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"image_target": [
|
||||
"tiles",
|
||||
0
|
||||
],
|
||||
"method": "mkl",
|
||||
"strength": 1.0,
|
||||
"multithread": true
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"match|tiles",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "upscaled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
"2x_animesharp_v4",
|
||||
"4x_ultrasharp"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216 → 2×",
|
||||
"seconds": 167
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+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,395 @@
|
||||
"""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
|
||||
|
||||
|
||||
MAX_VERSIONS = 60 # a model page's versions listed, newest first
|
||||
|
||||
|
||||
def civitai_versions(model_id):
|
||||
"""Every version on Civitai model page `model_id`, newest first, with its primary file to download:
|
||||
{name, versions: [{id, name, base_model, published, file_name, size, sha256, page}]}; None on 404.
|
||||
Raises SourceUnreachable when Civitai could not be asked."""
|
||||
data = _civitai_json(f"/api/v1/models/{int(model_id)}")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
versions = []
|
||||
for version in (data.get("modelVersions") or [])[:MAX_VERSIONS]:
|
||||
if not isinstance(version, dict) or not str(version.get("id", "")).isdigit():
|
||||
continue
|
||||
found = _from_civitai_version({**version, "modelId": data.get("id"), "model": {"nsfw": data.get("nsfw")}}) or {}
|
||||
versions.append({"id": int(version["id"]), "name": version.get("name") or "", "base_model": version.get("baseModel") or "",
|
||||
"published": str(version.get("publishedAt") or version.get("createdAt") or "")[:10],
|
||||
**{key: found.get(key, "") for key in ("file_name", "size", "sha256", "page")}})
|
||||
versions.sort(key=lambda item: item["id"], reverse=True)
|
||||
return {"name": data.get("name") or "", "versions": versions}
|
||||
|
||||
|
||||
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"}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""The example workflows Anomalous ships (api/examples/<id>.json), because a new install has no cards of
|
||||
its own: plans (title), each in a version per model family (version) with a note (about). Most use only
|
||||
ComfyUI's own nodes; a version may need a pack (packs) or a model, and says so. The Workflows page shows
|
||||
them as one card (web/modules/ui_recipe_examples.js); list_workflows gives those that can run here to an
|
||||
AI when no saved workflow fits, which opens and runs them like saved ones (file name "example:<id>").
|
||||
A file names no model and no picture: read() fills in ones that are here. Read only."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from . import workflow_route
|
||||
|
||||
FOLDER = os.path.join(os.path.dirname(__file__), "examples")
|
||||
PREFIX = "example:"
|
||||
ORDER = ("text_to_picture", "two_pass", "two_pass_anima", "two_pass_anima_tile", "picture_to_picture", "repaint")
|
||||
LOADERS = ("CheckpointLoaderSimple", "UNETLoader", "CLIPLoader", "VAELoader", "AnimaLLLiteApply_sdscripts")
|
||||
FAMILIES = {"sd": ("SDXL", "SD 1.x"), "anima": ("anima",)} # what the examples' settings suit
|
||||
FOR_MODELS = {"sd": "SDXL, SD 1.5", "anima": "Anima"}
|
||||
LOOK_AT = 40 # files looked at for a model of a family
|
||||
MAX_WITH_SETTINGS = 3 # saved workflows listed in full for an AI: an AI app files a long answer away, unread
|
||||
SHORT_NOTES = 120 # characters of a card's notes in a longer list
|
||||
PICTURES = (".png", ".jpg", ".jpeg", ".webp")
|
||||
|
||||
|
||||
def _load():
|
||||
examples = {}
|
||||
for key in ORDER:
|
||||
with open(os.path.join(FOLDER, f"{key}.json"), encoding="utf-8") as file:
|
||||
examples[key] = json.load(file)
|
||||
return examples
|
||||
|
||||
|
||||
EXAMPLES = _load()
|
||||
|
||||
|
||||
def _here():
|
||||
"""The loaders' drop-downs, in the form reference_blocks reads /object_info; a node type not here is left out."""
|
||||
import nodes
|
||||
|
||||
return {kind: {"input": nodes.NODE_CLASS_MAPPINGS[kind].INPUT_TYPES()} for kind in LOADERS if kind in nodes.NODE_CLASS_MAPPINGS}
|
||||
|
||||
|
||||
def _model(info, kind, field, family):
|
||||
"""The first file loader `kind` offers of `family` (those named for it looked at first), or ""."""
|
||||
from . import model_advice, reference_blocks
|
||||
|
||||
names = sorted(reference_blocks._choices(info, kind, field), key=lambda name: family not in name.lower())
|
||||
return next((name for name in names[:LOOK_AT] if model_advice.family(model_advice._main_base(name)) in FAMILIES[family]), "")
|
||||
|
||||
|
||||
def _picture():
|
||||
"""A picture in ComfyUI's input folder, its own example if that is there, else ""."""
|
||||
try:
|
||||
names = sorted(name for name in os.listdir(folder_paths.get_input_directory()) if name.lower().endswith(PICTURES))
|
||||
except OSError:
|
||||
return ""
|
||||
return "example.png" if "example.png" in names else names[0] if names else ""
|
||||
|
||||
|
||||
def _fills(info):
|
||||
"""{node type: the file it takes here} for the examples' loaders, "" for what is not here."""
|
||||
from . import reference_blocks
|
||||
|
||||
checkpoints = reference_blocks._choices(info, "CheckpointLoaderSimple", "ckpt_name")
|
||||
fills = {"CheckpointLoaderSimple": _model(info, "CheckpointLoaderSimple", "ckpt_name", "sd") or next(iter(checkpoints), ""),
|
||||
"UNETLoader": _model(info, "UNETLoader", "unet_name", "anima"), "LoadImage": _picture()}
|
||||
for kind, field, marks in reference_blocks.BLOCKS["picture"]["variants"]["anima"]["files"].values():
|
||||
fills[kind] = reference_blocks._file_like(info, kind, field, marks) or ""
|
||||
try:
|
||||
fills["AnimaLLLiteApply_sdscripts"] = reference_blocks._lllite(info, "tile")
|
||||
except reference_blocks.MissingModel:
|
||||
fills["AnimaLLLiteApply_sdscripts"] = ""
|
||||
return fills
|
||||
|
||||
|
||||
def _lacks(example, info, fills):
|
||||
"""What `example` needs and this ComfyUI lacks: [{pack}] or [{model: the loader's type}]."""
|
||||
packs = [{"pack": pack} for kind, pack in (example.get("packs") or {}).items() if kind not in info]
|
||||
kinds = {node["type"] for node in example["workflow"]["nodes"]}
|
||||
return packs + [{"model": kind} for kind in LOADERS if kind in kinds and kind in info and not fills.get(kind)]
|
||||
|
||||
|
||||
def read(example_id):
|
||||
"""Example `example_id` on files that are here (those missing stay blank), or None when there is no such example."""
|
||||
example = EXAMPLES.get(example_id)
|
||||
if not example:
|
||||
return None
|
||||
fills = _fills(_here())
|
||||
workflow = json.loads(json.dumps(example["workflow"]))
|
||||
for node in workflow["nodes"]:
|
||||
if node["type"] in fills:
|
||||
node["widgets_values"][0] = fills[node["type"]]
|
||||
return {"name": example["name"], "workflow": workflow}
|
||||
|
||||
|
||||
def listing():
|
||||
"""What the page's window shows of each example, and what it lacks here."""
|
||||
info = _here()
|
||||
fills = _fills(info)
|
||||
return [{"id": key, "title": item["title"], "version": item["version"], "about": item["about"], "recommended": bool(item.get("recommended")),
|
||||
"lacks": _lacks(item, info, fills)} for key, item in EXAMPLES.items()]
|
||||
|
||||
|
||||
def for_ai():
|
||||
"""The examples that can run here, as list_workflows gives saved workflows."""
|
||||
info = _here()
|
||||
fills = _fills(info)
|
||||
return [{"name": item["name"], "title": item["title"]["zh"], "example": True, "for_models": FOR_MODELS[item["family"]],
|
||||
"route": workflow_route.line(item["workflow"]), "notes": item["ai"]}
|
||||
for item in EXAMPLES.values() if not _lacks(item, info, fills)]
|
||||
|
||||
|
||||
def _short(card):
|
||||
"""A saved workflow in a longer list: what tells it apart."""
|
||||
shown = {key: value for key, value in card.items() if value and key not in ("settings", "saved")}
|
||||
return {**shown, "notes": shown["notes"][:SHORT_NOTES]} if "notes" in shown else shown
|
||||
|
||||
|
||||
def answer(found, fits, limit):
|
||||
"""What list_workflows gives: the saved workflows `found`, cut short when they are many, or else the examples
|
||||
that `fits`. The other examples go by name, so an AI with no card for the job knows of them."""
|
||||
examples = for_ai()
|
||||
fitting = [] if found else [item for item in examples if fits(item)]
|
||||
if len(found) > MAX_WITH_SETTINGS:
|
||||
found = [_short(card) for card in found]
|
||||
listed = found or fitting
|
||||
others = [item["name"] for item in examples if item not in fitting]
|
||||
return {"total_matches": len(listed), "workflows": listed[:limit], **({"examples": others} if others else {})}
|
||||
|
||||
|
||||
def file_of(name):
|
||||
"""The file name open_workflow and run_workflow take for the example called `name`, or None."""
|
||||
return next((PREFIX + key for key, item in EXAMPLES.items() if item["name"] == name), None)
|
||||
|
||||
|
||||
async def api_examples(request):
|
||||
"""GET /anomalous/example_workflows: the list; ?id=<id>: one, in `data` as /anomalous/recipe_full gives a card."""
|
||||
if "id" not in request.query:
|
||||
return web.json_response({"examples": await asyncio.to_thread(listing)})
|
||||
data = await asyncio.to_thread(read, request.query["id"])
|
||||
return web.json_response({"data": data}) if data else web.json_response({"error": "not_found"}, status=404)
|
||||
@@ -0,0 +1,578 @@
|
||||
{
|
||||
"name": "Example: picture to picture",
|
||||
"title": {
|
||||
"zh": "图生图",
|
||||
"en": "Picture to picture"
|
||||
},
|
||||
"about": {
|
||||
"zh": "从一张图出发,按提示词改。在 Load Image 节点选图;denoise 越高,改得越多。",
|
||||
"en": "Starts from a picture and changes it by the prompt. Pick it in the Load Image node; a higher denoise changes more."
|
||||
},
|
||||
"ai": "Redraws a picture (img2img) at denoise 0.6. open_workflow, set_model on its loader, then run_workflow with the prompt and image.",
|
||||
"version": {
|
||||
"zh": "SDXL / SD 1.5",
|
||||
"en": "SDXL / SD 1.5"
|
||||
},
|
||||
"family": "sd",
|
||||
"workflow": {
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 8,
|
||||
"last_link_id": 11,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
40,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
273.4576171875,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Checkpoint名称",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
4,
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
60
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"low quality, blurry, watermark, text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
40,
|
||||
420
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
102
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "image",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "选择文件上传",
|
||||
"name": "upload",
|
||||
"type": "IMAGEUPLOAD",
|
||||
"widget": {
|
||||
"name": "upload"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "遮罩",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "VAEEncode",
|
||||
"pos": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "像素",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
860,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"localized_name": "正面条件",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "负面条件",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "Latent图像",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"localized_name": "种子",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "步数",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "采样器名称",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "调度器",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "降噪",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
28,
|
||||
5.5,
|
||||
"euler_ancestral",
|
||||
"normal",
|
||||
0.6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1240,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1500,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图片",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "文件名前缀",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"example"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
5,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
6,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
6,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
8,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
9,
|
||||
6,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
10,
|
||||
1,
|
||||
2,
|
||||
7,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
11,
|
||||
7,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"version": 0.4,
|
||||
"extra": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
{
|
||||
"name": "Example: repaint an area",
|
||||
"title": {
|
||||
"zh": "局部重绘",
|
||||
"en": "Repaint an area"
|
||||
},
|
||||
"about": {
|
||||
"zh": "只重画图上的一块。在 Load Image 节点选图,右键打开遮罩编辑器,涂出要改的地方。",
|
||||
"en": "Redraws one part of a picture. Pick it in the Load Image node, right-click to open the mask editor and paint the part to change."
|
||||
},
|
||||
"ai": "Redraws the area masked on the Load Image node (inpaint). The user paints that mask; to repaint for them yourself, add_nodes(use='repaint') is better.",
|
||||
"version": {
|
||||
"zh": "SDXL / SD 1.5",
|
||||
"en": "SDXL / SD 1.5"
|
||||
},
|
||||
"family": "sd",
|
||||
"workflow": {
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 13,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
40,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
273.4576171875,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Checkpoint名称",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
4,
|
||||
12
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
60
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"low quality, blurry, watermark, text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
40,
|
||||
420
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
102
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "image",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "选择文件上传",
|
||||
"name": "upload",
|
||||
"type": "IMAGEUPLOAD",
|
||||
"widget": {
|
||||
"name": "upload"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "遮罩",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "VAEEncode",
|
||||
"pos": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "像素",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SetLatentNoiseMask",
|
||||
"pos": [
|
||||
660,
|
||||
560
|
||||
],
|
||||
"size": [
|
||||
165.134375,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"localized_name": "遮罩",
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 6
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SetLatentNoiseMask"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
960,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "正面条件",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"localized_name": "负面条件",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "Latent图像",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"localized_name": "种子",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "步数",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "采样器名称",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "调度器",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "降噪",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
28,
|
||||
5.5,
|
||||
"euler_ancestral",
|
||||
"normal",
|
||||
0.6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1340,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 12
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1600,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图片",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "文件名前缀",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"example"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
5,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
6,
|
||||
4,
|
||||
1,
|
||||
6,
|
||||
1,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
8,
|
||||
2,
|
||||
0,
|
||||
7,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
9,
|
||||
3,
|
||||
0,
|
||||
7,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
10,
|
||||
6,
|
||||
0,
|
||||
7,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
11,
|
||||
7,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
12,
|
||||
1,
|
||||
2,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
13,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"version": 0.4,
|
||||
"extra": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
{
|
||||
"name": "Example: text to picture",
|
||||
"title": {
|
||||
"zh": "文生图",
|
||||
"en": "Text to picture"
|
||||
},
|
||||
"about": {
|
||||
"zh": "最基础的一条路:写提示词,出一张图。",
|
||||
"en": "The plainest route: write a prompt, get a picture."
|
||||
},
|
||||
"ai": "One pass from words (txt2img). open_workflow, set_model on its loader, then run_workflow with the prompt.",
|
||||
"version": {
|
||||
"zh": "SDXL / SD 1.5",
|
||||
"en": "SDXL / SD 1.5"
|
||||
},
|
||||
"family": "sd",
|
||||
"workflow": {
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 7,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
40,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
273.4576171875,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Checkpoint名称",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
60
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"low quality, blurry, watermark, text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
440,
|
||||
540
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "宽度",
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "高度",
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "批量大小",
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
832,
|
||||
1216,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
860,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"localized_name": "正面条件",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "负面条件",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"localized_name": "Latent图像",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "种子",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "步数",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "采样器名称",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "调度器",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "降噪",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
28,
|
||||
5.5,
|
||||
"euler_ancestral",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1240,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1500,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图片",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "文件名前缀",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"example"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
5,
|
||||
3,
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
7,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
8,
|
||||
1,
|
||||
2,
|
||||
6,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
9,
|
||||
6,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"version": 0.4,
|
||||
"extra": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
{
|
||||
"name": "Example: two passes",
|
||||
"title": {
|
||||
"zh": "两段放大",
|
||||
"en": "Two passes"
|
||||
},
|
||||
"about": {
|
||||
"zh": "只用官方节点:先出一张图,放大 1.25 倍再重绘一遍。这是最简单的两段;想用放大模型或 Tile 锁图,用“做法”里的放大。",
|
||||
"en": "ComfyUI's own nodes only: a picture, then enlarged 1.25x and redrawn. The plainest two passes; for an upscale model or a Tile lock, use the upscale ways in Ways."
|
||||
},
|
||||
"ai": "A pass, then the latent enlarged 1.25x and redrawn at denoise 0.5. open_workflow, set_model on its loader, then run_workflow with the prompt. For an upscale model or a Tile lock, add_nodes(use='upscale') on a one-pass canvas does better.",
|
||||
"version": {
|
||||
"zh": "SDXL / SD 1.5",
|
||||
"en": "SDXL / SD 1.5"
|
||||
},
|
||||
"family": "sd",
|
||||
"workflow": {
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 14,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
40,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
273.4576171875,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Checkpoint名称",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
3,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
60
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
4,
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
5,
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"low quality, blurry, watermark, text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
440,
|
||||
540
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "宽度",
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "高度",
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "批量大小",
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
832,
|
||||
1216,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
860,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"localized_name": "正面条件",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "负面条件",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"localized_name": "Latent图像",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "种子",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "步数",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "采样器名称",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "调度器",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "降噪",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
28,
|
||||
5.5,
|
||||
"euler_ancestral",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "LatentUpscaleBy",
|
||||
"pos": [
|
||||
1240,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
82
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "缩放算法",
|
||||
"name": "upscale_method",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "upscale_method"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "缩放比例",
|
||||
"name": "scale_by",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "scale_by"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LatentUpscaleBy"
|
||||
},
|
||||
"widgets_values": [
|
||||
"nearest-exact",
|
||||
1.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
1560,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"localized_name": "正面条件",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "负面条件",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"localized_name": "Latent图像",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "种子",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "步数",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "采样器名称",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "调度器",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "降噪",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
12
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
28,
|
||||
5.5,
|
||||
"euler_ancestral",
|
||||
"normal",
|
||||
0.5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1940,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
14
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
2200,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图片",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "文件名前缀",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"example"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
5,
|
||||
3,
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
7,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
8,
|
||||
1,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
7,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
10,
|
||||
3,
|
||||
0,
|
||||
7,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
11,
|
||||
6,
|
||||
0,
|
||||
7,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
12,
|
||||
7,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
13,
|
||||
1,
|
||||
2,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
14,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"version": 0.4,
|
||||
"extra": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+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):
|
||||
|
||||
+130
-11
@@ -9,11 +9,29 @@ from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .image_search import filter_images
|
||||
from .media_routes import (
|
||||
CARD_POSTER_VIDEO_EXTENSIONS,
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS,
|
||||
_build_card_thumbnail,
|
||||
_build_video_poster,
|
||||
picture_placeholder,
|
||||
picture_size,
|
||||
shrunk_in_child,
|
||||
)
|
||||
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")
|
||||
BIG_FILE_BYTES = 32 * 1024 * 1024 # from this size the listing reads a picture's pixel size
|
||||
PREVIEW_EDGE = 4096
|
||||
PREVIEW_PIXELS = 36_000_000 # past this (or PREVIEW_BYTES) the viewers show a PREVIEW_EDGE copy
|
||||
PREVIEW_BYTES = 64 * 1024 * 1024
|
||||
_gallery_snapshot_lock = threading.Lock()
|
||||
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
|
||||
|
||||
@@ -22,18 +40,28 @@ 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
|
||||
path = os.path.join(root, filename)
|
||||
try:
|
||||
mtime = os.path.getmtime(os.path.join(root, filename))
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
subfolder = os.path.relpath(root, output_dir)
|
||||
images.append({
|
||||
item = {
|
||||
"filename": filename,
|
||||
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
|
||||
"type": "output", "mtime": mtime,
|
||||
})
|
||||
"type": "output",
|
||||
"mtime": stat.st_mtime,
|
||||
"bytes": stat.st_size,
|
||||
"media_type": "video" if ext in GALLERY_VIDEO_EXTENSIONS else "image",
|
||||
}
|
||||
# A big file's pixel size from its header: the card says it is a very large picture.
|
||||
size = picture_size(path) if stat.st_size >= BIG_FILE_BYTES and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS else None
|
||||
if size:
|
||||
item["width"], item["height"] = size
|
||||
images.append(item)
|
||||
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
|
||||
return images
|
||||
|
||||
@@ -56,33 +84,121 @@ 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/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'))
|
||||
file_path = resolve_within(resolve_within(output_dir, request.query.get('subfolder', '')), filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
served = file_path
|
||||
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)
|
||||
if served is None:
|
||||
return picture_placeholder(file_path)
|
||||
elif ext in CARD_POSTER_VIDEO_EXTENSIONS:
|
||||
served = await asyncio.to_thread(_build_video_poster, file_path)
|
||||
if not served: # no first frame (a broken file, no decoder): never the video itself as a picture
|
||||
return picture_placeholder(file_path)
|
||||
# Revalidate (cheap 304s): an output file can be replaced under the same name.
|
||||
return web.FileResponse(served, headers={'Cache-Control': 'no-cache'})
|
||||
|
||||
|
||||
async def api_output_preview(request):
|
||||
"""GET /anomalous/output_preview?filename&subfolder[&info=1] - an output picture as the viewers
|
||||
show it: itself, or for a very large one (past PREVIEW_PIXELS or PREVIEW_BYTES) a cached
|
||||
PREVIEW_EDGE px copy, so the browser never decodes hundreds of megapixels. `info=1`: JSON
|
||||
{width, height, bytes, preview} instead, for the viewer's note and its "Open the original"."""
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
try:
|
||||
filename = require_filename(request.query.get('filename'))
|
||||
file_path = resolve_within(resolve_within(output_dir, request.query.get('subfolder', '')), filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
|
||||
if not os.path.isfile(file_path) or os.path.splitext(file_path)[1].lower() not in GALLERY_IMAGE_EXTENSIONS:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
size, file_bytes = picture_size(file_path), os.path.getsize(file_path)
|
||||
large = file_bytes > PREVIEW_BYTES or bool(size) and size[0] * size[1] > PREVIEW_PIXELS
|
||||
if request.query.get('info') == '1':
|
||||
return web.json_response({"width": size and size[0], "height": size and size[1], "bytes": file_bytes, "preview": large})
|
||||
if not large:
|
||||
return web.FileResponse(file_path, headers={'Cache-Control': 'no-cache'})
|
||||
preview = await asyncio.to_thread(shrunk_in_child, file_path, PREVIEW_EDGE, 'preview')
|
||||
if not preview:
|
||||
return picture_placeholder(file_path)
|
||||
return web.FileResponse(preview, headers={'Cache-Control': 'no-cache', 'Content-Type': 'image/webp'})
|
||||
|
||||
|
||||
async def api_delete_gallery_image(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
try:
|
||||
filename = data.get("filename")
|
||||
subfolder = data.get("subfolder", "")
|
||||
|
||||
@@ -95,7 +211,10 @@ async def api_delete_gallery_image(request):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
try:
|
||||
await asyncio.to_thread(move_to_trash, file_path)
|
||||
except OSError as e:
|
||||
return web.json_response({"status": "error", "message": trash_failure(e)}, status=409)
|
||||
_invalidate_gallery_snapshot()
|
||||
return web.json_response({"status": "success"})
|
||||
else:
|
||||
|
||||
+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)}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Run by media_routes.py as a child process: a smaller WebP of a picture too large to decode
|
||||
inside ComfyUI (past Pillow's pixel limit), so the memory it takes goes back when it ends.
|
||||
Arguments: the picture, the WebP to write, the longest edge, the most pixels it may decode."""
|
||||
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
def shrink(source, target, edge, most_pixels):
|
||||
Image.MAX_IMAGE_PIXELS = most_pixels
|
||||
with Image.open(source) as image:
|
||||
image.thumbnail((edge, edge), Image.Resampling.LANCZOS) # a JPEG decodes at a fraction; others whole, once
|
||||
image = ImageOps.exif_transpose(image) # on the small one: turning the whole picture would copy it
|
||||
if image.mode not in ('RGB', 'RGBA'):
|
||||
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
|
||||
image.save(target, format='WEBP', quality=84, method=4)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
shrink(sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]))
|
||||
+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):
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""What each output image was kept as (GET /anomalous/kept_images), for the gallery's star:
|
||||
the Workflow Recipe whose source image it is, the combo made from it (a notebook file with
|
||||
`source_image`), the saved prompt taken from it (a prompt plan material whose source names
|
||||
the image). Each is {filename, name}, or null when the image was not kept as that."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .material_store import _list_materials, get_materials_dir
|
||||
from .notebooks import _list_notebooks
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
|
||||
KINDS = ("recipe", "combo", "prompt")
|
||||
|
||||
|
||||
def _kept_images():
|
||||
kept = {}
|
||||
|
||||
def mark(image, kind, filename, name):
|
||||
if not isinstance(image, dict) or not isinstance(image.get("filename"), str) or not image["filename"]:
|
||||
return
|
||||
subfolder = image.get("subfolder") if isinstance(image.get("subfolder"), str) else ""
|
||||
entry = kept.setdefault((subfolder, image["filename"]), dict.fromkeys(KINDS))
|
||||
if entry[kind] is None:
|
||||
entry[kind] = {"filename": filename, "name": str(name or "")}
|
||||
|
||||
for recipe in _list_recipes(get_recipes_dir()):
|
||||
mark(recipe["data"].get("source_image"), "recipe", recipe["filename"], recipe["data"].get("name"))
|
||||
for note in _list_notebooks():
|
||||
mark(note["data"].get("source_image"), "combo", note["filename"], note["name"])
|
||||
for material in _list_materials(get_materials_dir()):
|
||||
if material.get("kind") == "prompt_plan":
|
||||
mark(material.get("source_image"), "prompt", material["filename"], material.get("name"))
|
||||
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)
|
||||
except (OSError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Could not list kept images"}, status=500)
|
||||
return web.json_response({"status": "success", "images": images})
|
||||
@@ -0,0 +1,140 @@
|
||||
"""What only this computer may do. ComfyUI started with --listen lets other devices on the
|
||||
network open the page: they may look (lists, searches, pictures, covers), but every change
|
||||
under /anomalous/ (writing, moving and deleting files, downloads, scans, translations, backups,
|
||||
version switches, saved settings) answers only requests from the computer ComfyUI runs on
|
||||
(AGENTS.md 安全底线, rule 4). One middleware decides it for all routes: a request other than
|
||||
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). A browser on this computer that opens ComfyUI at one of its own
|
||||
addresses (ComfyUI listening on its LAN address only) is local too: the request then comes from
|
||||
the very address it reached, which no other device can have, and names it.
|
||||
|
||||
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
|
||||
|
||||
PREFIX = "/anomalous/"
|
||||
PROXY_HEADERS = ("Forwarded", "X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host")
|
||||
LOCAL_NAMES = ("localhost",)
|
||||
# Reads sent as POST (their lists are long): open to other devices too. They write nothing.
|
||||
OPEN_POSTS = frozenset({
|
||||
"/anomalous/resolve_paths_to_previews",
|
||||
"/anomalous/resolve_hash_batch",
|
||||
"/anomalous/inspect_image_material",
|
||||
})
|
||||
# GETs that are more than a read: a backup of all your data, a call to GitHub, your folders.
|
||||
LOCAL_GETS = frozenset({
|
||||
"/anomalous/backup/file",
|
||||
"/anomalous/version/releases",
|
||||
"/anomalous/tts_version/releases",
|
||||
"/anomalous/import/folders",
|
||||
"/anomalous/civitai_check",
|
||||
"/anomalous/download/versions", # asks Civitai
|
||||
"/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."
|
||||
" The View only chip at the top of the window says how to use it remotely too.")
|
||||
|
||||
|
||||
def loopback_host(name):
|
||||
if not name:
|
||||
return False
|
||||
name = name.strip("[]").lower()
|
||||
if name in LOCAL_NAMES:
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(name).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _address(name):
|
||||
"""An IP address from a peer, socket or Host name (IPv4 inside IPv6 as IPv4), or None."""
|
||||
try:
|
||||
address = ipaddress.ip_address(str(name or "").strip("[]").split("%")[0])
|
||||
except ValueError:
|
||||
return None
|
||||
return getattr(address, "ipv4_mapped", None) or address
|
||||
|
||||
|
||||
def _own_address(request):
|
||||
"""This server's end of the request's connection: the address the browser reached."""
|
||||
transport = request.transport
|
||||
sockname = transport.get_extra_info("sockname") if transport else None
|
||||
return _address(sockname[0]) if isinstance(sockname, (tuple, list)) and sockname else None
|
||||
|
||||
|
||||
def is_local(request):
|
||||
"""The request comes from this computer, directly, and names a loopback host, or else this
|
||||
computer's own address that it came from and reached."""
|
||||
if any(header in request.headers for header in PROXY_HEADERS):
|
||||
return False
|
||||
host = urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname
|
||||
if loopback_host(request.remote or ""):
|
||||
return loopback_host(host)
|
||||
own = _own_address(request)
|
||||
return own is not None and _address(request.remote) == own and _address(host) == own
|
||||
|
||||
|
||||
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:
|
||||
return False
|
||||
if method.upper() in READS:
|
||||
return path in LOCAL_GETS
|
||||
return path not in OPEN_POSTS
|
||||
|
||||
|
||||
def refusal():
|
||||
return web.json_response({"status": "error", "success": False, "error": "local_only", "code": "local_only",
|
||||
"message": MESSAGE}, status=403)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def local_only_middleware(request, handler):
|
||||
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 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
|
||||
@@ -25,6 +25,8 @@ def _material_assets_dir(materials_dir, filename, create=False):
|
||||
|
||||
def _inspect_source_image(source_image):
|
||||
source = _normalise_source_image(source_image)
|
||||
if source is None:
|
||||
raise ValueError("Missing source image")
|
||||
if not source["filename"].lower().endswith(".png"):
|
||||
raise ValueError("Only PNG output images can contain reusable workflow metadata")
|
||||
source_path = _output_source_path(source)
|
||||
|
||||
@@ -371,6 +371,14 @@ def _recipe_link_fingerprint(recipe):
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _summary_source_image(source):
|
||||
"""The output image a material was taken from, as {filename, subfolder}, or None."""
|
||||
image = source.get("image")
|
||||
if not isinstance(image, dict) or not isinstance(image.get("filename"), str) or not image["filename"]:
|
||||
return None
|
||||
return {"filename": image["filename"], "subfolder": image.get("subfolder") if isinstance(image.get("subfolder"), str) else ""}
|
||||
|
||||
|
||||
def _material_summary(filename, material):
|
||||
image = material.get("image") or {}
|
||||
source = material.get("source") if isinstance(material.get("source"), dict) else {}
|
||||
@@ -398,6 +406,8 @@ def _material_summary(filename, material):
|
||||
"label": str(source.get("parameter_name") or source.get("recipe_name") or source.get("notebook_name") or "").strip(),
|
||||
},
|
||||
"source_fingerprint": str(source.get("parameter_signature") or image.get("source_sha256") or ""),
|
||||
"moved_to_recipe": str(material.get("moved_to_recipe") or ""),
|
||||
"source_image": _summary_source_image(source),
|
||||
}
|
||||
if source.get("recipe_filename"):
|
||||
summary["source_recipe"] = {
|
||||
|
||||
+17
-50
@@ -19,11 +19,10 @@ from .material_schema import (
|
||||
_normalise_prompt_plan,
|
||||
_recipe_link_fingerprint,
|
||||
)
|
||||
from .parameters import get_parameters_dir
|
||||
from .recipe_constants import MAX_RECIPE_BYTES
|
||||
from .recipe_store import _read_recipe, get_recipes_dir
|
||||
from .trash import move_to_trash
|
||||
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
|
||||
from .workflow_schema import _parameter_signature, _validate_workflow
|
||||
|
||||
|
||||
_material_write_lock = threading.RLock()
|
||||
@@ -58,47 +57,6 @@ def _read_material(path):
|
||||
return value
|
||||
|
||||
|
||||
def _read_parameter_source(recipe_filename, parameter_filename=None):
|
||||
recipe_filename = require_filename(recipe_filename)
|
||||
if not recipe_filename.endswith(".json") or recipe_filename.startswith("."):
|
||||
raise ValueError("Invalid recipe filename")
|
||||
recipe_path = resolve_within(get_recipes_dir(), recipe_filename)
|
||||
if os.path.getsize(recipe_path) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Recipe source is too large")
|
||||
recipe = _read_recipe(recipe_path)
|
||||
source = recipe
|
||||
if parameter_filename:
|
||||
parameter_filename = require_filename(parameter_filename)
|
||||
if not parameter_filename.endswith(".json") or parameter_filename.startswith("."):
|
||||
raise ValueError("Invalid parameter filename")
|
||||
parameter_path = resolve_within(get_parameters_dir(), parameter_filename)
|
||||
if os.path.getsize(parameter_path) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Parameter notebook is too large")
|
||||
with open(parameter_path, "r", encoding="utf-8") as parameter_file:
|
||||
source = json.load(parameter_file)
|
||||
if not isinstance(source, dict) or source.get("recipe_filename") != recipe_filename:
|
||||
raise ValueError("Parameter notebook does not belong to recipe")
|
||||
workflow = source.get("workflow") if isinstance(source, dict) else None
|
||||
if not isinstance(workflow, dict):
|
||||
raise ValueError("Parameter source has no workflow")
|
||||
_validate_workflow(workflow)
|
||||
return recipe_filename, parameter_filename, recipe, source, workflow
|
||||
|
||||
|
||||
def _parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow):
|
||||
record = {
|
||||
"type": "recipe_parameter_notebook" if parameter_filename else "workflow_recipe",
|
||||
**_snapshot_recipe_source(recipe_filename),
|
||||
"parameter_signature": (_parameter_signature(workflow) or {}).get("value") or "",
|
||||
}
|
||||
if parameter_filename:
|
||||
record["parameter_filename"] = parameter_filename
|
||||
record["parameter_name"] = str(source.get("name") or "").strip()[:200]
|
||||
elif isinstance(recipe, dict):
|
||||
record["parameter_name"] = str(recipe.get("name") or "").strip()[:200]
|
||||
return record
|
||||
|
||||
|
||||
def _snapshot_recipe_source(recipe_filename):
|
||||
if not recipe_filename:
|
||||
return {}
|
||||
@@ -253,14 +211,17 @@ def _query_materials(materials_dir, query):
|
||||
if len(node_type) > 200:
|
||||
raise ValueError("Invalid node type")
|
||||
if len(search) > 200 or len(tag) > 60 or kind not in (
|
||||
"", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "prompt_note_bundle", "prompt_text", "prompt_plan"
|
||||
"", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "node_parameter_selection",
|
||||
"prompt_note_bundle", "prompt_text", "prompt_plan"
|
||||
):
|
||||
raise ValueError("Invalid material filter")
|
||||
# Whole workflows live with the recipes: "all" is the pieces, and one moved there is not listed.
|
||||
materials = [material for material in materials
|
||||
if (not search or search in " ".join([material["name"], *material["node_types"], *material.get("tags", [])]).casefold())
|
||||
if not material.get("moved_to_recipe")
|
||||
and (not search or search in " ".join([material["name"], *material["node_types"], *material.get("tags", [])]).casefold())
|
||||
and (not tag or tag in [value.casefold() for value in material.get("tags", [])])
|
||||
and (not kind or material["kind"] == kind)
|
||||
and (category == "all" or _material_category(material) == category)
|
||||
and (_material_category(material) == category if category != "all" else _material_category(material) != "workflow")
|
||||
and (not node_type or node_type in material["node_types"])]
|
||||
total = len(materials)
|
||||
limit = min(100, max(1, int(query.get("limit", 48))))
|
||||
@@ -288,10 +249,16 @@ def _update_material_details(materials_dir, filename, name, tags, prompt_role_ov
|
||||
return _with_live_recipe_source(_material_summary(filename, material))
|
||||
|
||||
|
||||
def _mark_material_moved(materials_dir, filename, recipe_filename):
|
||||
"""Notes that a kept workflow now lives as recipe `recipe_filename`; the material file stays."""
|
||||
with _material_write_lock:
|
||||
path = resolve_within(materials_dir, filename)
|
||||
material = _read_material(path)
|
||||
material["moved_to_recipe"] = recipe_filename
|
||||
_atomic_write_json(path, material)
|
||||
|
||||
|
||||
def _delete_material(materials_dir, filename, path):
|
||||
with _material_write_lock:
|
||||
_read_material(path)
|
||||
os.remove(path)
|
||||
assets_dir = _material_assets_dir(materials_dir, filename)
|
||||
if os.path.isdir(assets_dir):
|
||||
shutil.rmtree(assets_dir)
|
||||
move_to_trash(path, _material_assets_dir(materials_dir, filename))
|
||||
|
||||
+37
-148
@@ -25,16 +25,13 @@ from .material_schema import (
|
||||
_normalise_material_tags,
|
||||
_normalise_prompt_role_overrides,
|
||||
_normalise_selected_node_ids,
|
||||
_parameter_signature,
|
||||
_prompt_excerpt,
|
||||
_prompt_groups_from_roles,
|
||||
_prompt_roles_for_workflow,
|
||||
_workflow_hashes_for_blocks,
|
||||
)
|
||||
from .notebooks import MAX_NOTEBOOK_BYTES
|
||||
from .parameters import get_parameters_dir
|
||||
from .recipe_constants import MAX_RECIPE_BYTES
|
||||
from .recipe_schema import _build_model_references
|
||||
from .recipe_schema import _build_model_references, _normalise_source_image
|
||||
from .recipe_store import get_recipes_dir
|
||||
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
|
||||
|
||||
@@ -43,8 +40,6 @@ MATERIAL_SCHEMA_VERSION = 1
|
||||
|
||||
_store_get_materials_dir = _store.get_materials_dir
|
||||
_store_read_material = _store._read_material
|
||||
_store_read_parameter_source = _store._read_parameter_source
|
||||
_store_parameter_source_record = _store._parameter_source_record
|
||||
_store_snapshot_recipe_source = _store._snapshot_recipe_source
|
||||
_store_live_recipe_source = _store._live_recipe_source
|
||||
_store_with_live_recipe_source = _store._with_live_recipe_source
|
||||
@@ -89,16 +84,6 @@ def _read_material(path):
|
||||
return _store_read_material(path)
|
||||
|
||||
|
||||
def _read_parameter_source(recipe_filename, parameter_filename=None):
|
||||
_store.get_recipes_dir = get_recipes_dir
|
||||
_store.get_parameters_dir = get_parameters_dir
|
||||
return _store_read_parameter_source(recipe_filename, parameter_filename)
|
||||
|
||||
|
||||
def _parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow):
|
||||
return _store_parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow)
|
||||
|
||||
|
||||
def _snapshot_recipe_source(filename):
|
||||
_store.get_recipes_dir = get_recipes_dir
|
||||
return _store_snapshot_recipe_source(filename)
|
||||
@@ -263,136 +248,6 @@ async def api_save_image_material(request):
|
||||
})
|
||||
|
||||
|
||||
async def api_save_parameter_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
recipe_filename, parameter_filename, recipe, source, workflow = await asyncio.to_thread(
|
||||
_read_parameter_source,
|
||||
payload.get("recipe_filename", ""),
|
||||
payload.get("parameter_filename"),
|
||||
)
|
||||
source_name = str(source.get("name") or recipe.get("name") or "参数素材").strip()
|
||||
name = payload.get("name") or source_name
|
||||
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
|
||||
raise ValueError("Invalid material name")
|
||||
tags = _normalise_material_tags(payload.get("tags", recipe.get("tags") or []))
|
||||
allow_duplicate = payload.get("allow_duplicate", False)
|
||||
if not isinstance(allow_duplicate, bool):
|
||||
raise ValueError("Invalid duplicate preference")
|
||||
|
||||
reusable_ids = [
|
||||
node.get("id") for node in workflow.get("nodes", [])
|
||||
if isinstance(node, dict) and node.get("id") is not None
|
||||
and isinstance(node.get("widgets_values"), list) and node.get("widgets_values")
|
||||
]
|
||||
raw_selection = payload.get("selected_node_ids")
|
||||
selected_node_ids = _normalise_selected_node_ids(
|
||||
workflow,
|
||||
reusable_ids if raw_selection is None else raw_selection,
|
||||
)
|
||||
reusable_keys = {str(value) for value in reusable_ids}
|
||||
if any(str(value) not in reusable_keys for value in selected_node_ids):
|
||||
raise ValueError("Selected node has no reusable parameters")
|
||||
selected_keys = {str(value) for value in selected_node_ids}
|
||||
blocks = [
|
||||
block for block in _node_blocks(workflow, include_values=False)
|
||||
if str(block.get("node_id")) in selected_keys
|
||||
]
|
||||
references = [
|
||||
reference for reference in _build_model_references(source, verify_identities=False)
|
||||
if str(reference.get("node_id")) in selected_keys
|
||||
]
|
||||
source_record = _parameter_source_record(
|
||||
recipe_filename, parameter_filename, recipe, source, workflow
|
||||
)
|
||||
material_id = uuid.uuid4().hex
|
||||
filename = f"material_{int(time.time())}_{material_id}.json"
|
||||
material = {
|
||||
"schema_version": MATERIAL_SCHEMA_VERSION,
|
||||
"id": material_id,
|
||||
"kind": "recipe_parameter_selection",
|
||||
"name": name,
|
||||
"tags": tags,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"source": source_record,
|
||||
"workflow": workflow,
|
||||
"model_references": references,
|
||||
"capabilities": ["apply_node_parameters"],
|
||||
"selection": {"scope": "nodes", "node_ids": selected_node_ids},
|
||||
}
|
||||
params = source.get("params") if isinstance(source.get("params"), dict) else {}
|
||||
prompt_role_overrides = _normalise_prompt_role_overrides(params.get("promptRoleOverrides"))
|
||||
if prompt_role_overrides:
|
||||
material["promptRoleOverrides"] = prompt_role_overrides
|
||||
materials_dir = get_materials_dir()
|
||||
duplicate = await asyncio.to_thread(
|
||||
_persist_parameter_material, materials_dir, filename, material, allow_duplicate
|
||||
)
|
||||
if duplicate:
|
||||
return web.json_response({
|
||||
"status": "duplicate",
|
||||
"filename": duplicate["filename"],
|
||||
"name": duplicate["name"],
|
||||
}, status=409)
|
||||
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Could not save parameter material"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Parameter source not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save parameter material"}, status=500)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"material": _with_live_recipe_source(_material_summary(filename, material)),
|
||||
"node_blocks": blocks,
|
||||
})
|
||||
|
||||
|
||||
async def api_save_prompt_note_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
scope = payload.get("scope", "note")
|
||||
if scope not in ("note", "prompt"):
|
||||
raise ValueError("Invalid prompt scope")
|
||||
source_filename = require_filename(payload.get("notebook_filename", ""))
|
||||
if not source_filename.endswith(".json") or source_filename.startswith("."):
|
||||
raise ValueError("Invalid notebook filename")
|
||||
name = payload.get("name", "")
|
||||
if not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
|
||||
raise ValueError("Invalid material name")
|
||||
note = _normalise_prompt_note(payload.get("note"), scope == "prompt")
|
||||
if scope == "prompt" and not note["promptEn"].strip():
|
||||
raise ValueError("Prompt is empty")
|
||||
tags = _normalise_material_tags(payload.get("tags", []))
|
||||
allow_duplicate = payload.get("allow_duplicate", False)
|
||||
if not isinstance(allow_duplicate, bool):
|
||||
raise ValueError("Invalid duplicate preference")
|
||||
signature = hashlib.sha256(json.dumps(note, ensure_ascii=False, sort_keys=True, allow_nan=False).encode("utf-8")).hexdigest()
|
||||
material_id = uuid.uuid4().hex
|
||||
filename = f"material_{int(time.time())}_{material_id}.json"
|
||||
material = {
|
||||
"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
|
||||
"kind": "prompt_text" if scope == "prompt" else "prompt_note_bundle",
|
||||
"name": name.strip(), "tags": tags, "timestamp": int(time.time() * 1000),
|
||||
"source": {"type": "prompt_note", "notebook_filename": source_filename,
|
||||
"notebook_name": name.strip(), "parameter_signature": signature},
|
||||
"note": note, "selection": {"scope": scope},
|
||||
"capabilities": ["copy_prompt", "restore_prompt_note"],
|
||||
}
|
||||
duplicate = await asyncio.to_thread(
|
||||
_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate
|
||||
)
|
||||
if duplicate:
|
||||
return web.json_response({"status": "duplicate", "filename": duplicate["filename"],
|
||||
"name": duplicate["name"]}, status=409)
|
||||
return web.json_response({"status": "success", "filename": filename,
|
||||
"material": _material_summary(filename, material)})
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid prompt note material"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save prompt note material"}, status=500)
|
||||
|
||||
|
||||
async def api_save_prompt_plan(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
@@ -404,13 +259,16 @@ async def api_save_prompt_plan(request):
|
||||
allow_duplicate = payload.get("allow_duplicate", False)
|
||||
if not isinstance(allow_duplicate, bool):
|
||||
raise ValueError("Invalid duplicate preference")
|
||||
# The output image the prompts were taken from, so the gallery can show it was kept.
|
||||
source_image = _normalise_source_image(payload.get("source_image"))
|
||||
signature = hashlib.sha256(json.dumps(plan, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
material_id = uuid.uuid4().hex
|
||||
filename = f"material_{int(time.time())}_{material_id}.json"
|
||||
material = {"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
|
||||
"kind": "prompt_plan", "name": name.strip(), "tags": tags,
|
||||
"timestamp": int(time.time() * 1000), "plan": plan,
|
||||
"source": {"type": "prompt_plan", "parameter_signature": signature},
|
||||
"source": {"type": "prompt_plan", "parameter_signature": signature,
|
||||
**({"image": source_image} if source_image else {})},
|
||||
"capabilities": ["compose_prompt"], "selection": {"scope": "prompt_plan"}}
|
||||
duplicate = await asyncio.to_thread(_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate)
|
||||
if duplicate:
|
||||
@@ -432,6 +290,24 @@ async def api_get_materials(request):
|
||||
return web.json_response({"status": "error", "message": "Could not list materials"}, status=500)
|
||||
|
||||
|
||||
async def api_mark_material_moved(request):
|
||||
"""POST {filename, recipe}: the kept workflow `filename` was saved as recipe `recipe`."""
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
recipe = require_filename(payload.get("recipe", ""))
|
||||
if not filename.endswith(".json") or not recipe.endswith(".json"):
|
||||
raise ValueError("Invalid material")
|
||||
await asyncio.to_thread(_store._mark_material_moved, get_materials_dir(), filename, recipe)
|
||||
return web.json_response({"status": "success"})
|
||||
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid material"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not update material"}, status=500)
|
||||
|
||||
|
||||
async def api_update_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
@@ -514,7 +390,8 @@ async def api_get_materials_by_node_type(request):
|
||||
def collect():
|
||||
matches = []
|
||||
for summary in _list_materials(materials_dir):
|
||||
if node_type not in summary["node_types"]:
|
||||
# A whole workflow moved to a recipe is offered through the recipe's own sets.
|
||||
if node_type not in summary["node_types"] or summary.get("moved_to_recipe"):
|
||||
continue
|
||||
try:
|
||||
material = _read_material(resolve_within(materials_dir, summary["filename"]))
|
||||
@@ -522,6 +399,14 @@ 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:
|
||||
# A prompt's role, by what it is wired to in the workflow it came from.
|
||||
roles = _prompt_roles_for_workflow(material["workflow"], material.get("promptRoleOverrides"))
|
||||
for block in blocks:
|
||||
block["role"] = (roles.get(str(block["node_id"])) or {}).get("role") or ""
|
||||
# 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"],
|
||||
@@ -529,6 +414,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,424 @@
|
||||
"""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_bases, 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."}
|
||||
|
||||
|
||||
MAX_PROMPTS = 8
|
||||
MAX_TRIES = 16 # prompts x batch in one call
|
||||
|
||||
|
||||
def _submit(batch, base_only=False, hold=None):
|
||||
"""Queues what the canvas holds `batch` times (its first pass alone with `base_only`; `hold`: the base the
|
||||
user picked, ai_bases.py): (receipt IDs, the page's answer); IDs are None when no valid receipt came."""
|
||||
try:
|
||||
answer = ask_page("run_workflow", {"batch": batch, **({"base_only": True} if base_only else {}),
|
||||
**({"hold": hold} if hold else {})}, required_capability="prompt_ids")
|
||||
except (PageUnavailable, PageRefused) as error:
|
||||
raise ToolError(str(error))
|
||||
page = answer if isinstance(answer, dict) else {}
|
||||
ids = page.get("prompt_ids")
|
||||
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)):
|
||||
return None, page
|
||||
return ids, page
|
||||
|
||||
|
||||
def _refused(page):
|
||||
return (run_refusal.say(page.get("refusal")) or page.get("submission_error")
|
||||
or "No valid submission receipts. Check ComfyUI's queue before retrying; do not repeat automatically.")
|
||||
|
||||
|
||||
def _base_words(answer, page, base_only, hold):
|
||||
"""What run_workflow says of the base: the bases to pick from coming, or the picked one held (ai_bases.py)."""
|
||||
if base_only and page.get("base"):
|
||||
answer["base_only"] = page["base"] # the run's step keeps it: its previews become the user's picks
|
||||
answer["note"] += (" Only the first pass runs, as previews: when it ends Anomalous shows the user these bases to "
|
||||
"pick one; the picked one is kept and its seed held for your next run_workflow.")
|
||||
held = page.get("held") or {}
|
||||
if hold and held.get("kept"):
|
||||
answer["held"] = f"The rest grows from the base the user picked ({hold['kept']}); it is not drawn again."
|
||||
elif hold and held.get("held"):
|
||||
answer["held"] = f"The base the user picked: seed {hold['seed']} on node {hold['node']}, held for every try."
|
||||
if hold.get("show"):
|
||||
answer["base_check"] = {"show": hold["show"], "kept": hold["kept"]} # compared when the run ends
|
||||
elif hold:
|
||||
answer["held"] = (f"The base the user picked was not held: {held.get('reason') or 'the page did not hold it'}; "
|
||||
"this run draws its own base.")
|
||||
|
||||
|
||||
def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, prompts=None, base_only=False, **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)))
|
||||
if prompts is not None: # several prompts, queued in order as one run, each with the call's other blanks
|
||||
if slots.get("prompt") or not isinstance(prompts, list) or not 1 <= len(prompts) <= MAX_PROMPTS or any(not isinstance(text, str) or not text.strip() for text in prompts):
|
||||
raise ToolError(f"prompts is a list of 1 to {MAX_PROMPTS} prompts, given instead of prompt.")
|
||||
if len(prompts) * batch > MAX_TRIES:
|
||||
raise ToolError(f"{len(prompts)} prompts x batch {batch} is over {MAX_TRIES} tries in one call.")
|
||||
ids, groups, filled, page, stopped = [], [], None, {}, None
|
||||
hold = None if base_only else ai_bases.hold_of(ctx.get("task"))
|
||||
if base_only:
|
||||
ai_bases.forget(ctx.get("task")) # new bases: the earlier pick no longer holds
|
||||
for index, text in enumerate(prompts or [None]):
|
||||
try:
|
||||
done = mcp_fill.fill(ctx, workflow, **(slots if text is None else {**slots, "prompt": text}))
|
||||
except ToolError as error:
|
||||
if not ids:
|
||||
raise
|
||||
stopped = f"Prompt {index + 1} was not filled in: {error}"
|
||||
break
|
||||
filled = filled or done
|
||||
got, page = _submit(batch, base_only, hold)
|
||||
if got is None:
|
||||
if not ids:
|
||||
raise ToolError(_refused(page))
|
||||
stopped = f"Prompt {index + 1} was not queued: {_refused(page)}"
|
||||
break
|
||||
ids += got
|
||||
groups.append({"prompt": index + 1, "prompt_ids": got})
|
||||
if len(got) < batch:
|
||||
stopped = f"Only {len(got)} of {batch} tries of prompt {index + 1} were accepted. {run_refusal.say(page.get('refusal')) or ''}".strip()
|
||||
break
|
||||
requested = batch * len(prompts or [None])
|
||||
answer = {"prompt_ids": ids, "queued": len(ids), "requested": requested,
|
||||
"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."}
|
||||
_base_words(answer, page, base_only, hold)
|
||||
if prompts is not None:
|
||||
answer["by_prompt"] = groups # which receipts are which prompt; the canvas keeps the last prompt
|
||||
if filled:
|
||||
answer["filled"] = filled
|
||||
# Some prompts may have been accepted before validation or a transport failure stopped the batch.
|
||||
if stopped:
|
||||
answer["submission_error"] = stopped
|
||||
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."}
|
||||
_PROMPT = {"type": "string", "description": "Your own? First get_toolbox(skill=\"prompt_pose\")."}
|
||||
|
||||
|
||||
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": _PROMPT, "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). workflow (a saved one) opens once in the task's "
|
||||
"tab; prompt, negative, seed (every sampler), width/height (the start latent), image (its LoadImage) and "
|
||||
"values (any setting by name: {\"steps\": 30}; {\"12\": {\"denoise\": 0.5}} for one node) fill it "
|
||||
"in, else the open canvas. Then run_status, never the output folder.",
|
||||
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1,
|
||||
"description": "Tries, each its own seed."},
|
||||
"wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 55},
|
||||
"workflow": {"type": "string", "description": "A name or plan from list_workflows."},
|
||||
"prompt": _PROMPT, "negative": {"type": "string"},
|
||||
"prompts": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_PROMPTS,
|
||||
"description": "Instead of prompt: several, queued in order as one run."},
|
||||
"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, of several."},
|
||||
"values": {"type": "object", "description": "Other settings by name, any node's."},
|
||||
"base_only": {"type": "boolean", "description": "First pass only: previews to pick a base from."}}),
|
||||
_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,215 @@
|
||||
<!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.', '还没有任务可看。'],
|
||||
private: ['Privacy mode: the pictures are only shown in Anomalous.', '隐私模式:图只在 AMB 里看。'],
|
||||
};
|
||||
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);
|
||||
}
|
||||
if (view.private) parts.push(el('div', 'note', t('private')));
|
||||
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>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
"""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); a private task's are not shown (ai_privacy.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import ai_panels, ai_privacy, 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["type"] == "prompts" # prompts are written in Anomalous only
|
||||
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"]
|
||||
private = ai_privacy.is_private(record) # its pictures stay in Anomalous
|
||||
pictures = [] if private else [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)), "private": private,
|
||||
"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,101 @@
|
||||
"""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; while the page that did the last action is open,
|
||||
it gets the next too: it holds the task's tabs, and with ComfyUI open in two browser tabs one
|
||||
must not open a task's tab and the other then not find it), 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 time
|
||||
import uuid
|
||||
|
||||
TIMEOUT_SECONDS = 30
|
||||
MAX_WAITING = 20
|
||||
HOLD_SECONDS = 3 # how long the page that did the last action has to claim the next before another page may
|
||||
|
||||
_lock = threading.Lock()
|
||||
_waiting = {} # id -> {"event": Event, "claimed": bool, "outcome": dict | None}
|
||||
_last = {"page": None} # the page that did the last action (its own id, web/modules/mcp_bridge.js pageId)
|
||||
|
||||
|
||||
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, "sent": time.monotonic()}
|
||||
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, page=None):
|
||||
"""True for the page that may do request `request_id`: the first that asks, but for HOLD_SECONDS
|
||||
only the page that did the last action (`page`, its id), when it is another: "later" tells
|
||||
another page to ask again then (it is the one left when that page was closed)."""
|
||||
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
|
||||
if page and _last["page"] and page != _last["page"] and time.monotonic() - entry.get("sent", 0) < HOLD_SECONDS:
|
||||
return "later"
|
||||
entry["claimed"] = True
|
||||
if page:
|
||||
_last["page"] = page
|
||||
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,425 @@
|
||||
"""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 os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import ai_gates, ai_pictures, ai_tasks, ai_toolbox, mcp_fill, mcp_routes, model_advice, model_download, reference_blocks
|
||||
from .mcp_actions import _action, _local, _page
|
||||
from .media_routes import picture_size
|
||||
from .mcp_tools import ToolError, _resolve_model
|
||||
|
||||
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, and, while the user lets custom nodes in only
|
||||
by the ways (ai_gates.node_rule), those of no way the task did not pick either, before the page is asked."""
|
||||
ways_only = ai_gates.node_rule() == "ways"
|
||||
if not ctx.get("task") and not ways_only:
|
||||
return
|
||||
info = object_info(ctx)
|
||||
if ways_only:
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) if ctx.get("task") else None
|
||||
ways = reference_blocks.node_types()
|
||||
outside = sorted({name for name in types if name in info and not ai_toolbox.builtin(info[name])
|
||||
and name not in ways and not ai_toolbox.chosen(box, name, info[name])})
|
||||
if outside:
|
||||
raise ToolError(f"The user lets custom nodes in only by the ready-made ways (add_nodes use=…): not {', '.join(outside)}. "
|
||||
"Use a way or ComfyUI's built-in nodes, or ask the user in the AI workbench (show_panel).")
|
||||
if not ctx.get("task"):
|
||||
return
|
||||
refused = ai_toolbox.refused(ctx["task"], types, info)
|
||||
if refused:
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) or {}
|
||||
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 _main_base(near_node_id):
|
||||
"""The base model of the model the canvas's sampler uses, as the page reads its loader (model_advice.py)."""
|
||||
line = _page("block_model", {"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id")})
|
||||
return model_advice._main_base((line or {}).get("model") or "")
|
||||
|
||||
|
||||
def _base_of(name):
|
||||
"""ControlNet file `name`'s base model, as Anomalous knows it."""
|
||||
try:
|
||||
path = model_advice.folder_paths.get_full_path("controlnet", name)
|
||||
except (KeyError, TypeError):
|
||||
path = None
|
||||
return model_advice.base_of(path) if path else ""
|
||||
|
||||
|
||||
def _picture_model(ctx, model_id, info):
|
||||
"""(loader value, base model) of the model a standalone block draws with: `model_id`, else the first main
|
||||
model the user picked for the task. Among diffusion-model files only Anima's text encoder and VAE are known."""
|
||||
if not model_id:
|
||||
task = ai_tasks.get_task(ctx["task"]) if ctx.get("task") else None
|
||||
model_id = next((item for item in (ai_toolbox.effective(task) or {}).get("models") or []
|
||||
if item.split(":", 1)[0] in model_advice.MAIN_TYPES), None)
|
||||
if not model_id:
|
||||
raise ToolError("use picture needs the model to draw with: model (a model_id from search_models; ask_user which), "
|
||||
"or the one the user picks for the task.")
|
||||
kind, _index, root, path = _resolve_model(model_id)
|
||||
base = model_advice.base_of(path)
|
||||
loader = {"checkpoints": ("CheckpointLoaderSimple", "ckpt_name")}.get(kind, ("UNETLoader", "unet_name"))
|
||||
family = model_advice.family(base)
|
||||
if kind not in model_advice.MAIN_TYPES or family not in (("anima",) if loader[0] == "UNETLoader" else ("SDXL", "SD 1.x", "")):
|
||||
raise ToolError(f"{model_id} is no model use picture draws with: an SDXL or SD 1.5 checkpoint, or an Anima diffusion model "
|
||||
"(its text encoder and VAE are known). Open a saved workflow for it instead (list_workflows).")
|
||||
wanted = os.path.relpath(path, root).replace("\\", "/")
|
||||
value = next((name for name in reference_blocks._choices(info, *loader) if name.replace("\\", "/") == wanted), None)
|
||||
if value is None:
|
||||
raise ToolError(f"ComfyUI's {loader[0]} does not list {wanted}: is it in that model folder?")
|
||||
return value, base
|
||||
|
||||
|
||||
def _empty_canvas():
|
||||
"""Refuses a standalone block on a canvas that already makes a picture: it would run as a second one."""
|
||||
try:
|
||||
_page("block_model", {"near_node_id": None})
|
||||
except ToolError as error:
|
||||
if "No sampler" in str(error):
|
||||
return
|
||||
raise ToolError("The canvas already makes a picture: run it, or new_workflow (blank) first for a separate one.")
|
||||
|
||||
|
||||
def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, image=None, scene=None, use=None, want=None, model=None, route=None):
|
||||
if route is not None:
|
||||
return mcp_routes.build(ctx, str(route), model)
|
||||
if not isinstance(replace, bool):
|
||||
raise ToolError("replace must be true or false.")
|
||||
if [nodes is not None, block is not None, use is not None].count(True) != 1:
|
||||
raise ToolError("Give one of: use (from begin_task's blocks, with want), block (a ready-made one by name) or nodes (a group in API form).")
|
||||
if use is not None:
|
||||
task = ai_tasks.get_task(ctx["task"]) if ctx.get("task") else None
|
||||
try: # the way the user picked for this use, else the use's best block this ComfyUI can run
|
||||
block = reference_blocks.pick(str(use), None if want is None else str(want), object_info(ctx), bool(scene),
|
||||
(ai_toolbox.effective(task) or {}).get("ways") or ())
|
||||
except reference_blocks.BlockError as error:
|
||||
raise ToolError(str(error))
|
||||
if block is not None:
|
||||
loader, base = None, None
|
||||
if reference_blocks.BLOCKS.get(str(block), {}).get("standalone"): # a picture from words: the model it draws with, an empty canvas
|
||||
loader, base = _picture_model(ctx, model, object_info(ctx))
|
||||
_empty_canvas()
|
||||
if reference_blocks.BLOCKS.get(str(block), {}).get("takes") in ("reference", "picture") and image:
|
||||
image = ai_pictures.to_input(image) # the picture to follow or work on, where LoadImage reads
|
||||
if reference_blocks.BLOCKS.get(str(block), {}).get("scene") and scene:
|
||||
scene = ai_pictures.to_input(scene)
|
||||
def build(): # a ready-made block (reference_blocks.py): its roles are found on the canvas, what it cannot splice it replaces
|
||||
return reference_blocks.build(str(block), object_info(ctx), image, (lambda: base) if loader else lambda: _main_base(near_node_id),
|
||||
_base_of, scene, lambda name: picture_size(ai_tasks.folder_paths.get_annotated_filepath(name)), loader)
|
||||
try:
|
||||
try:
|
||||
built = build()
|
||||
except reference_blocks.MissingModel: # a model just downloaded into a subfolder: ComfyUI's lists see it once read anew
|
||||
model_download.forget_file_lists()
|
||||
forget_object_info()
|
||||
built = build()
|
||||
nodes, replace = built["nodes"], True
|
||||
except reference_blocks.BlockError as error:
|
||||
raise ToolError(str(error))
|
||||
else:
|
||||
_api_nodes(nodes, canvas_refs=True)
|
||||
_allowed(ctx, [spec["class_type"] for spec in nodes.values()])
|
||||
answer = _next(_page("add_nodes", {"nodes": nodes, "replace": replace,
|
||||
"roles": built["roles"] if block is not None else {},
|
||||
"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id")}, timeout=60))
|
||||
if block is not None and isinstance(answer, dict) and not answer.get("unwired") and answer.get("reaches_output") is not False:
|
||||
answer["next"] = built["after"]
|
||||
if built["tested"]:
|
||||
answer["tested"] = built["tested"] # so the AI can say how long it will take
|
||||
return answer
|
||||
|
||||
|
||||
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):
|
||||
return _page("open_workflow", mcp_fill.opening(workflow)[0], 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. Or use (and want): a ready-made block for that use (begin_task lists them), the best this "
|
||||
"ComfyUI can run, spliced into the canvas's sampler by itself; near_node_id picks the sampler when there are several.",
|
||||
add_nodes, {"nodes": {"type": "object"}, "use": {"type": "string", "enum": list(reference_blocks.JOBS)},
|
||||
"want": {"type": "string", "description": "Kind from begin_task (best, 2x, pose…)."},
|
||||
"block": {"type": "string"},
|
||||
"image": {"type": "string", "description": "A repaint block's inpaint_image; a control block's picture to follow."},
|
||||
"scene": {"type": "string", "description": "The picture place_in_scene puts it into."},
|
||||
"near_node_id": _NODE_ID, "replace": {"type": "boolean"},
|
||||
"model": {"type": "string", "description": "use=picture: a model_id, else the task's picked one."},
|
||||
"route": {"type": "string", "description": "The route ask_user(routes=true) got: built whole on a new tab."}}),
|
||||
_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 a saved workflow or a gallery picture's own in a new tab, the open one kept, "
|
||||
"to run it or build on it with add_node, connect_nodes and set_values.",
|
||||
open_workflow, {"workflow": {"type": "string", "description": "A list_workflows name or a search_images picture."}},
|
||||
["workflow"]),
|
||||
])
|
||||
@@ -0,0 +1,117 @@
|
||||
"""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, mcp_fit
|
||||
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, titles=True):
|
||||
"""{heading: "tool (title), ..."} for the tools of `specs` {name: spec}; the names alone without `titles`."""
|
||||
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})" if titles else 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
|
||||
answer = {**data, "directory": directory(specs)}
|
||||
if mcp_fit.size(answer) > mcp_fit.BUDGET: # a long message of the user's with it: the tools by name, to stay readable
|
||||
answer["directory"] = directory(specs, titles=False)
|
||||
retext(result, answer)
|
||||
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
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
"""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, or one of its 方案 as "workflow / plan": a parameter set saved
|
||||
under it, applied as its Apply button does, or a gallery picture, whose own workflow is opened: `opening`)
|
||||
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, or "picture:" and the picture); 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.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import ai_task_canvas, ai_tasks
|
||||
from .ai_pictures import to_input
|
||||
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
|
||||
from .mcp_tools import IMAGE_TYPES, ToolError, _resolve_folder_image, _resolve_image, plans
|
||||
from .recipe_images import _embedded_workflow_payload
|
||||
|
||||
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, plan) of the user's saved workflow called `name`, or of its 方案 "workflow / plan"
|
||||
(plan: the parameter set's file name, else None), or of the example called that."""
|
||||
from . import example_workflows
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
|
||||
name = str(name or "").strip()
|
||||
files = {}
|
||||
for recipe in _list_recipes(get_recipes_dir()):
|
||||
files.setdefault(recipe["data"].get("name") or recipe["filename"], recipe["filename"])
|
||||
if name and name in files:
|
||||
return files[name], name, None
|
||||
example = example_workflows.file_of(name)
|
||||
if example:
|
||||
return example, name, None
|
||||
workflow, _, plan = name.rpartition(" / ")
|
||||
found = next((file for file, plan_name in plans().get(files.get(workflow), []) if plan_name == plan), None)
|
||||
if not found:
|
||||
raise ToolError(f"No saved workflow or plan called {name}; list_workflows names them.")
|
||||
return files[workflow], name, found
|
||||
|
||||
|
||||
def picture_workflow(name):
|
||||
"""{subfolder, filename} of gallery picture `name` (in the output folder) when the name is a
|
||||
picture's and it carries a workflow; None when the name is no picture's."""
|
||||
if os.path.splitext(str(name or "").strip())[1].lower() not in IMAGE_TYPES:
|
||||
return None
|
||||
rel, path = _resolve_image(str(name).strip())
|
||||
if not (_embedded_workflow_payload(path) or {}).get("workflow"):
|
||||
raise ToolError(f"{rel} carries no workflow to open: only the pictures ComfyUI saves with one do.")
|
||||
subfolder, _, filename = rel.rpartition("/")
|
||||
return {"subfolder": subfolder, "filename": filename}
|
||||
|
||||
|
||||
def opening(name):
|
||||
"""(what the page's open_workflow takes, the mark it leaves on the tab) for `name`: a saved workflow,
|
||||
one of its plans or an example (saved_workflow), else a gallery picture's own workflow."""
|
||||
try:
|
||||
filename, shown, plan = saved_workflow(name)
|
||||
except ToolError:
|
||||
picture = picture_workflow(name) # not a saved one: a picture's own, to change what made it
|
||||
if not picture:
|
||||
raise
|
||||
rel = "/".join(part for part in (picture["subfolder"], picture["filename"]) if part)
|
||||
return {"picture": picture, "name": os.path.splitext(picture["filename"])[0]}, f"picture:{rel}"
|
||||
return {"filename": filename, "name": shown, "plan": plan}, (f"{filename}#{plan}" if plan else filename)
|
||||
|
||||
|
||||
def _picture(image):
|
||||
"""The value a LoadImage takes for picture `image`: in the input folder as it is, from the
|
||||
output folder its copy there (ai_pictures.to_input)."""
|
||||
for folder in ("input", "output"):
|
||||
try:
|
||||
rel, _path = _resolve_folder_image(image, folder)
|
||||
except ToolError:
|
||||
continue
|
||||
return rel if folder == "input" else to_input(rel)
|
||||
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"])
|
||||
args, opened = opening(workflow) if workflow else ({}, None) # opened: what the page marks the tab with
|
||||
name = args.get("name")
|
||||
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": opened, **slots})
|
||||
if workflow and (filled is None or filled.get("needs_open")):
|
||||
_page("open_workflow", args, timeout=60)
|
||||
filled = _page("fill_slots", {"filename": opened, **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,51 @@
|
||||
"""Answers an AI app reads in the chat. Past about 4,000 characters Antigravity (and apps like it) keeps
|
||||
a tool's answer as a file for the AI to open, and an AI told to use only Anomalous's tools never
|
||||
opens it: it sees nothing and asks again with smaller limits (the 1.63 exam: search_models eight
|
||||
times for one LoRA). An answer that is one list and longer than BUDGET keeps the items that fit, in
|
||||
their order, and says how many it left out, by name when its items have one, and how to reach
|
||||
them. Tools that fit their answers themselves (get_situation, search_models, list_workflows) are
|
||||
under it already; this is the net under the rest (list_combos, recent_activity and any list to
|
||||
come). Nothing is shortened inside an item: a prompt cut in half could be put in as it is.
|
||||
mcp_server.py runs it after the step is recorded, so the timeline keeps the whole answer."""
|
||||
|
||||
from .mcp_tools import retext, tool_text
|
||||
|
||||
# UTF-8 bytes. Antigravity's logs show answers filed from 4,099 bytes (3,627 characters, some of them
|
||||
# Chinese at three bytes each) while longer ones were shown at other times: stay under the lowest.
|
||||
BUDGET = 3800
|
||||
NAME = 60 # characters of a left-out item's name
|
||||
|
||||
|
||||
def size(data):
|
||||
return len(tool_text(data).encode("utf-8"))
|
||||
# A canvas cut short would read as a smaller canvas: get_situation gives every node in brief.
|
||||
WHOLE = {"describe_canvas"}
|
||||
|
||||
|
||||
def _the_list(data):
|
||||
"""The key of the one list of items in `data`, or None when there is none or more than one."""
|
||||
keys = [key for key, value in data.items() if isinstance(value, list) and value and all(isinstance(item, dict) for item in value)]
|
||||
return keys[0] if len(keys) == 1 else None
|
||||
|
||||
|
||||
def fit(result, name):
|
||||
"""`result` (an MCP tools/call result) with its one list cut to BUDGET, when it is longer."""
|
||||
data = result.get("structuredContent") if isinstance(result, dict) else None
|
||||
if name in WHOLE or result.get("isError") or not isinstance(data, dict) or size(data) <= BUDGET:
|
||||
return result
|
||||
key = _the_list(data)
|
||||
if not key:
|
||||
return result
|
||||
items = list(data[key])
|
||||
kept = {**data, key: items}
|
||||
while size(kept) > BUDGET and len(items) > 1:
|
||||
items.pop()
|
||||
left = data[key][len(items):]
|
||||
named = [str(item["name"])[:NAME] for item in left if item.get("name")]
|
||||
kept["left_out"] = (f"{len(left)} more {key} left out to keep this answer readable in the chat"
|
||||
+ (" (more_names)" if named else "") + ": a narrower query, a smaller limit or the next page lists them")
|
||||
if named:
|
||||
kept["more_names"] = named # what there is, to ask for by name
|
||||
if len(items) < len(data[key]):
|
||||
retext(result, kept)
|
||||
return result
|
||||
@@ -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,204 @@
|
||||
"""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_fit, 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
|
||||
SEARCH_WORDS = 4 # trigger words a found model shows; get_model has them all
|
||||
|
||||
_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 _compact(found):
|
||||
"""search_models' answer small enough to be read (mcp_fit.BUDGET): what a model_id says already
|
||||
(file, folder, type) left out, loaded_by once for a type when its models share it, a few trigger
|
||||
words, and as many models as fit, the rest by name (more_names), so one answer shows all there is
|
||||
and the AI need not search again to see the rest."""
|
||||
models, kinds = found["models"], {}
|
||||
for model in models:
|
||||
kinds.setdefault(model["type"], []).append(model)
|
||||
shared = {kind: group[0]["loaded_by"] for kind, group in kinds.items()
|
||||
if all("loaded_by" in model and model["loaded_by"] == group[0]["loaded_by"] for model in group)}
|
||||
for model in models:
|
||||
for key in ("file", "folder"):
|
||||
model.pop(key, None)
|
||||
if model.pop("type") in shared:
|
||||
model.pop("loaded_by")
|
||||
words = model.get("trigger_words") or []
|
||||
if len(words) > SEARCH_WORDS:
|
||||
model.update(trigger_words=words[:SEARCH_WORDS], more_trigger_words=len(words) - SEARCH_WORDS)
|
||||
answer = {"total_matches": found["total_matches"], **({"loaded_by": shared} if shared else {}), "models": models}
|
||||
everything = list(models)
|
||||
while mcp_fit.size(answer) > mcp_fit.BUDGET and len(models) > 1:
|
||||
models.pop()
|
||||
answer["more_names"] = [str(model.get("name") or model["model_id"])[:mcp_fit.NAME] for model in everything[len(models):]]
|
||||
answer["left_out"] = (f"{len(answer['more_names'])} more, named in more_names to keep this answer readable: "
|
||||
"get_model or a query with the name gives one in full")
|
||||
return answer
|
||||
|
||||
|
||||
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)
|
||||
if model["type"] == "loras": # the weight its author's pictures use: picking one needs no get_model each
|
||||
path = mcp_tools._resolve_model(model["model_id"])[3]
|
||||
weight = model_advice.author_examples(path, "loras", (mcp_tools.get_metadata(path) or {}).get("hash", "")).get("lora_weight")
|
||||
if weight:
|
||||
model["author_weight"] = weight
|
||||
return _compact(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,137 @@
|
||||
"""Routes: what to do with the models the user picked, chosen by the user, built by Anomalous. A light
|
||||
model does not work out that two picked models mean one for the base and one for the enlargement, so
|
||||
Anomalous prepares the choices (routes.json, those this ComfyUI can build for these models, a saved card
|
||||
using both first): ask_user(routes=true) shows them, and add_nodes(route=…) builds the one picked on a new
|
||||
tab from the ways (reference_blocks.py), its LoRAs on each model, so the AI wires nothing. Each picked
|
||||
model's cards are its examples: get_toolbox lists them with their route (workflow_route.py)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from . import ai_bases, ai_tasks, ai_toolbox, model_advice, reference_blocks, workflow_route
|
||||
from .mcp_tools import ToolError, _resolve_model
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
|
||||
with open(os.path.join(os.path.dirname(__file__), "routes.json"), encoding="utf-8") as source:
|
||||
ROUTES = json.load(source)
|
||||
MAX_CARDS = 3
|
||||
QUESTION = "How should the two models you picked be used?"
|
||||
TOOLBOX_NOTE = ("The user picked two main models: ask_user(routes=true) asks them how to use the two, with options Anomalous "
|
||||
"prepared, and its answer says what to call. Skip it only when the user already said how.")
|
||||
NEXT = "run_workflow(prompt=, negative=, base_only=true, batch=3) shows the user bases to pick from; then run_workflow runs the whole route."
|
||||
ONE_PASS = "run_workflow(prompt=, negative=) draws it; there is no second pass, so no base to pick."
|
||||
|
||||
|
||||
def _main(box):
|
||||
return [item for item in (box or {}).get("models") or [] if item.split(":", 1)[0] in model_advice.MAIN_TYPES]
|
||||
|
||||
|
||||
def _loras(box):
|
||||
return [item for item in (box or {}).get("models") or [] if item.startswith("loras:")]
|
||||
|
||||
|
||||
def _facts(model_id):
|
||||
"""(file name without folders, base model) of `model_id`; ToolError when it is gone."""
|
||||
_kind, _index, _root, path = _resolve_model(model_id)
|
||||
return os.path.splitext(os.path.basename(path))[0], model_advice.base_of(path)
|
||||
|
||||
|
||||
def _can_build(route, base, info):
|
||||
if route.get("only") and model_advice.family(base) not in route["only"]:
|
||||
return False
|
||||
if model_advice.family(base) in route.get("not", ()):
|
||||
return False
|
||||
try:
|
||||
reference_blocks.build("picture", info, main_base=lambda: base, model="model")
|
||||
if route.get("way"):
|
||||
name = reference_blocks.pick(*route["way"], info)
|
||||
reference_blocks.build(name, info, main_base=lambda: base, base_of=lambda _name: base)
|
||||
except reference_blocks.BlockError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _cards():
|
||||
"""(name, stages, line) of each saved workflow."""
|
||||
for recipe in _list_recipes(get_recipes_dir(), with_workflow=True):
|
||||
data = recipe.get("data") or {}
|
||||
workflow = data.get("workflow")
|
||||
stages = [stage for stage in workflow_route.route(workflow)[0] if stage["state"] == "run"]
|
||||
yield data.get("name") or recipe["filename"], stages, workflow_route.line(workflow)
|
||||
|
||||
|
||||
def cards_of(model_id):
|
||||
"""The saved workflows drawing with `model_id`, each with its route: the model's examples."""
|
||||
name = _facts(model_id)[0]
|
||||
return [{"card": card, "route": line} for card, stages, line in _cards() if any(stage["model"] == name for stage in stages)][:MAX_CARDS]
|
||||
|
||||
|
||||
def offer(box, info):
|
||||
"""{"options": [ask_user's choices], "calls": {option id: what to call}} for the two main models in `box`,
|
||||
or None: fewer picked, one gone, or of different families (a switch reuses the first's prompts and VAE)."""
|
||||
picked = _main(box)[:2]
|
||||
if len(picked) < 2:
|
||||
return None
|
||||
try:
|
||||
(first, base), (second, other) = _facts(picked[0]), _facts(picked[1])
|
||||
except ToolError:
|
||||
return None
|
||||
if not model_advice.family(base) or model_advice.family(base) != model_advice.family(other):
|
||||
return None
|
||||
names = {"a": first, "b": second}
|
||||
options, calls = [], {}
|
||||
card = next((card for card, stages, _line in _cards() if {first, second} <= {stage["model"] for stage in stages}), None)
|
||||
if card:
|
||||
options.append({"id": "card", "label": f"Your saved card: {card}"[:80], "label_key": "aiRouteCard", "values": {"name": card},
|
||||
"detail": "A route you saved with these two models.", "detail_key": "aiRouteCardDetail", "recommended": True})
|
||||
calls["card"] = f'run_workflow(workflow="{card}", prompt=, negative=, base_only=true, batch=3) shows the user bases to pick from; then run_workflow.'
|
||||
for key, route in ROUTES.items():
|
||||
if _can_build(route, base, info):
|
||||
options.append({"id": key, "label": route["label"].format(**names)[:80], "label_key": f"aiRoute_{key}", "values": names,
|
||||
"detail": route["detail"], "detail_key": f"aiRouteDetail_{key}", "sample": key})
|
||||
calls[key] = f'add_nodes(route="{key}") builds it. Then {NEXT if route.get("way") else ONE_PASS}'
|
||||
if not card and options:
|
||||
options[0]["recommended"] = True
|
||||
return {"options": options, "calls": calls} if len(options) > 1 else None
|
||||
|
||||
|
||||
def waiting(task):
|
||||
"""Whether the task's picks still wait for the user's route: two main models picked, none asked, nothing run."""
|
||||
return len(_main(ai_toolbox.effective(task))) >= 2 and not any(
|
||||
step.get("routes") or step.get("tool") == "run_workflow" for step in task.get("steps") or [])
|
||||
|
||||
|
||||
def build(ctx, key, model=None):
|
||||
"""Route `key` on a new tab: the picture on the first model, the picked LoRAs on it, the way after it with
|
||||
its own loader on the second model and the LoRAs there too. What was built, and what to do now."""
|
||||
from . import mcp_actions, mcp_canvas_edit
|
||||
|
||||
route = ROUTES.get(key)
|
||||
if not route:
|
||||
raise ToolError(f"No route {key!r}; there are: {', '.join(ROUTES)}.")
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"]) if ctx.get("task") else None)
|
||||
picked = ([model] if model else []) + [item for item in _main(box) if item != model]
|
||||
if len(picked) < route["models"]:
|
||||
raise ToolError(f"Route {key} draws with {route['models']} main models: the user picks them for the task, or give model.")
|
||||
ai_bases.forget(ctx.get("task")) # a base picked on another canvas: its sampler is not this route's
|
||||
mcp_canvas_edit.new_workflow(ctx, key)
|
||||
first = mcp_canvas_edit.add_nodes(ctx, use="picture", model=picked[0])
|
||||
node = int(first["nodes"]["model"])
|
||||
loras = _loras(box)
|
||||
for lora in loras:
|
||||
mcp_actions.add_lora(ctx, lora, after_node_id=node)
|
||||
answer = {"route": key, "built": first["nodes"], "next": NEXT if route.get("way") else ONE_PASS}
|
||||
if route.get("way"):
|
||||
use, want = route["way"]
|
||||
added = mcp_canvas_edit.add_nodes(ctx, use=use, want=want)
|
||||
answer["built"] = {**first["nodes"], **added["nodes"]}
|
||||
loader = int(next(added["nodes"][name] for name in route["loader"] if name in added["nodes"]))
|
||||
mcp_actions.set_model(ctx, loader, picked[1])
|
||||
if "second_model" in route["loader"]: # loaded bare; the Tile's own model copied the LoRAs already
|
||||
for lora in loras:
|
||||
mcp_actions.add_lora(ctx, lora, after_node_id=loader)
|
||||
if route.get("enlarge"):
|
||||
before, after = int(added["nodes"]["first"]), int(added["nodes"]["second"])
|
||||
mcp_canvas_edit.add_node(ctx, "LatentUpscaleBy", {"upscale_method": "nearest-exact", "scale_by": 1.5}, insert_after=before)
|
||||
mcp_canvas_edit.set_values(ctx, values={str(before): {"return_with_leftover_noise": "disable"}, str(after): {"add_noise": "enable"}})
|
||||
return answer
|
||||
@@ -0,0 +1,338 @@
|
||||
"""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_privacy, ai_sealed, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, mcp_apps, mcp_bridge, mcp_directory, mcp_fit, 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, object_info
|
||||
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, ToolError, 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
|
||||
# A private task's tokens become their texts for the tool only; the record keeps the tokens (ai_privacy.py).
|
||||
privacy_task = active_task or ai_tasks.active_task_id(client, session=session)
|
||||
private = await asyncio.to_thread(ai_privacy.applies, privacy_task)
|
||||
call_arguments = await asyncio.to_thread(ai_sealed.expand, privacy_task, arguments) # tokens from a prompt box work in any task
|
||||
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, call_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"])
|
||||
if name not in mcp_apps.APP_TOOLS:
|
||||
mcp_fit.fit(result, name) # read in the chat, not filed away unread; the user's news below stays whole
|
||||
# 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)
|
||||
# Last: a private task's pictures and prompts never leave, whatever put them in (ai_privacy.py).
|
||||
final_task = active_task or ai_tasks.active_task_id(client, session=session)
|
||||
if final_task != privacy_task and await asyncio.to_thread(ai_privacy.applies, final_task):
|
||||
private, privacy_task = True, final_task
|
||||
info = await asyncio.to_thread(_node_list, ctx) if private else None
|
||||
return await asyncio.to_thread(ai_privacy.guard, ai_outside.tell(result, outside), private, privacy_task, info, name)
|
||||
|
||||
|
||||
def _node_list(ctx):
|
||||
"""ComfyUI's node list, for telling which node inputs are text a private task keeps; {} when unreadable."""
|
||||
try:
|
||||
return object_info(ctx)
|
||||
except ToolError:
|
||||
return {}
|
||||
|
||||
|
||||
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:
|
||||
page = body.get("page") if isinstance(body.get("page"), str) and len(body["page"]) <= 64 else None
|
||||
granted = mcp_bridge.claim(body["id"], body.get("capabilities"), page)
|
||||
if granted == "later": # another page did the last action: ask again when it has had its turn
|
||||
return web.json_response({"granted": False, "retry_ms": int(mcp_bridge.HOLD_SECONDS * 1000) + 300})
|
||||
return web.json_response({"granted": granted})
|
||||
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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user