Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6331b25241 | ||
|
|
c8689d56e4 | ||
|
|
3ba16e874c | ||
|
|
42d8c7bbfc | ||
|
|
a4cbe72086 | ||
|
|
4e8603b4cc | ||
|
|
e1ac84f6d8 | ||
|
|
af3bb60981 | ||
|
|
e788306291 | ||
|
|
eabff31c1c | ||
|
|
c79b88d464 | ||
|
|
61b828c716 | ||
|
|
58e49cc635 | ||
|
|
585a745683 | ||
|
|
0fd50b1a18 | ||
|
|
e622850349 | ||
|
|
39e99db53d | ||
|
|
3768c8ec44 | ||
|
|
dbb645feb5 | ||
|
|
e0776891c8 | ||
|
|
1476fafd8a | ||
|
|
83bce45af6 | ||
|
|
09b06d1dc7 | ||
|
|
dd1bb8639c | ||
|
|
4e2110ad4b | ||
|
|
154c159bc0 | ||
|
|
882db0a81c | ||
|
|
5c0a207f06 | ||
|
|
d88c067183 | ||
|
|
7584c794e2 | ||
|
|
04b2eeff8f | ||
|
|
2ff5e28ac0 | ||
|
|
0d1e25ade3 | ||
|
|
6411e591ca | ||
|
|
23cb24c6fd | ||
|
|
596750bd1a | ||
|
|
3e6b6e09a5 | ||
|
|
bea49f08d3 | ||
|
|
63769026fb | ||
|
|
579a8be4d3 | ||
|
|
950990a19c | ||
|
|
e4372720f8 | ||
|
|
8cdff4833a | ||
|
|
fc62523193 | ||
|
|
3018c17fc7 | ||
|
|
25f7a10ba3 | ||
|
|
f460ae916f | ||
|
|
024d0a0abd | ||
|
|
14014a952c | ||
|
|
96fba93e80 | ||
|
|
af162ef953 | ||
|
|
79646b6d94 | ||
|
|
0553f920be | ||
|
|
35714084be | ||
|
|
1bf4946260 | ||
|
|
b5d534df6b | ||
|
|
f05c592c46 | ||
|
|
b6acd09a1f | ||
|
|
acbf0e39ae | ||
|
|
66fa0ae4a8 | ||
|
|
2c5b883187 | ||
|
|
5c99c90053 | ||
|
|
2c992c6ca5 | ||
|
|
2f1fd07f55 | ||
|
|
1b2013fe69 | ||
|
|
200e3823d1 | ||
|
|
e5cba40374 | ||
|
|
24aea49f3b | ||
|
|
ad71dfdf71 | ||
|
|
44d2c824bc | ||
|
|
4463f2406b | ||
|
|
a63a615514 | ||
|
|
a0c6849878 | ||
|
|
9cebdbd994 | ||
|
|
6ad975c1cd | ||
|
|
e8b50ecaf5 | ||
|
|
7c167444db | ||
|
|
7afa7518bc | ||
|
|
2951d610c5 | ||
|
|
3e3e26d2b2 | ||
|
|
12875f9053 | ||
|
|
3ad1eaacc5 | ||
|
|
e6217bb557 | ||
|
|
e75beafd70 | ||
|
|
7f7e3cef71 | ||
|
|
e5f3301971 | ||
|
|
881570e887 | ||
|
|
56774a2d99 | ||
|
|
413b876dfe | ||
|
|
2978025cbe | ||
|
|
a984ee4a31 | ||
|
|
67d5be6277 | ||
|
|
d9ce967de7 | ||
|
|
d8691d8811 | ||
|
|
aed9501b34 | ||
|
|
1e42033ac0 | ||
|
|
2a53e3edf5 | ||
|
|
c39701773a | ||
|
|
29b14d2c71 | ||
|
|
52259acd09 | ||
|
|
e588392e14 | ||
|
|
6d25e4aa74 | ||
|
|
d596af293c | ||
|
|
f84379c0ae | ||
|
|
be96077b3c | ||
|
|
174c82dcc5 | ||
|
|
143f076751 | ||
|
|
a165921889 | ||
|
|
9e5cd77148 | ||
|
|
05c6bd95ec | ||
|
|
35f02a1122 | ||
|
|
72212f1e6a | ||
|
|
cd1ea0ee2e | ||
|
|
0d8d163081 | ||
|
|
e78b46ba36 | ||
|
|
9b4b87a879 | ||
|
|
e370bedf02 | ||
|
|
0fe1827f3f | ||
|
|
3f71ddfcb7 | ||
|
|
77ae7ce888 | ||
|
|
705cdf0a7f | ||
|
|
476c34e79d | ||
|
|
fe27e7f2e3 | ||
|
|
276ad766a6 | ||
|
|
0f592dc77f | ||
|
|
e052a7ae39 | ||
|
|
bf2764239f | ||
|
|
b37204fd68 | ||
|
|
8bd3130fe7 | ||
|
|
45958356f1 | ||
|
|
02f0230c08 | ||
|
|
d664826093 | ||
|
|
a4b34100de | ||
|
|
43c994cc34 | ||
|
|
fd16be87ce | ||
|
|
109565fa00 | ||
|
|
5ecdef15e2 | ||
|
|
8c0b65385c | ||
|
|
04182578dc | ||
|
|
de02f5dd81 | ||
|
|
6dd7f94c0e | ||
|
|
5ce15dfe30 | ||
|
|
9f653a30c3 | ||
|
|
a9c4e3d961 | ||
|
|
96c340b79b | ||
|
|
e1f8bf39d9 | ||
|
|
3df69ba7f4 | ||
|
|
fafad3de75 | ||
|
|
4e6e1050b4 | ||
|
|
46a67c7760 | ||
|
|
abc34f3f8b | ||
|
|
1294e30e3c | ||
|
|
2b0393723d | ||
|
|
db09ef5dcc | ||
|
|
dfa208b44c | ||
|
|
ad14a596e5 | ||
|
|
f7e3348e3d | ||
|
|
0a0f2f6bcf | ||
|
|
e568e94fac | ||
|
|
fcb1d84e7f | ||
|
|
c2f04e3cc9 | ||
|
|
f1edb2163e | ||
|
|
3142dace09 | ||
|
|
770822a5b6 | ||
|
|
8ffa222128 | ||
|
|
44aa09e67c | ||
|
|
3798d9f9b4 | ||
|
|
bd4abead45 | ||
|
|
31d8b3737c | ||
|
|
6eaf4fce05 | ||
|
|
8464b7c983 | ||
|
|
f3985005a4 | ||
|
|
1b985baf29 | ||
|
|
852d9e19ed | ||
|
|
93bb46cef9 | ||
|
|
896cd3d20d | ||
|
|
731cf89101 | ||
|
|
ffa6f56d08 | ||
|
|
a36c8dfc50 | ||
|
|
4b283565f6 | ||
|
|
ea64877bb9 | ||
|
|
79ac441585 | ||
|
|
38c75f3fc0 | ||
|
|
fc40dead3d | ||
|
|
b72a7a5a5f | ||
|
|
aa3b49a3ee | ||
|
|
0628183c74 | ||
|
|
8a9a91b3d2 | ||
|
|
2370ad86b4 | ||
|
|
2c7a79885d | ||
|
|
1c2df86a90 | ||
|
|
1213a5e825 | ||
|
|
ae7a5bd1c8 | ||
|
|
462f8287d3 | ||
|
|
e2d4387de7 | ||
|
|
9f307b06d4 | ||
|
|
828c4d5488 | ||
|
|
888330de6a | ||
|
|
200e6e4d6b | ||
|
|
07c1bbe89c | ||
|
|
af2e223dc1 | ||
|
|
d86032ff58 | ||
|
|
00c884b517 | ||
|
|
dac700337a | ||
|
|
b4b86eb572 | ||
|
|
0f24498704 | ||
|
|
ea8284b482 | ||
|
|
39a7d7094a | ||
|
|
2c51befe66 | ||
|
|
975863bf0f | ||
|
|
f0079ad457 | ||
|
|
ed3f1774b3 | ||
|
|
9e2bdbe8cb | ||
|
|
8f8063615d | ||
|
|
393c4e9ade | ||
|
|
01dfa18d12 | ||
|
|
f8d159dc11 | ||
|
|
a7104fa507 | ||
|
|
346860ff00 | ||
|
|
ef9c8b4519 | ||
|
|
a6d2705b9d | ||
|
|
b6adb0a0bc | ||
|
|
585b751c57 | ||
|
|
6bdb1e072f | ||
|
|
f6311ff8f1 | ||
|
|
809c6124b7 | ||
|
|
3c29cc0142 | ||
|
|
57b480da79 | ||
|
|
43d7901d0d | ||
|
|
9b124333c0 | ||
|
|
628cacfbe9 | ||
|
|
35d6abfa48 | ||
|
|
e6088fc2a1 | ||
|
|
cbb6b9adb5 | ||
|
|
b357380fa1 | ||
|
|
b55a23b529 | ||
|
|
932555c448 | ||
|
|
54e8e1f6cf | ||
|
|
6f6cd6a4fc | ||
|
|
1fde428db1 | ||
|
|
81463e70a8 | ||
|
|
69afcb834f | ||
|
|
7c8876f08c | ||
|
|
61c0918498 | ||
|
|
780f60af35 | ||
|
|
b106111430 | ||
|
|
6eec3b82c3 | ||
|
|
039d982316 | ||
|
|
7c94cb9fea | ||
|
|
4b828e8cb4 | ||
|
|
e7719a76e6 | ||
|
|
cd813c778a | ||
|
|
cbd18181fc | ||
|
|
ec308da9e9 | ||
|
|
141b5f8271 | ||
|
|
6c6be650c0 | ||
|
|
a8db942e43 | ||
|
|
838904900e | ||
|
|
5a5552286e | ||
|
|
ca730fb08d | ||
|
|
c4e9c2eda2 | ||
|
|
0ada2aab7d | ||
|
|
f56d337ea0 | ||
|
|
b38770cfcc | ||
|
|
ec65e6f0c8 | ||
|
|
e4025cadf4 | ||
|
|
6e188d34e9 | ||
|
|
cfdf892b7b | ||
|
|
d29103e70c | ||
|
|
d2575cd0a0 |
@@ -24,6 +24,10 @@ temp/
|
||||
*.pid
|
||||
*.tmp
|
||||
*.temp
|
||||
*.scan_in_progress
|
||||
*.scan_progress.json
|
||||
.global_scan_in_progress
|
||||
.global_scan_progress.json
|
||||
*.bak
|
||||
*.backup
|
||||
*.sqlite
|
||||
@@ -52,7 +56,13 @@ api/notebooks/
|
||||
# AI Dev Scripts & Temporary text dumps
|
||||
scripts/
|
||||
doctor_code.txt
|
||||
docs/
|
||||
docs/*
|
||||
!docs/architecture/
|
||||
!docs/architecture/*.md
|
||||
!docs/decisions/
|
||||
!docs/decisions/*.md
|
||||
!docs/audits/
|
||||
!docs/audits/*.md
|
||||
AI_SYSTEM_TEMPLATE.md
|
||||
|
||||
# AI Agents Directory
|
||||
@@ -66,3 +76,4 @@ tests/
|
||||
# Local diagnostic/test artifacts
|
||||
grep_doctor.txt
|
||||
test_folders.json
|
||||
*.bat
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# 开发与维护规范
|
||||
|
||||
适用于本插件的所有修改,包括 Gemini、Codex 和人工维护。目标是让 UX 持续改善,同时让代码职责、状态来源和验证方式保持清楚。
|
||||
用户明确的当前要求优先;确需偏离本规范时,采用最小例外,在交付说明中写明原因、影响和验证。常规实现选择自行处理,不因本规范增加无必要的确认步骤。
|
||||
|
||||
## 1. 修改前先定位责任
|
||||
|
||||
1. 查看 `git status` 和相关差异,保留已有工作;不覆盖、回退或提交他人的无关修改。
|
||||
2. 阅读 [ARCHITECTURE.md](ARCHITECTURE.md),再按阅读地图打开与任务有关的专题,避免每次加载全部文档。
|
||||
3. 搜索目标函数的调用方、相关状态、CSS 选择器及已有辅助函数,再确定修改位置。
|
||||
4. 简短说明本次行为变化和负责它的模块。一个修复优先落在能解释问题的最小责任范围内。
|
||||
|
||||
UX 请求默认允许调整布局、交互和文案,不代表允许改变文件存储位置、接口协议、工作流语义、模型识别依据或用户数据。
|
||||
|
||||
### 小型 UX 修改快速流程
|
||||
|
||||
仅调整文案、图标、颜色、间距或局部交互时:查看相关差异 → 定位负责规则 → 最小修改 → 验证受影响界面 → 本地提交。
|
||||
同一任务已读过且未变化的规范不反复通读;只读相关专题,不做全仓审查,不顺手重构。
|
||||
按第 7 节选择相关检查,不默认运行全部 JS/Python 测试。检查通过后,除非又改了代码或出现新问题,不重复运行。
|
||||
没有改变职责、接口或持久化契约时,不更新架构文档或新增经验记录。提交说明由 AI 根据实际差异编写,无需用户代写。
|
||||
|
||||
### 任务中断后的接续与快照
|
||||
|
||||
- 开工先检查状态和差异。工作区干净时直接继续,不制造空提交。
|
||||
- 上次因额度或其他原因中断,存在属于该任务的改动时,先确认文件范围,再选择性暂存这些文件;不得使用无差别的 `git add .` 收入用户数据、凭据、临时文件或无关工作。
|
||||
- 已完成且有验证记录的改动可补正式提交;尚未验证或未完成的改动可先保存 `chore(checkpoint): <实际改动摘要>` 恢复快照,提交正文明确“未完成/验证待完成”、已做检查和下一步。快照是恢复点,不代表验收通过。
|
||||
- 后续修改和验证另作正式提交,不自动 amend、reset 或改写快照历史。若无法区分遗留文件归属,保留原状并说明,不猜测打包。
|
||||
- 长任务按可独立验证的完整阶段提交,避免把所有 Git 收尾留到最后。测试范围内检查完成后及时提交,不为额外润色拖延快照。
|
||||
- 上述快照和正式提交均只保存在本地,未经明确授权不推送。
|
||||
|
||||
## 2. 模块职责与规模
|
||||
|
||||
- `web/main.js` 负责注册和接线;`ui_sidebar.js` 的 `createDOM` 负责外壳装配。新工具的完整实现放在自己的模块,不继续塞进这两个入口。
|
||||
- 界面模块负责交互和渲染;组合、转换、校验等纯逻辑放在对应领域模块。底层模块不能反向导入上层界面,也不能读取 DOM 或选中节点来暗中决定业务行为。
|
||||
- 一个模块应有可用一句话解释的主要职责。按“谁拥有状态、谁负责关闭、谁发起请求”拆分,不按随意的行号切割。
|
||||
- 复用现有 `ui_lifecycle.js`、`ui_dom.js`、`translation_service.js`、`node_material_actions.js` 等接口。不要为同一行为再复制一套实现。
|
||||
- 通用文件不能成为杂物箱。不要为了省一个文件,把新领域逻辑塞进 `utils`、素材详情渲染器或浏览器主对象。
|
||||
- 不引入没有当前调用方的管理器、适配层、配置项、事件总线或依赖。一次性的简单逻辑可以留在其责任模块内。
|
||||
|
||||
**规模审查阈值:** 普通逻辑文件超过约 600 行,或单函数超过约 100 行时,新增功能前必须检查能否提取独立职责。继续扩展时,在交付说明中解释为什么仍属于同一职责。已有超大模块的小修复不要求顺手重写全文件;语言目录、数据表和样式表不按这个行数阈值拆分。不能通过空壳包装、压缩代码或碎片化文件绕过审查。
|
||||
|
||||
## 3. 状态、关闭与异步
|
||||
|
||||
- 每份可变状态只有一个权威来源。预览、计数、序列化结果从它派生,不维护几份会互相覆盖的数据。
|
||||
- 子视图状态默认留在子视图。跨界面确需保留的状态才放到 `owner`;新增共享字段时说明拥有者、初始化、重置和释放时机。
|
||||
- 父子视图传容器、必要数据和明确回调,不让子模块随意修改父视图的私有 DOM 和字段。
|
||||
- 新建临时窗口优先使用 `createViewScope()`。按钮、Esc、遮罩、替换窗口、父窗口关闭必须走同一个清理入口;重复关闭应安全。
|
||||
- 视图创建的全局事件、观察器、定时任务、拖拽监听和临时预览都要有对应释放路径。禁止仅 `element.remove()` 就当作关闭完成。
|
||||
- 拖拽中关闭也必须释放鼠标监听,恢复此前的 cursor 和 user-select。子窗口消费 Esc 时,不能顺带关闭父窗口。
|
||||
- 视图读取请求使用其生命周期的 `AbortSignal`;重新查询时取消或隔离旧请求。请求返回后检查当前视图和请求身份,不能让旧结果覆盖新内容。
|
||||
- 异步写入画布前捕获原 graph/node/widget/value,返回后复核身份及输入;不能在返回后重新找“当前选中节点”然后直接写。
|
||||
- 已发出的保存可能在窗口关闭后完成。不要假称取消了服务端写入,也不要让完成回调重新打开或修改已关闭的界面。
|
||||
|
||||
提示词工坊的具体状态约定见 [frontend.md](docs/architecture/frontend.md)。不要恢复已删除的嵌入式工坊、旧导入抽屉或第二套草稿来源。
|
||||
|
||||
## 4. CSS 和 UX 迭代
|
||||
|
||||
- 改样式前搜索同名选择器及主题、响应式覆盖,修改实际负责该属性的规则。禁止每轮都在文件末尾追加“最终修复 / override v2”覆盖包。
|
||||
- 变更后删除本次取代的重复声明、失效选择器和旧布局分支;不能仅依靠更高 specificity 让旧实现暂时失效。
|
||||
- 优先使用现有 `--amb-*` 设计变量。新增变量必须有实际用途和清楚的归属,不为每个常量包装一层变量。
|
||||
- 不新增无解释的 `!important`。确因宿主样式或既有层叠边界需要时,在附近注释说明冲突对象与原因,并验证普通模式和受影响主题。
|
||||
- 静态布局和外观放在 CSS。行内样式用于实际运行时值,例如拖动宽度、定位和数据决定的颜色,不用 JavaScript 重写整套固定样式。
|
||||
- 保持插件选择器作用域,避免影响 ComfyUI 的其他按钮、弹窗和输入框。
|
||||
- 添加功能时检查窄宽度、长中文、空列表、加载中、失败和禁用状态,以及焦点、键盘访问和嵌套窗口层级。
|
||||
- 不为了视觉调整改变按钮的写入、覆盖、删除含义。成功提示必须来自真实成功结果,失败和取消不能显示“已应用”。
|
||||
|
||||
## 5. 文案、数据与文件
|
||||
|
||||
- 新增固定界面文案放入 `locales.js`,同步中英文;不继续增加散落的 `lang === 'zh' ? ... : ...` 分支。
|
||||
- 用户输入、文件名、模型名和元数据通过 `textContent` 或已验证的转义工具进入 DOM;不要拼进未转义的 `innerHTML`。
|
||||
- 列表摘要不能当作详情。正文通过对应详情接口读取;分页、去重、空结果和取消语义必须明确。
|
||||
- 路径与文件名在后端使用现有边界校验;不能只检查 `..`。前端校验不能替代后端校验。
|
||||
- 修改模型、封面、附属文件或导入包前先完整校验和暂存。失败时保留旧数据,避免先删除旧文件再尝试保存新文件。
|
||||
- 修改多文件发布流程要覆盖中途失败和冲突。单个 JSON 的原子写入不能冒充整组文件的事务。
|
||||
- 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
|
||||
- UI 适配不能偷偷迁移用户数据、修改全局配置或改变现有路由与存储格式。确需改协议时同步调用方、兼容策略和测试。
|
||||
|
||||
## 6. 清理旧实现
|
||||
|
||||
- 替换一条路径时搜索它的导入、导出、调用、字段、事件和选择器;确认无用后一起移除。
|
||||
- 不保留注释掉的大段旧代码、`EXTRACTED` 占位、无人使用的备选实现或“以后可能需要”的分支。
|
||||
- 拆模块后检查移动函数引用的闭包变量、导入、回调参数和返回值。语法通过不代表按钮点击不会出现未定义变量。
|
||||
- 不保留两个相互覆盖的 `onclick` 或两条完成同一请求的路径;让所有入口调用同一个实现。
|
||||
- 不用空 `catch`、伪造成功值或任意 optional chaining 掩盖结构错误。可选宿主能力才允许局部降级,并给出准确结果。
|
||||
|
||||
## 7. 按风险验证
|
||||
|
||||
从插件根目录运行相关检查。选择与变化相称的验证,不为纯文案、文档或低风险样式改动制造镜像测试。
|
||||
|
||||
| 修改范围 | 至少验证 |
|
||||
| --- | --- |
|
||||
| 导入、导出、模块拆分 | 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` |
|
||||
| CSS、布局、交互 | 实际浏览器验证受影响状态;涉及宿主行为时验证 ComfyUI |
|
||||
| 文件修改、导入、存储 | 对应 Python 测试,至少覆盖成功、冲突和中途失败保留旧数据 |
|
||||
| 仅文档 | 检查链接、命令、实际代码一致性和 Git 差异 |
|
||||
|
||||
- 模拟测试尽量加载实际模块,只替换外部系统边界;不要把待验证的业务函数也 mock 成成功。
|
||||
- 修复故障时,优先补充能复现原故障的行为测试。不能只通过源码字符串“存在某段代码”来证明修复。
|
||||
- UX 明确替换旧交互时,更新测试到新的用户流程,并保留原本要保护的行为;不要仅删除失败断言或重新塞回废弃功能。
|
||||
- 新增应随仓库交付的回归测试要检查 `.gitignore`,确认确实进入版本控制。
|
||||
- 历史本地 Python 测试可能不在 Git 中。使用合适环境运行相关测试,例如 `python -m unittest discover -s tests -p 'test_*.py'`,核对实际发现的数量;零项测试不算通过。
|
||||
- 无法运行真实宿主、网络服务或完整测试时,准确说明缺口;不要将模拟画布、语法检查或已有通过数量称为本轮宿主验证。
|
||||
|
||||
## 8. 文档与交付
|
||||
|
||||
- 架构文档只记录职责、数据流、契约和关键约束。颜色、像素值及审美描述以 CSS 为准,不每次 UX 更新都往架构入口追加长篇设计说明。
|
||||
- 改动架构时更新最窄的相关专题。修改规范时以本文件为唯一正文,`GEMINI.md` 只保留阅读入口,避免两套要求漂移。
|
||||
- 完成前审查差异、死代码、临时文件和空白错误;保留现有文件编码及换行风格。
|
||||
- 验证后按仓库约定形成一个连贯的本地提交,只包含本任务文件;没有明确授权不推送 GitHub。
|
||||
- 交付说明包含:改了什么与原因、验证结果与限制、剩余问题、本地提交。仅在触发规模阈值或规则例外时额外解释取舍。
|
||||
|
||||
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?测试验证了实际行为吗?
|
||||
@@ -1,353 +1,265 @@
|
||||
# Anomalous Model Browser - Architecture Summary (架构指南)
|
||||
# Anomalous Model Browser Architecture
|
||||
|
||||
This document provides a high-level overview of the Anomalous Model Browser plugin for ComfyUI. It is designed to quickly onboard new AI agents or developers to the project's structure, design philosophy, and critical subsystems.
|
||||
This is the short entry point for maintainers and AI agents. It describes the
|
||||
system map, ownership boundaries, and rules that apply across subsystems. Read
|
||||
only the linked topic document relevant to the task; do not load every document
|
||||
by default.
|
||||
|
||||
Workspace lifecycle note: reopening an already initialized Workspace must restore the visible notebook body and reset the hidden recipe body before refreshing data. Hiding the overlay alone is not enough because the next open reuses the existing DOM tree.
|
||||
## Reading map
|
||||
|
||||
The floating trigger follows the browser modal lifecycle: opening the main browser hides the trigger, while every normal close path restores it. The hidden state uses a scoped class with pointer-events disabled; initialization errors keep the trigger available as the recovery entry point.
|
||||
Before implementation, follow [AGENTS.md](AGENTS.md), the canonical development
|
||||
and maintenance rules. [GEMINI.md](GEMINI.md) is the Gemini reading entry point.
|
||||
|
||||
Origin refresh is an explicit enrichment mode passed into `_enrich_recipe`; it must not mutate the Python list of references with ad-hoc state. Origin refresh writes schema v5, while normal updates preserve previously imported identity/origin records when the current machine cannot re-resolve them.
|
||||
The prompt studio reads material prompt payloads through `web/modules/material_prompt_data.js`;
|
||||
list summaries do not contain prompt bodies. See the Material Library contract below.
|
||||
|
||||
Recipe identity badges use short, bounded labels in the card layout; detailed explanations belong in the help tooltip. Identity badges and model-name rows must remain shrinkable flex children so localized or imported text cannot expand the recipe card beyond its container.
|
||||
| When changing... | Read... |
|
||||
| --- | --- |
|
||||
| Python routes, storage, paths, metadata, covers, or scan state | [`docs/architecture/backend.md`](docs/architecture/backend.md) |
|
||||
| Browser lifecycle, UI state, localization, media, or graph edits | [`docs/architecture/frontend.md`](docs/architecture/frontend.md) |
|
||||
| Update-guide content/versioning or sidebar hover labels | [`docs/architecture/update-guide.md`](docs/architecture/update-guide.md) |
|
||||
| Workflow Recipes, packages, galleries, Parameter Notebooks, or prompt roles | [`docs/architecture/recipes.md`](docs/architecture/recipes.md) |
|
||||
| Material Library snapshots, image parameter details, or reusable node blocks | [`docs/architecture/material-library.md`](docs/architecture/material-library.md) |
|
||||
| Model Doctor, provenance hashes, missing-model recovery, or deep scanning | [`docs/architecture/model-resolution.md`](docs/architecture/model-resolution.md) |
|
||||
| Browser audits, E2E functional bug reports, or verification sign-offs | [`docs/audits/README.md`](docs/audits/README.md) |
|
||||
| Why a current product boundary exists | [`docs/decisions/README.md`](docs/decisions/README.md) |
|
||||
| Recurring implementation mistakes and post-mortems | [`.agents/logs/ai_lessons.md`](.agents/logs/ai_lessons.md) |
|
||||
|
||||
Recipe common-parameter summaries use a readable minimum-width responsive grid and an explicit label/value row; labels stay horizontal, while long summary values use a single-line ellipsis with copy preserving the full value. Full expansion belongs to detailed parameter rows, not to narrow summary cards.
|
||||
`README.md` is user-facing documentation and `CHANGELOG.md` is user-facing
|
||||
release history. Neither is the source of truth for internal architecture.
|
||||
|
||||
Availability refresh actions expose immediate busy feedback, an accessible `aria-busy` state, and a recoverable error state; a disabled button without visible progress is not considered sufficient interaction feedback.
|
||||
## System shape
|
||||
|
||||
Identity help uses a click-to-expand inline explanation attached to the badge. The explanation stays in normal card flow with bounded width and wrapping, so it cannot be duplicated by a native `title` tooltip or cover adjacent flex content.
|
||||
|
||||
Recipe origin editing has two supported update paths: complete recipe updates for metadata/workflow edits, and a refresh-only request containing `filename` plus `refreshIdentities`. The latter must clone the stored recipe before enrichment so the refresh action cannot be rejected by complete-recipe validation or accidentally discard the saved workflow.
|
||||
|
||||
Origin edit dialogs locate a saved model reference by stable provenance fields (`node_id`, `widget_index`, category, and saved value), not by display-only fields such as `type`. Localized action labels must use keys that exist in the shared locale catalog; a missing key must never make a visible dialog action appear inert.
|
||||
|
||||
Recipe import matching is a separate, explicit local-recovery action. The recipe detail entry point sends all unresolved references to the hash-resolution batch endpoint, constrained by the reference's model category; the saved author path and filename are never identity evidence. A found local candidate is presentation-only until the user clicks `Apply match`, which updates the corresponding workflow widget through the full-recipe update contract and archives the prior recipe. Exact saved-path availability checks remain a separate lightweight preview operation.
|
||||
|
||||
## Current recipe interaction correction (2026-08-03)
|
||||
|
||||
The recipe detail view keeps Overview, Parameters, and Versions tabs. The Overview contains the model composition blocks; each resolved model name or preview is an explicit entry into the browser model detail view. Model previews are demand-loaded when Overview opens, and returning from model detail restores the recipe detail view. The model browser stores the active grid folder and scroll coordinates before opening detail and restores them when returning to the grid. List-card Append actions await the shared canvas transaction before reporting success or restoring the button state. Export confirmation uses three states (cancel, exclude, include); cancellation must stop the export flow rather than silently becoming an exclusion.
|
||||
|
||||
The Workspace close action is a state restoration boundary. Opening the Workspace records which main-browser panel was visible; closing it hides recipe/notebook content and restores that panel, falling back to the model grid when the previous state is unavailable. All Workspace close affordances use the same cleanup function.
|
||||
|
||||
## Recipe result gallery (2026-08-04)
|
||||
|
||||
Recipe detail has an on-demand Gallery tab for reusing the user's own generated results. Gallery discovery uses a separate `sha256-node-types-v1` signature: it compares the sorted node class composition and count, deliberately ignoring seeds, prompts, model values, and other parameters so ordinary variations still appear. The original `sha256-structural-v1` fingerprint remains the recipe/history integrity fingerprint. Opening a detail view scans at most the newest 200 PNG files below ComfyUI's output directory and reads only bounded embedded `workflow` or API `prompt` metadata; there is no background polling or persistent output index. Clicking a result loads bounded node parameters and shows recipe-versus-image differences; the match itself remains node-only. The main output Gallery refreshes on each open and has no persistent toolbar refresh strip. A chosen result becomes a recipe-owned, compressed WebP cover in `.assets/<recipe-stem>/`, so it survives output cleanup and follows the recipe during package export/import. The original output image remains a local convenience link, never package data or identity evidence.
|
||||
|
||||
## Recipe Parameters & Parameter Notebooks (2026-08-04)
|
||||
|
||||
The "Parameter Notebook" system has been fully integrated into the Workflow Recipe detail view, replacing the static parameters presentation. Rather than existing as a disjoint global list in the sidebar, Parameter Notebooks are now intrinsically linked to their parent Workflow Recipe.
|
||||
|
||||
When a user saves or updates a recipe, the system automatically creates a new, immutable Parameter Notebook capturing the exact generation values of that moment, and associates it with the recipe (`recipe_filename`). In the Recipe Detail view, the "Parameters" tab is now a two-pane interface: a sidebar listing the history of Parameter Notebooks for that recipe, and a main viewer showing the parameters and matching `sha256-params-v1` gallery for the selected notebook. The global sidebar entry for Parameter Notebooks has been removed, solidifying recipes as the primary structural template and parameter notebooks as their concrete instantiation history.
|
||||
|
||||
The Parameters tab keeps runtime-volatile values such as sampler seeds visible by name but redacts their values from summaries, copies, editing, and parameter matching; the raw serialized workflow retains the slot only for ComfyUI compatibility. Common values use readable horizontal label/value cards and preserve full text instead of silently truncating it, and complex arrays like LoRAs are formatted into clean multi-line bulleted lists. The right-hand viewer identifies the active notebook by name and timestamp, and a notebook switch uses a staggered waterfall transition animation to provide clear visual feedback; stale parameter-gallery responses cannot overwrite the newly selected notebook. “New parameter note” clones the selected recipe/snapshot into an editable draft and saves a new immutable snapshot; it never edits or overwrites historical notes.
|
||||
|
||||
Parameter Notebook actions include “Read current and create” and “Apply to current workflow”. Both use the same skeleton preflight: every saved node must have a matching local node by type/shape (with stable-ID/title preference), while extra local nodes are allowed. Read-current refuses to create a draft when the recipe skeleton is incomplete. Apply uses serialized nodes only for matching, then resolves each match back to the live ComfyUI node before reading `widgets` or invoking callbacks; serialized workflow records must never be treated as runtime widget objects. It validates all widget slots before mutation, skips volatile values, writes widget values inside `beforeChange`/`afterChange`, and calls `onWidgetChanged(index, value, previousValue, liveWidget)` so current ComfyUI error-clearing hooks receive the widget object they require. It marks the canvas dirty and restores prior widget values if a callback fails.
|
||||
|
||||
## Node Assistant & Parameter Notebook Integration (2026-08-06)
|
||||
|
||||
The Parameter Notebook system has been extended to act as a contextual preset library within the Node Assistant (`ui_doctor.js`). When a user selects a specific node (e.g., `KSampler`), the Node Assistant automatically queries the backend for all historical Parameter Notebooks across all recipes, filtering specifically for saved nodes that match the selected `type`.
|
||||
|
||||
The backend (`api/parameters.py`) implements a lightweight in-memory cache for notebook parsing to ensure instantaneous UI updates without heavy disk I/O, which can be bypassed via `?refresh=1` for immediate UI synchronization. The filtered presets are rendered hierarchically (Recipe -> Notebook -> Parameter Summary). Applying a preset invokes `applyLocalNodeParameters`, which performs a targeted, single-node widget replacement while preserving the rest of the canvas and safely triggering all required ComfyUI widget callbacks.
|
||||
|
||||
### Topological Prompt Tracing and Dynamic Role Injection
|
||||
Prompt tagging (Positive vs Negative) leverages a robust Topological Backward-Tracing engine (`recipe_parser.js`) rather than fragile regex matching on node titles. During recipe save, the engine starts from known conditioning ports on samplers (including `positive`, `negative`, `conditioning`, `cond`, `uncond`, `model_cond`, etc.) and traces backward through the graph to explicitly tag upstream `CLIPTextEncode` nodes.
|
||||
These topological `role` properties are baked into the recipe's `metadata.nodes`. To seamlessly bridge this structural knowledge to the Node Assistant, `api/parameters.py` performs Dynamic Role Injection—reading the bounded recipe's metadata by `node_id` and injecting the `role` back into the Parameter Notebook response. This ensures the UI accurately displays `[🟢 正面]` or `[🔴 负面]` prefixes without guessing.
|
||||
|
||||
## 1. Project Philosophy (设计理念)
|
||||
* **Zero Frameworks**: No React, Vue, or build tools. Everything is Vanilla JS and CSS for maximum compatibility, minimum overhead, and zero compilation steps.
|
||||
* **Non-Intrusive Integration**: Operates as a floating Gemini-style popover (`#anomalous-container`) mounted via ComfyUI's standard UI extension system. It avoids altering the native ComfyUI canvas except when explicit interaction is required. The floating trigger is mounted before browser panels are initialized and retries initialization on click, so a panel regression or missing optional browser API cannot hide the plugin entry point.
|
||||
* **Strict DOM Obliteration**: Rather than caching complex DOM structures (like `display: none` for large grids), the UI strictly employs `innerHTML = ''` when navigating between folders. This enforces immediate Garbage Collection, crucial for performance when users have thousands of models.
|
||||
* **Offline-First Resilience**: Model metadata is parsed from local `.info` and `.civitai.info` files or extracted directly from `.safetensors` headers via Python `struct`. API calls to Civitai are explicit, user-initiated actions.
|
||||
|
||||
## 1.5 Internationalization and Licensing Boundaries (2026-08-06)
|
||||
|
||||
The extension is a UI-only ComfyUI integration: `NODE_CLASS_MAPPINGS` is intentionally empty, and the project does not provide or install custom nodes. Its runtime is split between Vanilla JS/CSS frontend modules and Python `aiohttp` routes hosted by ComfyUI. The repository currently has no bundled font, image, video, vendor library, dependency manifest, or license file; Civitai, optional DeepL/Google translation endpoints, ComfyUI host APIs, and Pillow/aiohttp are runtime/environment integrations rather than bundled project assets.
|
||||
|
||||
`web/modules/locales.js` is the canonical home for user-facing translation data. The current codebase still contains substantial inline `zh`/`en` branches across `main.js`, Sidebar, Detail, Gallery, Doctor, Notebook, Dialog, Grid, and the optional hash resolver. The first i18n migration snapshot now provides `supportedLocales`, `normalizeLocale`, `resolveLocale`, `formatTranslation`, `translate`, and `createTranslator`. `main.js` and `ui_dialog.js` use this contract; the remaining modules are intentionally unmigrated until their own reviewable snapshots. The refactor must preserve current Chinese and English behavior, keep model names/paths/node types/user data out of locale dictionaries, and keep translated HTML inside the existing `safe_dom.js` boundary. Missing translations must never prevent a panel or action from rendering. Language selection remains the existing zh/en toggle until a later phase introduces a registered locale selector.
|
||||
|
||||
Every i18n or licensing change is a local, reviewable snapshot. The implementation must update this document and `.agents/logs/ai_changelog.md` in the same change, run proportional syntax/runtime checks, and create a local Git commit. Remote pushes require explicit user authorization. The MIT license applies to project-owned code and documentation only; external service content and host-project dependencies retain their own terms.
|
||||
|
||||
The second migration snapshot extends the shared translation contract to `ui_grid.js` and `ui_gallery.js`. Gallery confirmation and cover-selection banners are assembled with DOM nodes and `textContent` rather than translated raw HTML; user-controlled model names remain text content. These modules resolve strings at render time through `translate`, so existing global language changes continue to be reflected without introducing a new locale selector.
|
||||
|
||||
The third migration snapshot extends the same contract to `ui_detail.js`, `ui_doctor.js`, and `ui_notebooks.js`. Detail editing, model selection, Doctor diagnostics, the Node Assistant, the model picker, and Notebook validation now resolve user-facing strings through `translate` with parameter interpolation for counts, paths, model names, and scan status. Dynamic model/path/node data remains outside the locale dictionaries; rich UI state is composed with DOM nodes where translated text replaced previously formatted status HTML.
|
||||
|
||||
The fourth migration snapshot applies the contract to `web/main.js` for preflight workflow import and workflow share-code export/import. These modal surfaces use shared keys for labels, status messages, and count interpolation; the remaining locale conditional in `translateText` only maps the active locale to the backend service's `zh-CN` or `en` code and is not a UI copy branch. `ui_sidebar.js` remains a separate pending snapshot because its scan wizard, settings hub, language toggle, and folder manager require their own key extraction review.
|
||||
|
||||
The fifth migration snapshot applies the contract to `web/modules/ui_sidebar.js`. Sidebar navigation, scan wizard cards, metadata/hash scan controls, model settings, language-toggle refreshes, layout controls, and folder management now resolve user-facing copy through `translate`. Wizard descriptions that intentionally contain trusted formatting remain dictionary-owned rich text; dynamic file names, counts, API errors, and folder data remain runtime values. Locale checks used only for the language CSS class and toggle direction remain functional state logic rather than inline UI copy.
|
||||
|
||||
Licensing and community-delivery work are intentionally deferred. The current local MIT `LICENSE` change is owned by the user's separate Gemini workflow and is not part of the i18n snapshots. Russian localization begins only after the contributor submits a PR. Remote pushes and Issue #9 synchronization remain disabled until the user explicitly authorizes them after other feature work is complete.
|
||||
|
||||
The sixth migration snapshot completes the Stage 2 UI i18n pass for `ui_recipes.js`, `ui_recipe_detail.js`, the supporting Recipe modules, and `web/hash_resolver.js`. Recipe modules now use the shared `translate` contract rather than importing the dictionary directly; the hash resolver uses the same contract for its manual repair summary with a dynamic repaired-count parameter. Recipe workflow/model data remains runtime data and is not added to locale dictionaries.
|
||||
|
||||
## 2. Directory Structure (核心文件结构)
|
||||
Anomalous Model Browser is a UI-only ComfyUI extension. It registers no custom
|
||||
nodes (`NODE_CLASS_MAPPINGS` is empty). ComfyUI loads the Vanilla JavaScript/CSS
|
||||
frontend from `web/`; the Python package registers `/anomalous/` `aiohttp`
|
||||
routes and performs local filesystem, metadata, recipe, and scan operations.
|
||||
|
||||
```text
|
||||
Anomalous_Model_Browser/
|
||||
├── .agents/ # Local, ignored AI handoff material
|
||||
│ ├── logs/
|
||||
│ │ ├── ai_changelog.md # Engineering snapshots, decisions, checks, and handoff notes
|
||||
│ │ └── ai_lessons.md # Curated experience summary and recurring pitfalls
|
||||
│ └── plans/ # Product/design proposals that are not runtime behavior
|
||||
├── __init__.py # ComfyUI Extension Entry Point. Registers nodes and sets WEB_DIRECTORY = "./web"
|
||||
├── api/ # Backend Python API (Modularized)
|
||||
│ ├── __init__.py # Appends all modular routes to server
|
||||
│ ├── config.py # Configuration and paths setup
|
||||
│ ├── metadata.py # Model metadata extraction and parsing
|
||||
│ ├── models.py # Core model listing and routing (API endpoints)
|
||||
│ ├── notebooks.py # Notebooks management logic (Prompt Notes)
|
||||
│ ├── parameters.py # Parameter Notebook CRUD and Gallery logic
|
||||
│ ├── recipes.py # Local Workflow Recipe CRUD and validation
|
||||
│ ├── recipe_packages.py # Bounded ZIP inspection, export, staging, and import commit
|
||||
│ ├── scanner.py # Scanning engine for hash resolution and caching
|
||||
│ └── utils.py # Shared backend utilities and safe path boundary helpers
|
||||
├── scraper.py # Async HTTP client logic & web scraping (Root Level)
|
||||
├── CHANGELOG.md # Version history
|
||||
├── web/
|
||||
│ ├── main.js # Frontend Vanilla JS Entry (Under 1000 lines, mounts modules to prototype)
|
||||
│ ├── hash_resolver.js # Frontend auto-fix engine: scans workflows for missing models
|
||||
│ ├── styles.css # Vanilla CSS with scoped classes (.anomalous-*)
|
||||
│ └── modules/ # Extracted UI logic modules
|
||||
│ ├── ui_sidebar.js # Sidebar and folder tree logic
|
||||
│ ├── ui_detail.js # Model detail panel
|
||||
│ ├── ui_doctor.js # Model Doctor and Assistant panel
|
||||
│ ├── ui_notebooks.js # Prompt Notes editor
|
||||
│ ├── ui_recipes.js # Workflow Recipe cards, search/filter, save dialog, and actions
|
||||
│ ├── recipe_parser.js # Read-only LiteGraph recipe metadata extraction
|
||||
│ ├── recipe_identity.js # Pure recipe model-reference and identity normalization
|
||||
│ ├── recipe_diff.js # Pure bounded semantic version comparison
|
||||
│ ├── recipe_actions.js # Transactional canvas-append actions
|
||||
│ ├── ui_recipe_detail.js # Demand-loaded recipe detail panel and tab rendering
|
||||
│ ├── ui_gallery.js # Fullscreen Gallery Viewer
|
||||
│ ├── ui_grid.js # Main grid and model loading
|
||||
│ ├── graph_splice.js # Transactional MODEL/CLIP chain insertion and rollback
|
||||
│ ├── locales.js # Dedicated multi-language dictionary (i18n)
|
||||
│ └── safe_dom.js # HTML escaping and allowlisted rich-text sanitization
|
||||
├── tests/ # Backend security and path-boundary regression tests
|
||||
├── docs/ # Supplemental documentation
|
||||
ComfyUI frontend
|
||||
web/main.js
|
||||
-> web/modules/* UI, graph integration, localization
|
||||
-> /anomalous/* JSON and bounded media requests
|
||||
|
||||
ComfyUI Python server
|
||||
__init__.py
|
||||
-> api/__init__.py route registration
|
||||
-> api/*.py storage and domain operations
|
||||
-> scraper.py explicit metadata/hash enrichment
|
||||
|
||||
User-owned data
|
||||
ComfyUI model folders models and sidecars
|
||||
user/.../anomalous_recipes workflow recipes and recipe assets
|
||||
user/.../anomalous_parameters immutable parameter snapshots
|
||||
user/.../anomalous_materials curated image/workflow material bundles
|
||||
user/.../anomalous_notebooks prompt notes, with legacy originals preserved
|
||||
```
|
||||
|
||||
## 3. Backend Architecture (Python API)
|
||||
Modularized into the `api/` package. Registered with ComfyUI's internal `aiohttp` server.
|
||||
All endpoints are prefixed with `/anomalous/`.
|
||||
The frontend and backend communicate through narrow JSON contracts. The
|
||||
frontend must not infer filesystem authority, and the backend must not depend on
|
||||
DOM or live LiteGraph state.
|
||||
|
||||
### Python Rules of Engagement:
|
||||
1. **Never block the event loop**: File I/O should ideally be offloaded or fast. Large files are handled via `struct` (reading only the first 64KB for metadata), avoiding full reads.
|
||||
2. **Handle missing dependencies gracefully**: Avoid 3rd-party pip requirements if possible.
|
||||
3. **Always Restart ComfyUI**: Any changes to the `api/` package **REQUIRE** a full ComfyUI server restart to take effect.
|
||||
4. **Never use hardcoded backslash strings**: When writing path manipulation code, use `os.sep` or `os.path.normpath()` instead of `replace('\\', '/')`. Editing tools may corrupt escaped backslashes silently.
|
||||
5. **Dynamic Folder Resolution (Config-Driven)**: Never hardcode folder types (like `checkpoints`, `loras`). Always use `api.utils.get_active_folder_types()` to enforce whitelist logic. Folders disabled by the user in `config.json` are completely skipped during `os.walk`, ensuring zero I/O overhead for hidden directories.
|
||||
6. **Strict Path Containment**: Every request path must go through `resolve_folder_subdir()`, `resolve_within()`, and `require_filename()` as appropriate. Checking only for `..` is forbidden: absolute Windows paths, UNC paths, alternate separators, and symlinks can otherwise escape the configured model/output/notebook directory. File-serving endpoints must also enforce an explicit media-extension allowlist.
|
||||
7. **Canonical Configuration**: Runtime UI settings and newly saved API keys live in `api/config.json`. `scraper.py` reads that file first and falls back to the legacy root `config.json` only for backward compatibility. API keys must never be persisted in browser `localStorage`.
|
||||
8. **Atomic Background State**: Set scan state or create the exclusive marker file before launching a worker thread/process. Folder scans use `.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep missing-model scans use `GLOBAL_SCAN_STATE`, exposed by `/anomalous/scan_missing_models_status`.
|
||||
9. **Private Development Tests**: The local `tests/` directory contains backend and frontend regression tests for maintainers only. It is intentionally ignored by `.gitignore` and must not be included in the installable/plugin Git distribution. Keep these files locally for validation; do not import them from runtime code or move them into the shipped extension bundle.
|
||||
10. **Workflow Recipes Are User Data**: Recipes live only in `ComfyUI/user/default/workflows/anomalous_recipes`, never in the extension repository. `GET /anomalous/recipes` returns card metadata only; the graph is fetched separately through `GET /anomalous/recipe_full`. Recipe writes are bounded, validate local-only data-image thumbnails and contained output-image references, validate graph node IDs/widget bounds/link endpoints before writing, and use atomic replacement. Successful save, update, and restore responses include a compact integrity receipt (node/link/group counts and the persisted workflow fingerprint). A bound output stores a compressed cover plus a local source reference; it never copies an arbitrary path supplied by the browser. Export packages strip machine-local `source_image` paths, include the union of current and selected-history snapshot assets, and preserve imported model identity records independently from transient local availability. Replacement import stages recipe/assets/history and restores the prior set if the commit fails. `recipe_packages.py` accepts only bounded ZIP packages with `manifest.json`, `recipe.json`, declared contained WebP assets, and optional bounded history. Import is inspect-then-commit, rejects traversal/symlinks/archive bombs/checksum failures, never installs code or dependencies, and stages before atomic rename.
|
||||
## Ownership map
|
||||
|
||||
### NON-NEGOTIABLE: Model Sidecar & Cover Lifecycle / 模型伴生文件与封面生命周期死规矩
|
||||
* `.civitai_bak.*` is a persistent restore source created from a real Civitai download. Setting a custom cover MUST modify only `.preview.*`; it MUST NOT delete, overwrite, or repurpose `.civitai_bak.*`.
|
||||
* Reset cover priority is fixed: restore `.civitai_bak.*` to `.preview.*`; otherwise remove `.preview.*` only when a bare original cover (`model.png`, etc.) can take over naturally. If the active `.preview.*` is the only image, preserve it and return a visible warning instead of causing irreversible cover loss. Reset does not silently redownload from the network.
|
||||
* Physical rename means **move/rename** every recognized sidecar, including `.civitai_bak.*`, to the new model stem. It never means deleting the Civitai backup. Model deletion may clean sidecars only after the selected main model was successfully deleted.
|
||||
* Main model extensions (`.safetensors`, `.ckpt`, `.pt`, `.bin`) are never sidecar suffixes. A cleanup operation MUST NOT delete a same-stem model with another extension. If such a sibling survives, stem-keyed sidecars are ambiguous and must be preserved for it.
|
||||
* Performance boundary: sidecar operations use centralized immutable suffix tuples and a constant number of exact-path checks. Do not use `os.walk`, directory-wide globbing, full-directory indexing, hashes, or network calls for rename/delete/reset. This keeps the operation independent of model-folder size.
|
||||
* Preserve the product-language distinction in documentation and UI: **delete cleans sidecars; rename migrates sidecars**. Never describe both operations as deleting or "taking away" the files.
|
||||
### Backend
|
||||
|
||||
### NON-NEGOTIABLE: Lossless Performance Boundaries / 无损性能优化死规矩
|
||||
* Metadata and embedded safetensors-header hashes may be cached only with a bounded cache whose key includes the real path and physical file signatures (`size`, `mtime_ns`, and `ctime_ns`) for the model and both metadata sidecars. Callers receive independent copies; cached mutable dictionaries/lists must never be exposed directly.
|
||||
* Preview URLs use the preview file's stable nanosecond modification time as their cache version. Never append `Date.now()` or another per-request random token during ordinary listing: doing so disables the browser cache and redownloads unchanged media. A changed cover must still produce a changed URL.
|
||||
* Folder listing must inventory a directory once with `os.scandir()` and preserve the established preview priority (`.preview.*` before bare media). Do not perform one `exists()` sequence per model when the directory inventory already contains the answer.
|
||||
* Recursive directory walks, metadata parsing, and other potentially large disk operations must run through `asyncio.to_thread()` rather than blocking ComfyUI's aiohttp event loop. This improves responsiveness without skipping files.
|
||||
* Large grids keep the complete ordered result set but create cards in bounded animation-frame chunks. Images use native lazy loading/async decoding; videos delay their source until near the viewport and retain the configured autoplay/hover behavior once activated. A new folder request must cancel the previous request and stale render generation.
|
||||
* Card-quality choice is deliberately user-facing but bounded to two understandable modes: `balanced` requests a derived longest-edge-512px WebP for static grid covers, while `original` serves the source cover. Detail pages continue to use the original in both modes. Derived thumbnails live only in ComfyUI's temporary area, are keyed by the source real path plus physical signatures, and are capped at 256 MiB with oldest-accessed eviction. They MUST NOT modify, replace, rename, or sit beside the user's source cover; unsupported/animated images and any generation failure fall back to the original.
|
||||
* Closing the browser immediately aborts listing work, disconnects observers, pauses all media, and releases grid video/audio sources. Lightweight card DOM may remain warm for 90 seconds for a smooth reopen, after which cards and retained model payloads are released automatically. Folder replacement must stop old media before removing DOM nodes. Non-grid panels retain their established UI state, so their paused media is rebuilt only by the panel's existing lifecycle rather than being left visibly broken on reopen.
|
||||
* Model Doctor batch resolution is an I/O optimization only. Provenance-rich workflows skip the redundant full filename-to-hash cache refresh; legacy workflows missing injected provenance still refresh it for compatibility. Requests are grouped by the exact required model-type tuple so each group is scanned once, while every item still uses the same cryptographic hash, exact byte-size, category constraint, conflict rejection, and ambiguity rules as `/anomalous/resolve_hash`. Batch failure must fall back to the single-item endpoint; batching must never introduce filename/path evidence.
|
||||
* Preview resolution should try contained exact relative paths first and walk the library only for unresolved basename fallbacks. Exact-path lookup here locates a preview for a model value already supplied by ComfyUI; it is not Model Doctor identity discovery.
|
||||
- `api/config.py` owns configured paths and active model-folder types.
|
||||
- `api/path_utils.py` owns containment, filename validation, atomic JSON writes, and resolving
|
||||
a saved model value inside the model folders;
|
||||
`api/utils.py` is a compatibility export surface.
|
||||
- `api/metadata.py` owns sidecar and safetensors metadata extraction.
|
||||
- `api/model_catalog.py`, `api/model_resolution.py`, `api/model_metadata.py`, and
|
||||
`api/model_media.py` own model listing, identity recovery, mutation, and covers;
|
||||
`api/models.py` is a compatibility facade.
|
||||
- `api/scanner.py` and `scraper.py` own scan orchestration and enrichment.
|
||||
- `api/workflow_schema.py`, `api/recipe_schema.py`, `api/recipe_images.py`, and
|
||||
`api/recipe_store.py` own recipe validation/shaping, images, CRUD, history, and
|
||||
integrity receipts; `api/recipes.py` is the HTTP facade.
|
||||
- `api/recipe_packages.py` owns bounded inspect-stage-commit package handling.
|
||||
- `api/parameters.py` owns Parameter Notebook persistence and lookup.
|
||||
- `api/notebooks.py` owns Prompt Note persistence and recoverable legacy copying.
|
||||
- `api/material_schema.py`, `api/material_assets.py`, and `api/material_store.py`
|
||||
own curated material shaping, private assets, persistence/cache, search and
|
||||
lifecycle; `api/materials.py` owns HTTP mapping and compatibility entry points.
|
||||
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
|
||||
`api/folder_types.py` own the formerly mixed utility route families.
|
||||
- `model_policies.py` owns shared backend rename and protected-category policy.
|
||||
- `model_identity.py` owns file SHA-256 evidence shared with the standalone scanner.
|
||||
- `api/image_search.py` powers the output gallery search (`gallery_images?q=`):
|
||||
it reads only the PNG text chunks before the pixel data (ComfyUI prompt and
|
||||
workflow, A1111 `parameters`), caches one record per image by mtime, and
|
||||
matches all query terms; hex terms of 8+ characters also match recorded model
|
||||
SHA256 values and, via `collect_model_hash_index` in `model_resolution.py`,
|
||||
local model files with that hash. Terms arrive as repeated `term` params, one
|
||||
phrase each. `ui_search_chips.js` is the reusable search-block input (Enter or a
|
||||
comma commits a block); `ui_gallery.js` places it above the gallery.
|
||||
- `api/version_manager.py` owns the plugin's own version: the installed tag/branch
|
||||
(`/anomalous/version`, local only), published releases fetched only on request
|
||||
(GitHub releases API, falling back to `git ls-remote` tags; drafts skipped,
|
||||
pre-releases never counted as latest), switching to a published tag (detached
|
||||
checkout), returning to the default branch (fast-forward only), and undoing the
|
||||
last switch (recorded in the ignored `.anomalous_version.local.json`). Every
|
||||
switch refuses to run over modified tracked files. `ui_version_manager.js` is
|
||||
the version line (shown in the update guide opened by the header "!" button) and panel; nothing goes online until "check for updates"
|
||||
is clicked, and restarts go through ComfyUI Manager's reboot route when present.
|
||||
|
||||
## 4. Frontend Architecture (Vanilla JS)
|
||||
Located in `web/main.js` (Under 1000 lines, fully refactored into ES Modules). Wraps its logic inside `app.registerExtension({ name: "Anomalous.ModelBrowser", ... })`.
|
||||
- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared
|
||||
browser class and extracted-method wiring; `browser_entry.js` owns the single
|
||||
browser instance plus floating/topbar/menu entry behavior (with Dual-Binding PointerEvents drag capture, `lostpointercapture` fail-safe listeners, zero-drift viewport boundary clamping, flicker-free pre-mount coordinate binding and `anomalous-trigger-initializing` smooth opacity fade reveal, default safe placement in the top-left canvas area at `top: 80px; left: 80px;` gracefully avoiding the left sidebar dock, and versioned `anomalous_trigger_pos_v3` atomic JSON coordinate persistence); `entry_controls.js`
|
||||
owns entry mode, trigger sizing/styling normalization, mathematical viewport-safe boundary
|
||||
clamping (`clampFloatingTriggerPosition` with minimum safe boundary `minX=70` preventing left sidebar dock entrapment), clean `loadSavedTriggerPosition`/`saveTriggerPosition` storage drivers, and non-distorting coordinate validation
|
||||
(`isValidSavedTriggerPosition`); `api/__init__.py` injects an aiohttp no-cache middleware (`Cache-Control: no-cache, no-store, must-revalidate`) for extension static files to eliminate browser memory/disk cache desynchronization across regular page refreshes; and `interface_settings.js` owns language and theme preferences.
|
||||
- `ui_sidebar.js` creates the browser shell and folder navigation.
|
||||
`ui_settings_hub.js` owns settings and model-card preferences;
|
||||
`ui_toolbox.js` owns the tool catalog, fixed shortcut bar, and tool dispatch;
|
||||
`ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return.
|
||||
Scan-wizard launch, single-model precision scans (`triggerDirectModelScan` with strictly factual Civitai vs non-Civitai feedback reporting inferred base-model or match status directly within the bottom-right progress panel and toasts without blocking browser alerts), modal lifecycle ergonomics (backdrop click and Escape key dismissal with listener detachment, scrollable content area with sticky footer actions), post-scan frontend hash and native combo refreshes (`app.refreshComboInNodes()`, `window.anomalous_reload_hashes()`), and polling live in `ui_scan_wizard.js`; folder visibility/order lives in
|
||||
`ui_folder_manager.js`; and help content lives in `ui_help.js`.
|
||||
- `ui_model_sources.js` owns the Model Sources Hub, managing workflow-model and global-library source detection, Civitai/HuggingFace URL attribution, sidecar persistence, and resilient scope switching between active workflow and full local library (with cached library state preservation and reliable re-rendering).
|
||||
- `ui_materials.js`, `ui_material_cards.js`, and `ui_material_application.js` own the Material Library UI, category navigation, card presentation (with grab cursor affordances, explicit drag tooltips, and polymorphic card dragging via `bindPolymorphicMaterialCardDrag`), context-aware drag guidance, relaxed third-party node prompt widget sniffing and injection, and the structured empty state onboarding blueprint guiding users through collection, canvas drag, and prompt studio mixing. Drag precedence prioritizes node hits over blank canvas drops; blank canvas drops auto-instantiate `CLIPTextEncode` nodes for prompt materials with standard colors or open full workflows.
|
||||
- `ui_update_guide.js` and `update_guide_data.js` own the non-intrusive update guide modal (accessible via header button `#anomalous-update-notice-btn` and Help modal; version ID `2026-09-recipes-and-studios`), presenting a 4-step milestone walkthrough (Workflow Recipe Studio, Material Library & Prompt Studio, Model Sources Hub, and Precision Direct Scan with canvas addition) with full bilingual localization. `ui_spotlight_tour.js` provides the interactive spotlight mask tour (`startSpotlightTour`), gliding smooth focal box highlights across topbar workspaces and bottom dock actions with directional tooltip cards and keyboard navigation.
|
||||
- `sidebar_actions.js` owns the sidebar bottom action hover-reveal short labels (100ms), singleton dynamic DOM tooltip bubbles (`#anomalous-sidebar-tooltip-bubble`, 600ms), click/pointerdown instant text/tooltip suppression guards, `isBottomModalOpen` tooltip occlusion guards, and anti-flicker pointer stability.
|
||||
- `tool_registry.js` centralizes metadata, SVG icons (enlarged 20px crisp vector outlines with 2px stroke, #cbd5e1 contrast), and stable IDs for the 9 catalog tools (including Prompt Notes / 提示词笔记) and 2 fixed anchors (Toolbox and Settings).
|
||||
- `shortcut_layout.js` provides tool layout utilities and fallbacks. The bottom shortcut bar maintains the clean fixed 4-tool setup (`scan`, `doctor`, `assistant`, `materials`) plus two anchors (`toolbox`, `settings`) housed in prominent 36px buttons with full click/active text suppression and `.is-active` toggled styling.
|
||||
- `ui_toolbox.js`'s Toolbox modal strictly filters out all tools already present on the bottom bar, presenting a sleek 216px 3-row utility catalog with compact, frameless 44px tiles (providing an elevated silhouette with breathing room for catalog discovery), downward anchor caret pointing to the toolbox trigger button, 0.18s smooth spring pop-in animation, clean click action execution, and zero obstructive text or beta footers.
|
||||
- `ui_grid.js` and model-detail modules own model presentation: `ui_grid.js` manages chunked card rendering,
|
||||
card placeholder ergonomics (eliminating misleading unclickable text in favor of pure centered icon and status),
|
||||
card action buttons (one-click canvas addition with plus icon, model metadata editor, direct precision scanner without wizard modal popups)
|
||||
with absolute positioning cascades immune to tooltip target conflicts, vibrant hover affordance,
|
||||
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
|
||||
coordinates detail display, `ui_model_editor.js` owns metadata editing, and
|
||||
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` and
|
||||
`ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
|
||||
with stage interaction in `ui_image_stage.js` and metadata tabs in
|
||||
`ui_image_inspector.js`.
|
||||
- `ui_recipes.js` / `ui_recipe_detail.js`, `ui_notebooks.js`, and `ui_materials.js`
|
||||
own their respective workspace surfaces and persistence flows. `ui_recipes.js` owns
|
||||
the Workflow Recipe studio catalog workspace with search/filter tags, grid/list layout toggle,
|
||||
dedicated top-right modal close anchor (permanently decoupled from the tool button row to prevent wrapping displacement),
|
||||
streamlined action header (preserving active workflow saving while pruning unfinished package
|
||||
import entrypoints), and card browsing. `ui_notebooks.js` owns Prompt Note catalog,
|
||||
sidebar dual-group management (note list + floor quick jump anchor navigation with scrollspy active tracking and tooltip hints),
|
||||
and persistence; `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
|
||||
sub-functions adhering to the 50-line rule: sticky top action toolbar with floating More popover dropdown and timed two-step delete safety guard,
|
||||
unfolded companion models card with unconstrained multi-column tile flow eliminating nested gallery scrollbars,
|
||||
prompt composer with dynamic field-sizing and compact inline find & replace toolbar, flat material library archiving card with
|
||||
clean single-icon feedback, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
|
||||
`notebook_canvas.js` owns LiteGraph creation. Prompt Notes are integrated as a standard tool in the Toolbox
|
||||
with defensive workspace return state restoration, TDZ-safe summary initialization, and responsive empty-state fallback rendering. `ui_recipe_detail.js`
|
||||
coordinates the Workflow Recipe detail session and model composition. `ui_recipe_overview.js`
|
||||
owns the Overview prompt showcase (with `entry.text` fallback, guarded non-shrinking primary action CTA, and floating Popover More dropdown menu), and `ui_recipe_parameters.js`
|
||||
owns the responsive Parameter Presets workspace (featuring default-expanded raw node parameter inspection,
|
||||
a `clamp(230px, 24vw, 290px)` sidebar with guarded card actions, uncluttered console action bars with deferred status feedback,
|
||||
`minmax(130px, 1fr)` Bento Grid with universal click-to-copy, LoRA cards with flexbox truncation guards,
|
||||
and sticky editor headers).
|
||||
`ui_materials.js` owns Material Library discovery and pagination,
|
||||
`ui_material_cards.js` owns catalog cards, `ui_material_detail.js` owns the
|
||||
full detail surface, and `ui_material_application.js` owns selected-node
|
||||
tracking and explicit material application.
|
||||
Within recipe detail, `ui_recipe_versions.js` owns history comparison/restore,
|
||||
`ui_recipe_gallery.js` owns result cards and direct Image Detail Workbench handoff,
|
||||
`ui_recipe_model_matching.js` owns preview resolution and explicit local replacement,
|
||||
`ui_recipe_metadata.js` owns inline persistence, and `ui_recipe_detail_dom.js` owns
|
||||
the DOM/copy helpers shared by detail subviews. `ui_recipe_catalog.js` owns recipe
|
||||
filters, navigation, dismissible topbar drag guidance strip with localStorage persistence, the 3-step empty-state onboarding blueprint (`renderRecipeEmptyGuide`), and background catalog-wide model readiness resolution (`resolveCatalogRecipeReadiness`), `ui_recipe_cards.js` owns cards and card actions
|
||||
(including `grab` drag affordance, cover `可拖拽` badge, harmonized multi-state model readiness pill with `getRecipeReadiness` synchronizing available, missing, and pending matches with detail overview, and direct canvas drag-and-drop), `ui_recipe_dialogs.js` owns save/edit dialogs, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
|
||||
- `ui_prompt_composer.js` owns the standalone Prompt Studio drawer. Its child
|
||||
views are `ui_prompt_source_deck.js`, `ui_prompt_workbench.js`, and
|
||||
`ui_prompt_inspector.js`. Assembly plan data, track-vs-role separation, and
|
||||
cross-role tail smart-sorting are owned by `prompt_composition.js` and `prompt_studio_data.js`.
|
||||
`ui_prompt_source_deck.js` owns the card preview popover with narrow bridging corridors,
|
||||
differentiated hide timers, and fast dismissal when hovering or clicking library blank space.
|
||||
- `ui_prompt_translator.js` owns the standalone Prompt Translator, featuring robust multilingual/Chinese node prompt extraction (`extractPromptFromNode`), real-time canvas selection synchronization (`app.canvas.onNodeSelected`), automatic prompt injection on open/docked mode, on-demand read/sync controls, guarded selection writeback across single and multi-tab workflows, compact streamlined button ergonomics preventing multi-row wrapping, elastic vertical flex textareas maximizing canvas-side vertical space, and an expanded 460px default sidebar width with automatic backward-compatible width migration. Both translator and
|
||||
studio use `ui_lifecycle.js` for global listeners, request cancellation and
|
||||
resize cleanup. Translation requests go through `translation_service.js`.
|
||||
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns
|
||||
material-specific metadata and parameter rendering.
|
||||
- `ui_doctor.js` owns diagnostics and global scans; `ui_node_assistant.js` owns
|
||||
selected-node assistant history, `ui_node_model_picker.js` owns native combo
|
||||
replacement, and `ui_node_presets.js` owns parameter preset rendering and
|
||||
application. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
|
||||
own the remaining explicit graph changes.
|
||||
- `ui_model_sources.js` owns the Model Source Hub (模型来源统一中控中心), providing dual-scope
|
||||
(Workflow and Library) source inspection, external platform jumping, canvas `Note` node generation,
|
||||
`workflow.extra.anomalous_model_sources` metadata synchronization, automated asynchronous model
|
||||
metadata resolution (`resolveWorkflowModelsMetadata`) via `/anomalous/resolve_paths_to_previews` with
|
||||
local sidecar priority detection, and protected read-only link display with deliberate edit-mode
|
||||
unlocking and dirty-state dynamic local persistence (hiding redundant `[Save Local]` buttons until links are modified).
|
||||
- `locales.js` is the shared runtime string catalog. Existing inline bilingual
|
||||
UI strings remain migration debt; new strings belong in the catalog.
|
||||
- `styles.css` is the ordered import manifest for `web/styles/*.css`, which own
|
||||
presentation and theme overrides. Color values, dimensions
|
||||
and visual design descriptions are not duplicated as architectural contracts.
|
||||
|
||||
### The Modular Extraction Strategy (模块化拆分架构)
|
||||
We have successfully transitioned from a monolithic `main.js` to a modular ES architecture.
|
||||
Instead of fragmenting the class scope and losing context, we extracted all UI panels into `web/modules/` and bound them back to the `AnomalousBrowser.prototype`.
|
||||
1. **Modules**: All major UI components (`ui_sidebar.js`, `ui_detail.js`, `ui_doctor.js`, `ui_notebooks.js`, `ui_gallery.js`, `ui_grid.js`) are decoupled ES modules.
|
||||
2. **Shared State**: Everything continues to live on the `AnomalousBrowser` class instance (`this.xxx`). No complex context passing required.
|
||||
3. **TOC (Table of Contents)**: The top of `main.js` contains a TOC mapping out the extracted module bindings.
|
||||
## Cross-system invariants
|
||||
|
||||
### UI Components (Dynamically Created):
|
||||
* **Sidebar (`anomalous-sidebar`)**: Renders the nested folder structure.
|
||||
* **Folder Manager (📁 Manage Folders)**: An interactive modal in `ui_sidebar.js` allowing drag-and-drop reordering and visibility toggling of folders. Drives backend I/O optimization.
|
||||
* **Grid (`anomalous-grid`)**: The main model display area. Emptied (`innerHTML = ''`) and repopulated on every folder click.
|
||||
* **Detail Panel (`anomalous-detail-panel`)**: Slides out when a specific model is clicked.
|
||||
* **Gallery Viewer (`ui_gallery.js`)**: A fullscreen modal for viewing images.
|
||||
* **Prompt Notes (`ui_notebooks.js`, legacy internal name)**: A specialized editor for composing prompts and drag-dropping LoRA/model nodes. Its shared user-facing surface is **Workspace / 创作工作台**, with **Prompt Notes / 提示词笔记** and **Workflow Recipes / 工作流配方** as its two sections. This presentation rename intentionally leaves notebook routes, storage, and internal property names stable for compatibility.
|
||||
* **Workflow Recipes (`ui_recipes.js`, `ui_recipe_detail.js`)**: A second section inside the shared Workspace modal, not a separate top-level navigation surface. Recipes save the complete serialized graph and add two summary layers: adapters for common model/LoRA/prompt/sampling semantics, plus bounded generic widget summaries for every node (including third-party nodes). Prompt extraction utilizes topological backward-tracing (from KSampler input links backwards to CLIPTextEncode nodes) to rigorously identify Positive and Negative tags without relying on fragile node-title regex matching. Save captures the serialized graph exactly once before the dialog; that snapshot, not a later live-canvas serialization, is the workflow sent to the API. Schema v3 introduced a canonical SHA-256 workflow fingerprint and explicit model-reference identity records; schema v4 adds optional recipe-owned model-preview snapshot descriptors. Schema v5 separates model identity from C-site Official origin metadata (model_name/model_url, which can be manually edited or auto-fetched via Civitai API by hash directly from the detail view), ensuring UI rendering prioritizes official names over obfuscated physical filenames. The card API stays light; the full graph and history are fetched only after edit, restore, or “View details.” The detail panel owns Overview, Parameters, and Versions tabs. Parameters are sorted topologically (using Kahn's algorithm on graph links) to match workflow execution order, and their display names dynamically resolve through `LiteGraph.registered_node_types` (and dummy node instantiation for widgets) to automatically support translation plugins. The save flow uses the product default for bounded model-preview snapshots without exposing a per-recipe checkbox; a future Workflow Recipe global setting may own that preference. The edit dialog resolves safe primitive values from the full workflow by node ID/widget index before updating the matching serialized slot; bounded summaries are never the edit source. Structural edits are explicitly loaded onto the canvas and saved back through the same update path. Every update archives the previous full recipe locally (bounded to 20 versions). Missing-node preflight also reads authoritative workflow node types. `recipe_parser.js` never mutates the live canvas.
|
||||
* **Recipe interaction extensions (`recipe_diff.js`, `recipe_actions.js`)**: Recipe cards filter only lightweight name/notes/tags metadata. Version comparison fetches one validated historical JSON and returns bounded semantic changes without instantiating or mutating either graph. Append clones serialized nodes into the live graph with collision-free IDs, transactional link remapping, placement, selection, and rollback. Card and detail actions use a shared busy/restore discipline so a network or canvas failure cannot leave a button permanently disabled or trigger duplicate mutations. Open/Append UI actions share the same exported handlers from `ui_recipe_detail.js`. All Quick Queue functionality was removed to enforce a single execution path through the canvas.
|
||||
* **Recipe detail presentation**: Copy actions use visible temporary success/failure feedback. Name, notes, and tags can be edited inline from the loaded detail view and are persisted through the existing full-recipe update contract, preserving the serialized workflow and history behavior. Workflow fingerprints and model SHA-256 values are advanced information shown only inside collapsed disclosure panels; card and detail surfaces use bounded glass-style cards, responsive grids, and restrained hover transitions. The Overview separates base model and each LoRA into one full-width composition block; sampling values remain in a compact key-value grid.
|
||||
* **Doctor Panel (`ui_doctor.js`)**: Diagnoses model health for individual nodes or the entire workflow. Includes "View Profile" functionality.
|
||||
These rules are intentionally summarized here and specified in the linked topic
|
||||
documents.
|
||||
|
||||
### NON-NEGOTIABLE: Recipe Presentation & Model Preview Boundaries / 配方展示与模型预览边界
|
||||
* Recipe cover, current local model preview, and optional frozen model-preview snapshot are three different concepts. None is model identity evidence.
|
||||
* The authoritative `workflow` field contains only the serialized ComfyUI graph. Recipe covers, preview descriptors, and future snapshot asset IDs belong to additive recipe presentation data; never inject preview bytes or local preview URLs into the graph.
|
||||
* Recipe cards perform no per-model preview lookup. Current previews are demand-loaded only after the Models & reproducibility tab opens and must use a bounded exact-reference/category lookup. Detail opening must not recursively walk model folders.
|
||||
* Do not persist preview URLs as truth: they are current-machine cache locators and may change after rename or cover replacement. Display precedence is future frozen snapshot, then current local preview, then category placeholder.
|
||||
* Frozen preview snapshots are schema-v4 presentation data. The save flow uses them by default and does not expose a per-recipe checkbox; recipes with an explicitly stored `false` value remain compatible until a future Workflow Recipe global setting owns this preference. This default matters because an export can include only snapshots that were saved first. Assets live in a contained recipe-owned `.assets/<recipe-stem>/` directory, are immutable/content-addressed, and are served only through `GET /anomalous/recipe_asset`. Snapshot generation accepts static local preview files only, writes WebP thumbnails no larger than 320 px or 96 KiB each, is capped at 12 images and 1.25 MiB per save/update, and never copies videos or original full-resolution media. Deleting a recipe removes only its contained assets after the recipe file is deleted; retained history may share an asset ID. Export still asks whether to include these bounded presentation assets in the package.
|
||||
* Preview hashes, filenames, paths, images, and visual similarity MUST NEVER create a Verified identity badge or participate in Model Doctor matching. Workflow fingerprints also exclude covers and preview presentation.
|
||||
* Recipe model cards may display the basename and current local preview by default, with full saved paths and hashes behind Advanced information. Overview and compact recipe summaries must use the basename only; a filesystem path is never a model display name. Exact local references are used only for current-machine preview lookup and temporary navigation into the browser detail panel. Imported or unresolved references remain visually inactive until the user explicitly requests matching through the existing hash/size/category resolver; filename or path alone can never activate a match. A successful match activates only the current detail view and does not rewrite the authoritative workflow path.
|
||||
* Navigating from a resolved recipe model reference to the model browser detail panel must preserve the main browser modal. The recipe workspace child overlay is hidden after the detail panel is shown; it must not call the browser-wide `close()` lifecycle, because that lifecycle also hides and releases the target detail panel. The navigation stores a lightweight recipe return token (recipe payload, active tab, and scroll position); the model detail Back action consumes that token, hides/cleans the model panel, and restores the recipe detail view rather than falling back to the model grid.
|
||||
* Local model-preview media may include image, MP4, or WebM files and uses hover playback for video. Recipe save/export never embeds original video media: a bound output video may contribute only a bounded static first-frame thumbnail, while package assets remain contained WebP snapshots.
|
||||
* Detail readability is not card-summary readability: a compact card may use bounded summaries, but the detail panel may resolve the corresponding safe widget value from the already-loaded contained `workflow` by node ID and widget index. Sensitive/button widgets remain excluded. Long values and positive/negative prompts must use a visible collapse/expand control plus a copy action; never silently truncate a detail value. Overview may show normalized positive/negative prompt summaries, while Parameters preserves native node names and renders each saved `CLIPTextEncode` text widget separately. If the bounded summary omits an old native CLIP node, Parameters must recover it from the authoritative serialized workflow's `widgets_values` without changing the saved graph, then keep native CLIP rows together in the presentation order.
|
||||
1. **Identity is provenance, not naming.** Model Doctor may use a cryptographic
|
||||
hash, exact byte size under the allowed category policy, and target category.
|
||||
Paths, filenames, display names, previews, and fuzzy similarity are never
|
||||
identity evidence.
|
||||
2. **Filesystem input is untrusted.** Backend request paths must pass the shared
|
||||
containment and filename helpers. Checking only for `..` is insufficient on
|
||||
Windows and in the presence of alternate separators, UNC paths, or symlinks.
|
||||
3. **User files are changed transactionally and conservatively.** Recipe writes,
|
||||
imports, graph mutations, and sidecar operations validate before mutation and
|
||||
either complete coherently or restore the prior state.
|
||||
4. **The event loop stays responsive.** Recursive walks, hashing, metadata
|
||||
parsing, and other potentially large disk operations run off the aiohttp
|
||||
event loop. UI rendering and media loading are bounded and cancellable.
|
||||
5. **Host state is preserved.** Browser panels are mutually exclusive,
|
||||
Workspace/model-detail transitions are recoverable, and graph edits use
|
||||
ComfyUI's change/callback contracts.
|
||||
6. **Runtime strings are localized safely.** User-visible copy comes from
|
||||
`locales.js`; dynamic values stay outside dictionaries and enter the DOM as
|
||||
text. Only allowlisted rich content goes through `safe_dom.js`.
|
||||
7. **Optional integrations fail locally.** Missing graph APIs, metadata, network
|
||||
availability, or an optional resolver may disable that capability but must
|
||||
not make the main extension disappear.
|
||||
8. **Presentation data is not authority.** Covers, thumbnails, summaries,
|
||||
cached names, and workflow fingerprints never replace the authoritative
|
||||
serialized workflow or model provenance record.
|
||||
|
||||
### NON-NEGOTIABLE: Recipe Action Boundaries / 配方动作边界
|
||||
* `Open in Canvas` replaces the live graph only after an explicit dirty-canvas confirmation and never queues automatically. `Append to Canvas` clones saved nodes, remaps IDs/links, validates the complete insertion, and rolls back all inserted nodes on failure. Detail actions use one busy/settle path so a successful canvas action resolves the detail controller and a failed action restores the button state.
|
||||
* Append must not call `loadGraphData` on the live graph. It must not mutate the saved recipe, existing nodes, existing links, or external model files. Unsupported/missing node types produce a visible error instead of a destructive fallback.
|
||||
* Append accepts both LiteGraph tuple links and object-shaped link records, treats groups as first-class inserted items, rejects subgraph definitions until a dedicated ID-remapping path exists, and removes every item created by the action when any insertion step fails.
|
||||
* `recipe_diff.js` compares bounded semantic records only. It must not expose newly-unfiltered sensitive widget values, render a full graph diff, instantiate saved nodes for display, or mutate either recipe.
|
||||
## Data and compatibility boundaries
|
||||
|
||||
### Graph Splicing and Picker Context (`graph_splice.js`, `model_picker.js`)
|
||||
Manual LoRA insertion is an explicit canvas mutation and is isolated from Model Doctor identity recovery. The graph helper analyzes ports by their declared `MODEL` and `CLIP` types, never by fixed slot indexes or display names. It supports inserting a compatible loader before a node with connected MODEL/CLIP inputs, or after a node with MODEL/CLIP outputs. Downstream fan-out is rejected as ambiguous until the user can choose a concrete branch.
|
||||
- Runtime settings and newly saved API keys live in `api/config.json`.
|
||||
`scraper.py` may read the legacy root `config.json` only as a compatibility
|
||||
fallback. API keys are not stored in browser `localStorage`.
|
||||
- Recipes, Parameter Notebooks, and Material Library records/assets are user data outside the extension directory.
|
||||
They are never bundled with or silently migrated into the plugin source.
|
||||
- The extension may integrate with Civitai and optional translation services
|
||||
only through explicit product behavior. External content and dependencies keep
|
||||
their own terms.
|
||||
- Project code and documentation use the MIT license. `TRADEMARKS.md` separately
|
||||
defines the project-name and official-branding boundary.
|
||||
- Internal property names and established routes may remain stable when a
|
||||
user-facing surface is renamed. Do not churn compatibility contracts merely
|
||||
to match presentation wording.
|
||||
|
||||
Graph edits are transactional: validate the complete topology before mutation, wrap the operation in `graph.beforeChange()` / `graph.afterChange()`, and restore every original connection if node creation or any link operation fails. A successful insertion must be one undoable graph change and must dirty the canvas. The helper must not move, delete, or rewrite existing nodes or widgets, and model choices supplied by the UI must remain constrained to ComfyUI's native combo values for the inserted node.
|
||||
## Change and snapshot protocol
|
||||
|
||||
The Node Assistant owns the corresponding picker UI. Replacement actions live in the selected-node toolbar, while insert-before/insert-after actions are enabled only when graph analysis reports a safe topology. The picker derives its complete candidate set from the target widget's native combo values, then provides client-side folder browsing, full-path search, and sorting. It must not broaden the list with models from another category or require metadata scanning merely to select a filename.
|
||||
Every product-code change should end as one coherent local Git snapshot:
|
||||
|
||||
`model_picker.js` owns pure picker-type inference, base-model family normalization, and bounded upstream MODEL/CLIP traversal used to discover the nearest non-LoRA main model. Picker presentation metadata is resolved in one batch through `/anomalous/resolve_paths_to_previews`. The request supplies the native folder-type boundary inferred from the target widget, and the response may include preview URLs, model category, and cached sidecar metadata for each requested path. Picker cards expose category and `baseModel` badges using text-only DOM assignment. LoRA compatibility filtering uses the connected main model's `baseModel` metadata, falling back to the current LoRA's metadata only when the main model cannot be identified. This is a user-visible browsing filter, not Model Doctor identity evidence; missing metadata must remain accessible through an explicit unfiltered option.
|
||||
1. Run checks proportional to the changed behavior.
|
||||
2. Update architecture documentation **only** when the change modifies a module
|
||||
owner, data flow, public/internal interface contract, persistence format,
|
||||
security boundary, or critical invariant.
|
||||
3. When architecture changes, update the narrowest relevant topic document.
|
||||
Update this entry point only if the system map, cross-system invariants, or
|
||||
reading map changed.
|
||||
4. Do not add an architecture entry merely to say that existing boundaries were
|
||||
unchanged. Ordinary fixes belong in code, tests, Git history, and—when useful
|
||||
to users—`CHANGELOG.md`.
|
||||
5. Record a durable lesson in `.agents/logs/ai_lessons.md` only for a recurring
|
||||
trap or a critical failure mode, not as a turn-by-turn work log.
|
||||
6. Create a local commit after verification. Keep unrelated work out of the
|
||||
snapshot and do not push without explicit user authorization.
|
||||
7. Leave a clean worktree, or identify every intentional uncommitted file in the
|
||||
handoff.
|
||||
|
||||
### JavaScript Rules of Engagement:
|
||||
1. **Strict Localization (双语)**:
|
||||
- Never use hardcoded UI strings.
|
||||
- Always use the ternary operator bounded to the global state: `window.anomalous_browser_lang === 'zh' ? '中文' : 'English'`.
|
||||
- Never use variables like `currentLang === 'zh'` for DOM rendering, because they fail when closure scopes diverge from the global state.
|
||||
2. **Avoid Global Scope Pollution**: Scope all IDs and classes with `anomalous-`.
|
||||
3. **Z-Index Tiers & Overlap Prevention (层叠规范)**:
|
||||
- Base UI: `10000`
|
||||
- Overlay Modals: `10001` to `999999`
|
||||
- *Critical Rule*: Never arbitrarily assign z-indexes. A child modal MUST have a strictly higher z-index to prevent the parent from consuming clicks.
|
||||
4. **DOM ID Conflicts with CSS**: When assigning an `id` to an element dynamically, always `grep_search` `styles.css` first.
|
||||
5. **Untrusted Text and Rich HTML**: Filenames, folder names, notebook names, workflow values, and metadata are untrusted. Use `textContent` or `escapeHtml()` for normal text. Only Civitai description/notes fields may retain formatting, and they must be inserted with `setSafeRichHtml()` from `safe_dom.js`; direct metadata assignment to `innerHTML` is forbidden.
|
||||
|
||||
## 5. Hash Resolver Subsystem & Scanning Engine (`hash_resolver.js` & `scraper.py`)
|
||||
A highly complex subsystem responsible for automatically resolving missing/broken model references in workflows, and aggressively scanning models to build local caches.
|
||||
|
||||
### NON-NEGOTIABLE: Model Doctor Identity Boundary / 模型医生身份判定死规矩
|
||||
* Model Doctor exists to recover the same physical model referenced by provenance data embedded by this plugin in an exported workflow or image. Local path differences and local renames are the problem being solved; therefore a path, filename, display name, custom name, source filename, or fuzzy name similarity MUST NEVER be used as evidence that two models are identical.
|
||||
* Automatic identity evidence is limited to plugin-carried cryptographic hashes, exact physical byte size as a controlled fallback/disambiguator, and the model category required by the target widget. Byte size alone is acceptable only when it produces exactly one candidate inside the required category and no target hash contradicts it; a supplied hash mismatch never falls back to a filename or size-only guess.
|
||||
* Paths and filenames may be used only after model identity has already been established: to return the resolved local dropdown value and to verify that value against ComfyUI's native combo choices. They must not participate in candidate selection, ranking, tie-breaking, or ambiguity resolution.
|
||||
* Normalizing slash direction for a value that already has an exact equivalent in the widget's native ComfyUI options is representation normalization, not model discovery, and does not weaken this identity boundary.
|
||||
* If a legacy/corrupt hash cannot be recovered uniquely from allowed evidence (for example several in-category files have the same byte size), Model Doctor MUST report ambiguity or remain unresolved. It must never guess from the old filename/path. Manual replacement is the explicit fallback.
|
||||
* This boundary is part of the plugin's native product contract. Do not weaken, reinterpret, or bypass it without explicit user authorization.
|
||||
|
||||
### The Global Hash Cache (`window.anomalous_hash_cache`)
|
||||
* This is the absolute core of the resolution engine. It maps physical filenames to their exact SHA256 hashes.
|
||||
* It is fetched on startup by the optional resolver via `fetch('/anomalous/all_hashes')`. If the current ComfyUI frontend does not expose a compatible graph serialization API, the resolver disables itself with a warning; this must never prevent the main browser extension and its visible entry point from loading.
|
||||
* Whenever a scan finishes (Scan Wizard or Deep Hash Scan), `window.anomalous_reload_hashes()` MUST be called to synchronize the frontend dictionary with the newly generated `.info` files on the disk.
|
||||
* The cache exposes relative-path and basename aliases only when they are unambiguous. If two local models share a key but have different hash/size values, that alias is omitted instead of silently choosing one. Frontend lookups must prefer the full widget value before falling back to a basename.
|
||||
|
||||
### Model Provenance Binding (模型溯源绑定 - `anomalous_inject_hash`)
|
||||
* Controls whether hashes are invisibly injected into the `extra_pnginfo` and workflow JSON when a user saves a workflow or generates an image.
|
||||
* **Logic Flow**: The active graph constructor's `prototype.serialize` is hooked when available (falling back to the legacy global `LGraph` only when present). If enabled, it intercepts the serialization, looks up every model widget's filename in `window.anomalous_hash_cache`, and explicitly writes `extraObj.anomalous_hashes[node_id_filename] = {hash, size}`. A missing graph API disables only this optional hook.
|
||||
* Exact-path checks are limited to pre-flight verification that an already-resolved local reference exists; they are outside Model Doctor provenance recovery and must never be used as its fallback. For provenance recovery, the backend constrains the search to the model category inferred from the node/widget and intersects the saved hash with the saved byte size. If no file in that category owns a legacy/stale hash, only a unique in-category byte-size match may recover the model as `stale_hash`. Equal-sized candidates remain unresolved, and a real hash/size conflict is rejected instead of choosing arbitrarily.
|
||||
* Civitai `.info` files may describe several physical files (for example a diffusion model, text encoder, and VAE). `get_metadata()` must select the matching `files[]` entry by exact physical byte size and may use an unmatched entry only when it is the sole hash candidate. It must never take the first SHA256 or use a filename to choose between entries.
|
||||
|
||||
### The Triple-Fallback Scanning Engine (`api/scanner.py` & `scraper.py`)
|
||||
When "Deep Hash Scan" is triggered, it runs in a background thread to prevent UI lockup. It uses a triple fallback to identify models:
|
||||
1. **Fallback 1 (Header Hash Match)**: Extracts `modelspec.hash.sha256` or Blake3/AutoV2 directly from the `.safetensors` header (O(1) speed). Hits Civitai API to fetch official metadata.
|
||||
2. **Fallback 2 (Full File SHA256)**: If the header has no hash, it brutally calculates the full file SHA256 (Slow) and hits Civitai.
|
||||
3. **Fallback 3 (Offline Inference)**: If Civitai returns 404, it reads the Tensor Fingerprints (keys like `cond_stage_model`) from the header to guess the Base Model (SDXL, SD 1.5, Flux, SD3) and builds a local `.info` file.
|
||||
* **Offline hash injection**: After a fallback succeeds, the engine injects the discovered hash into the generated JSON. Consumers must still associate that entry with the current physical file using size/name matching; array position is not an identity guarantee.
|
||||
* Physical rename conflicts are non-destructive. When the target filename already exists, both files must be hashed; deletion is permitted only when their complete SHA256 values match. Different files with the same generated display name must both be preserved.
|
||||
|
||||
### Resolution Execution (`window.anomalous_resolve_all_missing_nodes`)
|
||||
Scans all nodes in `app.graph._nodes` that are colored red.
|
||||
* Extracts the saved hash from the graph's `anomalous_hashes`.
|
||||
* Sends hash, byte size, and the inferred model category to `/anomalous/resolve_hash`. The endpoint accepts a result only when the identity signals agree, or when a unique in-category size resolves a request that has no usable hash; a contradictory supplied hash remains unresolved.
|
||||
* If a match is found, the frontend refreshes native ComfyUI combo definitions and requires the returned path to exist in that widget's native choices. Cross-category or otherwise invalid paths are rejected; the resolver must never append a foreign path to `widget.options.values` merely to report success.
|
||||
* After native validation succeeds, it mutates the dropdown `value`, removes the red color, and flags it as `anomalous_auto_resolved`.
|
||||
|
||||
## 6. Known Gotchas & Critical Context (新接手必读)
|
||||
* **Metadata Override Danger**: If you write to a `.civitai.info` file, ALWAYS read it first and update the dictionary. Never just dump `{custom_name: 'foo'}`.
|
||||
* **Search Context Trap**: If the user searches using the sidebar while a detail panel is open, you MUST explicitly hide the detail panel and show the grid.
|
||||
* **Media Leakage**: Destroying a DOM element that contains a playing `<video>` or `<audio>` does **not** stop the audio. You must `.pause()` it before doing `innerHTML = ''`.
|
||||
* **Video Cover Compatibility & Interaction**: Any frontend component displaying model preview covers MUST check if the file is `.mp4`/`.webm`. The model-card setting owns the grid behavior: `always` may autoplay only while the card is near/in the viewport; `hover` loads/plays on pointer hover and pauses on leave. Both modes use muted/loop/playsinline and release their source when the browser closes. Single main covers may keep their established autoplay behavior. **CRITICAL GOTCHA**: Do NOT use `new URL(url).pathname` to check extensions! Our backend serves images via query parameters (e.g. `?filename=cover.mp4`), so `pathname` strips the filename. Always test the full URL using regex like `/\.(mp4|webm)(?:$|\?|&|#)/i`.
|
||||
* **Variable Naming Collisions**: When moving UI elements, search for ALL references to the old `const` declaration. A single duplicate `const` in the same scope kills the entire module at parse time.
|
||||
* **Data Scaling & Payload Limits**: When passing arrays/lists (e.g. hundreds of selected files), NEVER append them to URL query parameters (`GET` requests), or you will trigger `414 URI Too Long`. Use `POST` with a `JSON Body`. Similarly, NEVER pass massive arrays directly to `subprocess.run` via CLI arguments, as it will crash silently due to OS character limits (8191 on Windows). Always dump massive data to an intermediate `.json` file for the subprocess to read.
|
||||
|
||||
> For a complete list of past mistakes and detailed post-mortems, refer to `.agents/logs/ai_lessons.md`. Engineering snapshots and handoff notes belong in `.agents/logs/ai_changelog.md`.
|
||||
|
||||
## 7. Mandatory Change Snapshot Protocol (强制变更快照流程)
|
||||
|
||||
Every product-code change in this plugin must finish as one coherent local Git snapshot:
|
||||
|
||||
1. Run checks proportional to the touched behavior before committing.
|
||||
2. Update `.agents/logs/ai_changelog.md` in the same change with the implementation summary, architectural decision, checks run, and handoff notes.
|
||||
3. Update this `ARCHITECTURE.md` in the same change so directory ownership, data flow, API contracts, naming boundaries, and non-negotiable rules remain accurate. If the code does not alter an architectural boundary, record that the existing boundary is intentionally unchanged rather than inventing a new abstraction.
|
||||
4. Create a local Git commit after verification. Keep unrelated work out of the snapshot and do not push unless the user explicitly requests it.
|
||||
5. Leave a clean worktree for the next agent, or document every intentional uncommitted file in the handoff.
|
||||
|
||||
`CHANGELOG.md` is public user-facing release communication. Update it only for intentional user-visible release notes; never use it for internal design decisions, implementation details, test output, or agent handoff. Planning-only documentation may be committed separately, but proposals must be labeled clearly and must not be described as implemented runtime behavior.
|
||||
|
||||
## 66. Recipe Canvas Actions Use Append as the Safe Composition Path
|
||||
|
||||
Recipe cards and detail views intentionally expose only “Append to Canvas”. The removed “Open to canvas” action was ambiguous across ComfyUI versions: the legacy API replaced the current graph, while the current workflow-tab API opens a new temporary tab unless an internal active-workflow object is supplied. Keeping only append avoids both meanings and preserves the user's current canvas.
|
||||
|
||||
The separate structural-edit action may still load a recipe into a new canvas for editing; it is not a recipe composition action and remains explicitly confirmed. Successful append actions continue to hide the notebook body, recipe view, recipe modal panel, and outer modal before invoking the existing close/media cleanup.
|
||||
|
||||
## 67. Recipe Save Does Not Ask for Presentation Pins or Snapshot Policy
|
||||
|
||||
The save dialog records recipe identity, notes, tags, and cover/source image. It does not render the expandable “Choose key parameters” section or a model-preview snapshot policy checkbox: the complete serialized workflow remains the source of truth, and these implementation choices should not burden users who only want to save/share a recipe. The current bounded snapshot default is internal and can later move behind a Workflow Recipe global setting. Existing `params.pinned` data is preserved when editing an older recipe so removing the control is not a destructive metadata migration; new saves use an empty pin list.
|
||||
|
||||
## 68. Parameter Notebook Switches Must Identify the Right-Hand State
|
||||
|
||||
The Parameters tab is a two-pane history browser, so highlighting only the left notebook list is insufficient feedback. The right pane must show the active notebook name and timestamp in a prominent selection banner and animate the panel on a genuine notebook switch. Parameter-gallery requests carry a selection token and must discard stale responses after the user switches notebooks.
|
||||
|
||||
Saved parameter notes are individually removable from the notebook sidebar. The row uses sibling selection and delete buttons rather than nested interactive controls, asks for explicit confirmation, and posts only the validated notebook filename to `/anomalous/delete_parameter`. Deleting the active note clears its selection, invalidates in-flight parameter-gallery requests, reloads the notebook list, and selects the newest remaining note. The current recipe parameter baseline is not a stored notebook row and therefore cannot be deleted through this control.
|
||||
|
||||
## 69. Docked Notebook Controls Must Shrink as a Unit
|
||||
|
||||
The notebook create row contains a text input and a confirmation button. In the 660px docked browser, the notebook sidebar is reduced to 80px, so the input must have `min-width: 0` and the confirmation button must remain a non-shrinking fixed-size item. Otherwise the input's placeholder intrinsic width pushes the check button outside the clickable sidebar.
|
||||
|
||||
The output gallery is a user-refreshed view of ComfyUI's output directory. Opening the gallery performs one page-one scan, and the toolbar's Refresh button explicitly repeats that scan on demand. There is no background polling; the grid preserves scroll position when manually refreshed.
|
||||
|
||||
## 70. Keep the main browser entry module parseable
|
||||
|
||||
The floating trigger is created by `web/main.js`, but ComfyUI loads every JavaScript file in the custom node's `WEB_DIRECTORY` as an ES module. A syntax or duplicate top-level declaration in any imported module prevents `main.js` from registering and leaves no visible plugin icon, even when the backend routes and the optional hash resolver are healthy.
|
||||
|
||||
When a trigger disappears, verify the browser extension registry and load the main entry in isolation before changing CSS or z-index values. In particular, do not duplicate helper declarations while merging recipe UI changes; shared helpers in `ui_recipes.js` must have one definition per module. `node --check` is required for the affected module, and a real ComfyUI runtime check must confirm that `#anomalous-trigger-btn` is created.
|
||||
|
||||
## 71. Recipe and Node Assistant Integration Must Fail Loudly and Preserve Host State
|
||||
|
||||
The Node Assistant consumes parameter notebooks by node type, while the Recipe detail remains the authoritative source for full-workflow skeleton matching. The assistant's preset endpoint may cache notebook discovery briefly, but save/delete operations invalidate that cache and an explicit assistant refresh requests a fresh read.
|
||||
|
||||
Applying a node preset is a transactional live-widget operation: volatile seed slots are ignored, values are cloned safely, widget callbacks and ComfyUI's four-argument `onWidgetChanged(index, value, previousValue, liveWidget)` hook are invoked only when callable, and callback failures roll back the node before the UI reports failure. A successful write marks both graph and canvas dirty and emits `graphChanged` so recipe and assistant surfaces can refresh.
|
||||
|
||||
Prompt roles are derived only from an explicit allowlist of ComfyUI prompt nodes, consumers, and conditioning pass-through nodes. Link storage may be an array, object, or `Map`, and every linked role input is inspected. Unknown third-party nodes are opaque boundaries: neither their title nor their type name may turn an unresolved text into a positive or negative prompt. Saved role metadata is joined to assistant records using string-safe node IDs; legacy recipes use exact, unambiguous full-value recovery and otherwise remain unknown.
|
||||
|
||||
The assistant must not call removed doctor-panel methods from canvas selection hooks. Existing host selection callbacks are preserved, and the assistant hook only updates its own panel.
|
||||
|
||||
## 72. Legacy Prompt Roles Are Recovered from Full Prompt Values
|
||||
|
||||
Older recipes may contain correct `params.promptPositive` and `params.promptNegative` arrays but no per-node `role` field. When the Node Assistant prepares a node-type preset response, the backend compares the node's complete saved text widget value against those arrays and injects a role only when the match is unambiguous. An identical value present in both arrays remains unclassified rather than being guessed; explicit metadata roles always take precedence.
|
||||
|
||||
## 73. Prompt Roles Are Conservative and User-Correctable
|
||||
|
||||
The first supported automatic contract is intentionally narrow: ComfyUI's native `CLIPTextEncode` can receive a role only through allowlisted official sampler/Guider inputs and allowlisted conditioning pass-through nodes. An unresolved native prompt is `unknown`; a third-party text candidate is also `unknown` unless the user labels it. The detail panel states this limitation directly and never uses node titles, prompt content, or a default-positive rule as semantic evidence.
|
||||
|
||||
Manual labels are recipe-skeleton metadata stored in `params.promptRoleOverrides`, keyed by string-safe node ID and guarded by the saved node type. Supported values are `positive`, `negative`, `both`, `ignored`, and `unknown`; removing an override restores the saved automatic result. Recipe updates retain an override only while node ID and type still match. The Parameters view and Node Assistant must consume the recipe override before automatic or legacy metadata, so all surfaces show the same role. Future third-party adapters may expand the allowlist, but unknown nodes must remain conservative until explicitly supported.
|
||||
|
||||
## 74. MIT Copyright Permission and Project Branding Are Separate
|
||||
|
||||
`LICENSE` remains the unmodified standard MIT license and is the copyright license for repository code, documentation, stylesheets, and ordinary UI resources unless a file expressly says otherwise. Do not append project-specific restrictions to that license or describe ordinary UI implementation as proprietary while simultaneously licensing the repository under MIT.
|
||||
|
||||
`TRADEMARKS.md` owns the separate source-identification boundary for the name **Anomalous Model Browser**, stylized presentations of that name, and any graphic explicitly designated as the official logo. It permits truthful references, links, compatibility statements, unmodified redistribution, and clear “based on” attribution. Public modified distributions use a distinct primary name/logo and must not imply official status or endorsement. README summarizes and links to both documents; legal-policy changes must be intentional and must not be hidden in engineering changelogs.
|
||||
|
||||
## 75. Beta Workflow Features Carry Localized Data-Safety Guidance
|
||||
|
||||
Workflow Recipes and the Node Assistant's recipe-powered parameter presets are explicitly presented as beta features until a stable release decision removes that status. The Node Assistant's Actions tab (visual model replacement and LoRA insertion) is mature and outside that beta boundary. The Recipe list renders its compact notice at library level, while the assistant notice belongs inside the hidden-by-default Parameter Presets container and appears only when that tab is active; no warning sentence is hardcoded in a UI module. The Settings Help content and bilingual README repeat the operational boundary and identify `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` under the active ComfyUI user directory as the data to back up.
|
||||
|
||||
Documentation distinguishes the two application scopes: Node Assistant presets update the currently selected same-type node and ignore volatile values, while a Parameter Notebook's “Apply to Current Workflow” verifies the complete recipe skeleton before updating safe widgets. Model-preview snapshots are presentation assets, never model-file backups. README and Help must remain bilingual and describe the same behavior whenever these workflows change.
|
||||
|
||||
`locales.js` is the authoritative source for every user-visible runtime string in this feature. Chinese and English dictionaries must have identical key sets, no duplicate keys, and all statically referenced `t('key')` calls must resolve in both dictionaries. Language parity and static-key coverage are release checks for changes to Recipe, Assistant, Settings, or Help surfaces.
|
||||
|
||||
Persistent beta notices carry `data-anomalous-i18n-key` metadata. The sidebar language switch refreshes those live nodes in place and rebuilds the Node Assistant around the current selection, so changing language does not require closing either surface and cannot leave its warning, placeholder, or selected-node content in the previous language. Beta notices must be scoped to the exact experimental interaction rather than a stable parent surface. Any future persistent localized notice should use the same contract.
|
||||
|
||||
## 76. Recipe-to-Model Navigation Is an Exclusive, Recoverable Transition
|
||||
|
||||
Opening a local model from Recipe Overview temporarily replaces every main content surface: grid, Gallery, Model Doctor, Node Assistant, parameter panel, and Workspace are hidden before the model detail becomes visible. The recipe payload, active tab, scroll position, and original `workspaceReturnState` remain available across the transition, while `recipeModelReturn` exclusively owns reconstruction of the recipe detail; model detail and Node Assistant must never be visible together.
|
||||
|
||||
There are two valid ways to end this transition. The model detail Back action (or reopening Workspace and choosing Workflow Recipes) consumes `recipeModelReturn` and reconstructs the exact recipe detail. Navigating directly to another main panel or closing Workspace abandons it: stale payload/scroll state is cleared, detail media and DOM are released, and the recipe list plus action bar are restored. `showRecipes()` also normalizes those list surfaces defensively whenever no detail is active, so a lost callback cannot produce a blank Recipe body. Opening Workspace while a recipe-model return is pending must not overwrite the earlier `workspaceReturnState`, otherwise closing the reconstructed recipe could restore an empty model-detail panel.
|
||||
Decision records explain enduring choices; they are not a chronological diary.
|
||||
Git history is the authoritative record of implementation changes. Planning-only
|
||||
documents must be clearly labeled as proposals and must not describe unshipped
|
||||
behavior as current architecture.
|
||||
|
||||
@@ -1,5 +1,160 @@
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.57.3 Beta (Hotfix: Offline Scan, Prompt Workshop) — 2026-09-29
|
||||
|
||||
### 🔌 Offline scan and small fixes (离线扫描与小修复)
|
||||
- **Offline scan stays offline (离线扫描不再联网)**: "Offline fast read" no longer sends the greyed-out rename and overwrite options, and an offline scan never fetches Civitai model pages or preview images, even for models whose `.info` came from Civitai. Scan requests without options are refused instead of starting an online scan.
|
||||
- Clicking the drop zone at the end of the Prompt Workshop's assembly track focuses the card search again instead of failing silently.
|
||||
- Malformed requests to translation, deletion, scanning and image-material routes get a clear 400 instead of a server error.
|
||||
|
||||
## v1.57.2 Beta (Hotfix: Canvas Performance, Saving Workflows) — 2026-09-27
|
||||
|
||||
### 💾 Saving workflows and Prompt Notes (保存工作流与提示词笔记)
|
||||
- **Saving a workflow works again (保存工作流失败)**: since v1.57, **Save Current Workflow** failed with "Could not save this Workflow Recipe" for any workflow with a model loader, because splitting the backend into smaller files left the save path calling a function that was no longer imported. The same split also made model cards load full-size cover images instead of thumbnails (slow on big folders), and made editing details of a model that had moved fail.
|
||||
- **Prompt Notes with UNet models (提示词笔记与 UNet 模型)**: sending a note with a UNet / diffusion model to the canvas built a graph that could not run, because its LoRAs waited for a CLIP the UNet does not have. LoRAs of a UNet now load model-only, and the note says to connect your own text encoder to the two prompt nodes. Models whose metadata names no base model (most models not downloaded from Civitai) were missing from every group; they now appear under **No base model listed**. Listing models no longer blocks ComfyUI while it reads the folders.
|
||||
- Installed v1.57.2 before 2026-09-27 through the version panel? Pick v1.57.2 there once more to get this build; updating through ComfyUI Manager gets it as usual.
|
||||
|
||||
### ⚡ Smooth canvas dragging again (画布拖动卡顿修复)
|
||||
- **Dragging and zooming the canvas no longer slows down with this plugin installed (安装插件后拖动画布掉帧)**: one stylesheet rule, which only hid the sidebar tooltip while the Toolbox or Settings popup was open, made the browser re-check the whole page every time any element's inline style changed. ComfyUI moves every text-box widget on every frame while you pan or zoom, so each frame paid for it, even with the browser closed. Measured on a 300-node workflow in ComfyUI: style work while panning drops from about 11× the no-plugin cost back to the same as without the plugin.
|
||||
- **Large workflows with ComfyUI Prompt Assistant (与提示词小助手同时安装时的严重卡顿)**: the cost grew with the number of page elements, and Prompt Assistant adds a row of buttons to every text box, so together style work took about 19 ms per frame (more than a whole 60 fps frame): single-digit frame rates or freezes on big workflows. It is now back to the level of Prompt Assistant alone. The two plugins never conflicted otherwise.
|
||||
- The tooltip still stays hidden while the Toolbox or Settings popup is open; the popups already hide it when they open and it does not reappear until they close.
|
||||
|
||||
### 🧩 Material Library with a node selected (选中节点时素材库卡片错位)
|
||||
- **One apply button per card (每张卡片只有一个应用按钮)**: with a node selected, the Material Library showed every card's **Apply Scheme** button twice, each with a doubled ⚡ icon.
|
||||
- **No more overlapping text (卡片文字不再重叠)**: the extra button squeezed the badges, date, node types and tags on top of each other. Cards in apply mode now get a taller row with the button at the bottom; applying works as before.
|
||||
|
||||
## v1.57.1 Beta (Version Panel: Update, Switch & Roll Back) — 2026-09-23
|
||||
|
||||
### 🏷️ Version & Updates Panel (版本号与一键更新 / 版本切换)
|
||||
- **Version entry in the update guide (更新引导顶部的版本号与更新入口)**: the installed version (e.g. `v1.57.1-beta`) and a **Check for updates / switch version** button now appear at the top of the update guide (opened via the header **!** button), avoiding accidental clicks near the tool dock or crowding the header in docked/scaled layouts. Reading it is local only; nothing goes online.
|
||||
- **Manual "Check for updates" (手动检查更新)**: the plugin never checks by itself. Clicking **Check for updates / switch version** opens the panel, and only the **Check for updates** button contacts GitHub. Each published release shows its notes, date and a GitHub link; pre-releases are hidden by default and are never offered as the latest version.
|
||||
- **One-click update to published releases (一键更新到正式发布版本)**: updates only target releases published on GitHub, not in-progress commits, so what you install is a released build.
|
||||
- **Switch or roll back (切换 / 回退版本)**: any published release can be installed from the list. Rolling back warns that older versions may not read settings or recipes saved by newer ones; nothing is deleted, and it all works again after returning.
|
||||
- **Back to latest & undo (回到最新版 / 撤销上次切换)**: **Back to latest** returns to the default branch and pulls the newest code; **Undo last switch** returns to exactly where you were before. Versions older than v1.57.1 have no panel, so from those use **Update** for this plugin in ComfyUI Manager to come back.
|
||||
- **Safe by default (安全保护)**: every switch is refused while plugin files have local modifications (the files are listed), and a local branch with unpublished commits is never overwritten.
|
||||
- **Restart (重启生效)**: after a switch the panel offers to restart ComfyUI through ComfyUI Manager; without Manager it asks you to restart by hand.
|
||||
|
||||
## v1.57.0 Beta (Toolbox Architecture, Gallery Search & Polymorphic Drag) — 2026-09-23
|
||||
|
||||
### 🎨 Pure Vector Iconography Overhaul (纯代码矢量图标体系全面升级)
|
||||
- **Universal Code-Native SVG Icons (全套规范化纯代码矢量 SVG 图标)**:
|
||||
- Completely phased out legacy emojis and pixel icons in favor of lightweight, mathematically crisp 24x24 code-native vector SVGs (`stroke-width: 2`, rounded joins/caps).
|
||||
- Redesigned the floating entry trigger orb with the **Anomalous Hypercube Core** (`1b985ba`) and frosted glass lighting effect, maintaining smooth rendering and subtle hover micro-interactions.
|
||||
- Sidebar and toolbox action buttons upgraded with distinct vector geometry: 3D Asset Cube for Toolbox, Radar Pulse for Scan Wizard, Stethoscope for Model Doctor, Four-Point Spark for Node Assistant, Layered Sheets for Material Library, and Obsidian Gear for Settings.
|
||||
- Eliminated icon flicker, CSS pseudo-element clipping, and alignment shifts across all display scales.
|
||||
|
||||
### 🔍 Multi-Dimensional Gallery Search (历史出图图库多维检索与标签块)
|
||||
- **Multi-Field Real-Time Search (提示词/模型/种子/哈希多维检索)**:
|
||||
- Upgraded the native output gallery header with an intelligent search bar capable of parsing prompts, model file names, exact numeric seeds, and cryptographic SHA-256 model hashes simultaneously (`1476faf`).
|
||||
- Search terms are dynamically converted into visually distinct, standalone **Search Blocks (搜索标签块)** (`e077689`).
|
||||
- Each search block clearly displays its matched category and provides an accessible `×` button for one-click removal, enabling flexible compound filtering without retyping.
|
||||
- Backed by optimized chunk parsing (`api/image_search.py`) that reads only header metadata prior to pixel buffers, ensuring zero-lag gallery navigation.
|
||||
|
||||
### 🚀 Polymorphic Canvas Drag & Drop (全域多态画布拖拽架构)
|
||||
- **Universal Canvas Drag (全域多态画布拖拽)**:
|
||||
- Dragging workflow materials onto empty canvas automatically reconstructs and opens the full workflow.
|
||||
- Dragging prompt-only materials onto empty canvas auto-creates native `CLIPTextEncode` nodes with pure prompt text, bilingual titles, and standard ComfyUI dark theme colors (`#532323` for negative / `#235327` for positive).
|
||||
- Dragging materials onto existing canvas nodes prioritizes the target node, injecting matching widget values and prompt text via semantic widget sniffing while strictly filtering out model file paths and loader blocks.
|
||||
- **Floating Trigger Pre-Mount & Anti-FOUC (悬浮入口预挂载防闪烁与安全吸附)**:
|
||||
- Completely eliminated FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal (`53c9f1b`).
|
||||
- Hardened PointerEvents persistence, safe coordinate validation, and boundary clamping to prevent trigger entrapment or collapsing to the viewport origin (`09b06d1`).
|
||||
|
||||
### 🧰 Toolbox Catalog & Dedicated Bottom Dock (实用工具箱与底部固定快捷栏架构)
|
||||
- **Dedicated Bottom Action Bar (结构化底部快捷栏)**:
|
||||
- Streamlined the lower-left sidebar into a dedicated 6-button utility bar:
|
||||
1. **Toolbox (🧰 实用工具箱)**: Anchored as the primary launcher on the left to access secondary tools.
|
||||
2. **Scan Wizard (🔄 扫描向导)**: Direct shortcut to launch model library scanning.
|
||||
3. **Model Doctor (🩺 模型医生)**: Direct shortcut to diagnose and auto-heal missing workflow models.
|
||||
4. **Node Assistant (🤖 节点助手)**: Direct shortcut for canvas node inspection, model swapping, and parameter injection.
|
||||
5. **Material Library (✨ 素材库)**: Direct shortcut to manage curated material bundles and polymorphic canvas drag-and-drop.
|
||||
6. **Settings (⚙️ 设置)**: Fixed anchor on the right for interface preferences.
|
||||
- **Toolbox Modal Catalog (实用工具箱浮窗面板)**:
|
||||
- Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel housing 5 secondary tools not on the main dock:
|
||||
1. **Workflow Transfer Center (导入导出)**: Lossless AMB format workflow share code import and export.
|
||||
2. **Prompt Studio (提示词工坊)**: Modular prompt mixer deck for lego-block prompt extraction, reordering, and assembly.
|
||||
3. **Prompt Translator (翻译助手)**: Bilingual real-time English/Chinese prompt translation.
|
||||
4. **Model Source Hub (模型来源)**: Online model source URL inspector with instant canvas Note node generation.
|
||||
5. **Prompt Notes (提示词笔记)**: Lightweight notebook for drafting and organizing reusable prompt snippets (`workflows/anomalous_notebooks`).
|
||||
- **Enhanced Sidebar Tooltips & Reveal Timing (侧边栏操作提示与动画优化)**:
|
||||
- 100ms quick hover text replacement on shortcut buttons and 600ms rich singleton floating tooltip bubble (`#anomalous-sidebar-tooltip-bubble`) with bold title and muted explanation, cleanly avoiding CSS pseudo-element clipping issues.
|
||||
- Clicking or pressing any action button immediately suppresses hover text and floating tooltips.
|
||||
- Maintained elegant obsidian glass / dark monochromatic aesthetics without harsh or distracting bright colors.
|
||||
|
||||
### Navigation and update guide
|
||||
- Sidebar icons reveal compact text labels on hover or keyboard focus while keeping their existing button size and actions. Detailed hints explain each tool; scan-state and language changes retain labels. Unclipped tooltips ensure explanations are fully readable.
|
||||
- Update guide opens only from the persistent top-right exclamation button or Help. Dismissing never removes the entry. Sidebar labels slide upward on hover/focus; duplicate source-panel and obsolete notice styles were removed. See `docs/architecture/update-guide.md`.
|
||||
|
||||
### Model Source Hub (模型来源中控中心)
|
||||
- Added a unified Model Source Hub under Toolbox for dual-scope (current workflow and full library) model source URL inspection, one-click browser navigation to Civitai/HuggingFace/Liblib/ModelScope, custom source editing, workflow metadata synchronization (`extra.anomalous_model_sources`), and instant canvas `Note` node generation for non-intrusive workflow documentation.
|
||||
|
||||
### Export availability
|
||||
- Removed Prompt Studio's JSON export action and closed the Material Library file-import entry; copying prompts and local saves remain available.
|
||||
- Recipe package import/export is disabled in the UI and API, including both upload inspection and import commit. The verified AMB0/AMB1 workflow Import / Export Center is restored in Toolbox as the only active import/export center. Ordinary downloads and image/workflow hash metadata are unchanged.
|
||||
- Studio source cards automatically follow library additions, edits and deletions, display their origin, and cannot be deleted from the studio. Failed synchronization preserves existing cards; mixer copies remain editable.
|
||||
|
||||
### Material Library
|
||||
- Fixed grouped material filters, Recipe scope/tag filters, and prompt studio imports using actual detail payloads. Fixed side-studio draft reset/return state and guarded recipe drops against canvas changes. Recipe model status no longer treats saved hash verification as current availability.
|
||||
- Separated the lower-left Material Library from the Recipes/Prompt Notes workspace. Added canvas drag-and-drop: parameter materials replace matching node values, while the simplified prompt panel inserts text at the beginning or end, with undo.
|
||||
- Material Library now opens from the lower-left control; Workflow Recipes stays at the upper right. The workflow Import / Export Center is available in Toolbox.
|
||||
- Added Prompt Combinations: ordered general/specific fragments, enable switches, separate positive/negative content, and saved snapshots.
|
||||
- Library cards can apply matching parameters to one selected node through the same transaction as Node Assistant, with seed preservation, native-option validation and guarded undo. Prompt combinations support explicit text replacement or append.
|
||||
- Material details initially expand prompt cards only; other node parameters render on first expansion. Details omit the complete source workflow, which loads separately when opening a full snapshot. Selected-node materials never return their hidden source graph.
|
||||
- Material capture supports PNG workflows, Recipe parameters and Prompt Notes. Applying reference images to LoadImage remains future work.
|
||||
- Added search by name, tag, or node type, type/tag filters, and pagination. Material names and tags can be edited in place without changing their workflow.
|
||||
- Saving the same source image and node selection now prompts before creating another copy. Save labels distinguish full image/workflow snapshots, node parameters, and Prompt Notes.
|
||||
- Shared parameter inspection between the library and image workbench, improved keyboard card access and clipboard failure feedback, and fixed confirmation dialogs being covered by the workbench.
|
||||
|
||||
### Reliability
|
||||
- Material browsing reuses signature-validated summaries and reads full workflows only for matching node types. Gallery pagination shares a short-lived directory snapshot; refresh and deletion invalidate it immediately.
|
||||
- Prompt Notes now use the ComfyUI user directory with recoverable legacy copying, atomic saves, and visible save failures. Material saves stage their files and roll back new assets if persistence fails.
|
||||
- File identity now uses full-file SHA-256 only. BLAKE3 and tensor/header digests cannot become verified file identities. Older offline metadata is reverified on demand while preserving personal notes.
|
||||
|
||||
### 🩺 Model Doctor
|
||||
- **Manual size fallback (模型大小手动兜底)**: A unique same-size local model is now presented for explicit confirmation instead of being applied automatically. Background checks never apply it, while hash/size identity conflicts are rejected and shown in the Doctor dashboard.
|
||||
|
||||
### 🧰 Workflow Recipes
|
||||
- **Scope-aware recipes (局部/整体配方)**: Graphs with open required connections or no runnable output are saved as partial recipes and append to the current canvas. Independently runnable graphs are saved as complete recipes and open in a new workflow canvas.
|
||||
- **Optional save-time model verification (保存时可选模型核验)**: Recipe saving now performs a lightweight cached-identity check. Recognized model references missing identity are listed in the save dialog, while full SHA-256 calculation remains explicitly opt-in. This is especially useful for hash-only Model Doctor categories such as VAE and text encoders.
|
||||
- **Model lifecycle consistency (模型生命周期一致性)**: Partial append remaps and merges node-scoped model Hash records, while applying a local model match updates the workflow value, structured model reference, base-model summary, and Hash index together.
|
||||
- **Recipe-scoped model notes (配方模型备注)**: Each model reference can keep a bounded personal note. Package export asks whether to include these notes, and version comparison reports note and identity changes.
|
||||
- **Material Library MVP (统一素材库 MVP)**: Main Gallery and Recipe gallery images now expose a non-intrusive two-column parameter detail view and can be saved as one curated bundle containing the original image, exact embedded workflow, model provenance, and reusable node blocks. Core metrics remain concise, while a lazy two-level inspector exposes every serialized node widget value, node properties, mode, and volatile-parameter marker on demand. Every node card now has a direct “Save to Library” action; checked nodes share a colocated multi-save action above the list, while the footer is reserved for the full image/workflow snapshot. One generation-settings action saves the sampler and size nodes together, and prompt cards expose topology-based positive/negative detection, manual role overrides, and direct node saving. Saved Material details preserve those overrides and allow returning to automatic detection. Workflow Recipe parameters and saved Parameter Notebooks can now publish all widget-bearing nodes, one node, or an explicit node selection as image-free Recipe Parameter materials. The ambiguous plain-text share action has been removed in favor of a visible Material Library save entry. Focused node materials feed Node Assistant without exposing a misleading full-workflow action. Saved Material cards stay compact and open a focused in-library inspector with the uncropped reference image on the left and scoped model/node parameters on the right; returning releases the full response and detailed DOM. Detail navigation bounds parsed metadata to a 16-entry LRU, cancels obsolete reads, avoids a second full-PNG browser allocation, removes duplicated widget payloads, and defers local-model preview resolution until the Models tab opens. Main Gallery keeps its existing click, drag, delete, and cover-selection behavior. The Workspace Material Library opens complete snapshots in a new canvas, while Node Assistant filters matching blocks by the selected node type, skips volatile seeds, and hands remapped Hash evidence to Model Doctor.
|
||||
|
||||
### ✨ Material capture and simpler pages
|
||||
- 图片、配方参数与提示词笔记均可保存到素材库,保存后直接查看。提示词笔记支持整份组合或仅提示词,并可从素材载入为新笔记。
|
||||
- 图片保存表单、用途调整、节点技术信息、笔记模型区与配方多选改为按需展开;配方参数页去掉重复数值,统一使用“参数方案”名称。
|
||||
|
||||
### ✨ Interface & Access
|
||||
- **Native Configurable Shortcut (原生可配置快捷键)**: Added `Ctrl + Shift + M` as the default shortcut for opening the browser. The plugin's Interface settings show the currently active binding and open ComfyUI's native key recorder for customization. Conflict detection, overwrite confirmation, removal, reserved-key guidance, and modal guards therefore remain centralized without a separate global keyboard listener.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Prompt Studio Preview Popover Safe Zone (提示词预览浮窗全域安全区与防消失机制)**: 解决鼠标从词卡划向右侧浮窗按钮过程中浮窗提前消失的问题。引入全局动态指针安全区判定(`isPointInSafeZone`),将卡片、浮窗(向外各扩展 40px)与空间通道全部纳入安全驻留区,光标移动即实时取消关闭计时;并在定时器回调中执行终审坐标校验;结合 100ms 展开防抖与 `[📌 固定]` 钉住功能,确保用户可从容点击复制与添加按钮。
|
||||
- **Live Language Switching (语言即时切换)**: Changing the plugin language now immediately re-translates the open native Interface settings—including labels, tooltips, and combo choices—as well as the browser UI, without refreshing ComfyUI.
|
||||
|
||||
## v1.56.1 Beta (Interface Preferences & Scan Reliability) — 2026-08-26
|
||||
|
||||
### ✨ Interface & Access
|
||||
- **Native Interface Preferences (原生界面偏好)**: Added one native ComfyUI entry-mode setting with three strictly exclusive choices: floating button, action-bar button beside the run controls, or Extensions menu only. Floating size/style controls now appear only when floating mode is selected. A colocated language preference follows ComfyUI by default or applies a plugin-only Chinese/English override, and the existing in-browser language shortcut updates that same native setting. Entry selection remains authoritative during action-bar redraws, and every entry reuses the same browser instance and open path.
|
||||
|
||||
### 🐛 Reliability & Scan Feedback
|
||||
- **Interrupted Scan Recovery (异常扫描自动恢复)**: Closing ComfyUI during a folder or global scan no longer leaves the browser permanently locked. Versioned scan ownership records distinguish live workers from stale or legacy markers, clean interrupted state automatically, and allow an immediate retry without reinstalling the extension.
|
||||
- **Real Scan Progress (真实扫描进度)**: Folder, custom-selection, and global scans now show a non-blocking localized progress panel backed by the scanner's actual folder/model counts and current filename. Reopening the browser reconnects to the running task through the existing status polling contract.
|
||||
|
||||
## v1.56 Beta (Model Doctor Intelligence & Smart Civitai Routing)
|
||||
|
||||
### 🚀 Major Improvements & Features
|
||||
- **On-Demand Dynamic Hash Resolution (按需动态算哈希与智能认亲)**: Upgraded Model Doctor's fallback resolution engine. When a local candidate model matches the exact byte size of a missing reference but lacks local metadata (e.g., after renaming and deleting `.info` files), the backend dynamically calculates its SHA256 on demand. Upon cryptographic verification, the node is seamlessly resolved and an offline inferred `.info` record is saved locally.
|
||||
- **Dedicated Hash Inspection Modal (🔑 查看哈希透视详情)**: Added a dedicated `🔑 查看哈希 (View Hash)` button on every model card in the Doctor Dashboard. Clicking it opens a crystal-clear inspection modal comparing the workflow-embedded provenance hash & size with local disk hash & size, featuring one-click clipboard copying.
|
||||
- **Smart Civitai by-hash Routing & NSFW Domain Dispatch (C 站精准直达与 .red/.com 智能分流)**: Upgraded the "🌐 Civitai" action buttons across Model Doctor and Workflow Import. Instead of crude search bar queries, it now queries the official Civitai `by-hash` API to resolve exact Model IDs and Version IDs, automatically routing NSFW models to `civitai.red` and safe models to `civitai.com`.
|
||||
- **UNET Default Scan Coverage (UNET 目录扫描补齐)**: Added `models/unet` to the default scanned model types, ensuring UNET / Diffusion Models are consistently indexed during background deep scans.
|
||||
- **Parameter Notebook Renaming (参数笔记本重命名)**: Users can now rename saved parameter notebooks directly from both the recipe sidebar list and the detail heading via inline `✏️` action buttons and modal prompts, safely updating names with atomic writes without disrupting parameter provenance or canvas matching.
|
||||
- **Cross-Platform Path Separator Normalization (跨平台斜杠全链路归一化)**: Fully unified Windows `\` and Unix `/` path handling across workflow serialization, hash retrieval, and node diagnostic matching, preventing lookup misses caused by cross-platform workflow exchange.
|
||||
|
||||
### 🐛 Bug Fixes & UX Polish
|
||||
- Fixed a false-negative rejection issue where size-matching renamed models without local `.info` files were incorrectly rejected as `identity_conflict`.
|
||||
- Fixed duplicate key icons on the Model Doctor Hash Inspection modal header.
|
||||
- Fixed a loop duplication issue in the workflow import modal preflight inspection.
|
||||
- Updated unit test suite with comprehensive coverage for dynamic hash resolution and unet folder discovery.
|
||||
|
||||
## v1.55 Beta (Workflow Recipes Preview)
|
||||
|
||||
> **Beta scope:** Workflow Recipes, Parameter Notebooks, and the recipe-powered Parameter Presets tab in Node Assistant are preview features. The Node Assistant's Actions tab—visual model replacement and LoRA insertion—is not part of this beta. Save the current canvas and back up `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` before updating, importing, restoring, deleting, or applying recipe data. The stable target for these preview features is v1.6.
|
||||
@@ -115,7 +270,11 @@
|
||||
- **Offline Base Model Inference**: Added a zero-API local inference engine! For models downloaded purely from HuggingFace (or private unreleased models) that return a 404 on Civitai, the scanner no longer gives up. It now forcibly parses the .safetensors structure and uses **Tensor Fingerprinting** (e.g., detecting double_blocks.0.img_attn for Flux) to accurately deduce the underlying base architecture with 100% precision.
|
||||
- **Universal UI Integration**: Successfully inferred offline models are dynamically assigned a virtual .info payload (ID: -1). This instantly grants them full VIP access to the frontend ecosystem—they seamlessly appear in the Cross-Folder Radar, interact perfectly with the bilingual Notebook, and support one-click Auto-Inject loaders, all completely completely offline!
|
||||
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## v2.0.0 (The Workflow & UI Evolution Update)
|
||||
### 🚀 Major Features
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Gemini 工作入口
|
||||
|
||||
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构。
|
||||
|
||||
随后阅读 [ARCHITECTURE.md](ARCHITECTURE.md),按其中的阅读地图加载与当前任务有关的专题。
|
||||
|
||||
不要只凭当前任务描述直接追加实现。先确认现有责任模块、状态来源、关闭清理和样式规则;完成后按规范验证并报告结果。
|
||||
|
||||
如果当前工具没有加载本文件,任务发起者应明确要求 Gemini 阅读 `GEMINI.md` 和 `AGENTS.md`。文件的存在不代表工具已实际读取,更不代表检查已自动执行。
|
||||
|
||||
前端设计任务另读 [前端设计与协作方案](docs/plans/frontend-design-brief.md);具体功能计划按该文链接进入,不使用历史视觉建议作为当前任务单。
|
||||
@@ -1,159 +1,211 @@
|
||||
# Anomalous Model Browser
|
||||
<div align="center">
|
||||
|
||||
[](CHANGELOG.md) [](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
# 🚀 Anomalous Model Browser
|
||||
|
||||
[English](#english) | [中文](#中文)
|
||||
**A Comprehensive Creative Workspace & Model Manager for ComfyUI**
|
||||
*零依赖 C 站元数据抓取 · 智能模型医生 · 可视化节点助手 · 工作流配方与参数笔记*
|
||||
|
||||
<br/>
|
||||
|
||||
[](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
[](https://youtu.be/hAvsj7uiaCw)
|
||||
|
||||
<br/>
|
||||
|
||||
[**English**](#-english) | [**中文说明**](#-中文)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<h2 id="english">🇬🇧 English</h2>
|
||||
|
||||
> A comprehensive Creative Workspace and Model Manager for ComfyUI. Features zero-dependency Civitai scraping, an auto-resolving Model Doctor, a visual Node Assistant, lossless Workflow Exchange, and reusable Workflow Recipes with Parameter Notebooks.
|
||||
> **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.
|
||||
|
||||
> [!WARNING]
|
||||
> **Beta features and data protection:** Workflow Recipes and only the recipe-powered **Parameter Presets** tab inside Node Assistant are currently in testing; the Node Assistant's **Actions** tab is not part of this beta. Before updating the plugin, importing someone else's recipe, restoring or deleting recipe data, or applying saved parameters, save the current canvas and back up `workflows/anomalous_recipes` and `workflows/anomalous_parameters` inside your ComfyUI user directory. Recipe model-preview snapshots do not contain model files and are not model backups.
|
||||
### 🎬 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/)
|
||||
|
||||
### 🌟 Core Capabilities
|
||||
|
||||
| 🚀 Feature | 💡 Description |
|
||||
| 🚀 Primary View | 💡 Description |
|
||||
| :--- | :--- |
|
||||
| **Comprehensive Model Management** | Automatically extract Base Model architectures, cover images, trigger words, and author info by scanning model hashes. Customize model names, notes, and covers. Safely delete unwanted models or quickly replace them visually with cover icons. |
|
||||
| **Workflow Node Repair (🩺)** | As long as a workflow/image was exported with this plugin active, the **Model Doctor (🩺)** can auto-detect missing nodes and replace them with correct local paths. Stubborn models? Use "Deep Hash Scan" or get Civitai links. |
|
||||
| **Node Assistant & Recipe Presets (🤖)** | Select a canvas node to visually replace a model, safely insert a LoRA into a compatible chain, or load same-type node parameters saved by Workflow Recipes. A saved KSampler note, for example, can apply sampler, scheduler, steps, CFG, and denoise in one click while volatile seeds are ignored. |
|
||||
| **Workflow Recipes (🧰)** | Save a complete workflow with a cover, notes, tags, model identity, prompts, sampler settings, node parameters, matching output images, and version history. Append recipes to the canvas, compare output/version parameters, match foreign model references to local files, and import or export portable recipe packages. |
|
||||
| **Smart Notebook (📑)** | Leveraging scanned local architectures, it offers rapid matching between Checkpoints, UNet, and compatible LoRAs. Features a built-in translation tool, auto-chunks prompts into tags, and supports 1-click canvas deployment. |
|
||||
| **History Gallery (🖼️)** | Natively reads your local `output` folder. Supports 1-click viewing, deleting, and mouse-wheel zooming. Drag and drop any generated image directly onto the canvas to instantly load its embedded workflow! |
|
||||
| **Additional Features** | Click the top-right `➕` of any model card to deploy it to the canvas. The UI supports side-docking to leave space for your canvas operations. |
|
||||
| **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. |
|
||||
|
||||
### 📖 Step-by-Step Operating Guide
|
||||
### 📦 Quick Installation
|
||||
|
||||
#### 1. Initialization (Scan Wizard) 🔄
|
||||
* **Location**: The Scan icon (**🔄**) at the bottom left of the plugin interface.
|
||||
* **Operation**: This is the most important prerequisite. Open the Scan Wizard, select your configuration, and execute the scan. Ensure your network is connected.
|
||||
* **Result**: This activates all core features of the plugin by building your local model database.
|
||||
1. Open your terminal in the ComfyUI `custom_nodes` directory:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. Restart ComfyUI. *(Alternatively, install via **ComfyUI Manager** by searching for `Anomalous Model Browser`)*
|
||||
3. **Updating**: click the **!** button in the browser header; the current version is shown at the top, and **Check for updates / switch version** opens the version panel. From there you can update to the newest published release, roll back to an earlier one, or return to the latest version, then restart ComfyUI. The plugin never checks for updates on its own.
|
||||
|
||||
#### 2. Workflow Repair (Model Doctor) 🩺
|
||||
* **Location**: The second Stethoscope icon (**🩺**) from the bottom left.
|
||||
* **Operation**: When you import someone else's workflow and the nodes turn red due to missing paths, click the Model Doctor. It will intelligently identify all broken nodes and replace them with your correct local paths.
|
||||
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).
|
||||
|
||||
#### 3. Visual Swapping (Node Assistant) 🤖
|
||||
* **Location**: The third Robot icon (**🤖**) from the bottom left.
|
||||
* **Actions**: Select a node on the ComfyUI canvas. For model nodes, the Actions tab can visually replace the current model or insert a LoRA before/after a compatible MODEL + CLIP chain. Picker cards support previews, model-category/base-model badges, folders, full-path search, sorting, and safe handling of ambiguous branches.
|
||||
* **Parameter Presets (Beta)**: Switch to **Parameter Presets** to read parameter notes associated with Workflow Recipes. Presets are grouped by recipe and contain saved values for the same node type as the selected node. Clicking a preset applies it transactionally to that one node; runtime-changing values such as seeds are ignored.
|
||||
<br/>
|
||||
|
||||
#### 4. Workflow Recipes & Parameter Notebooks 🧰
|
||||
* **Location**: Open **Creative Workspace (📑)** and switch from **Prompt Notes** to **Workflow Recipes**.
|
||||
* **Save and reuse**: Save the complete current graph with a cover, name, tags, notes, model references, prompts, sampler settings, and bounded node summaries. Recipes can be appended to the current canvas without replacing it.
|
||||
* **Details**: Overview shows model composition and reproducibility information. Models can be matched to local files by hash, size, and model category rather than the author's path; Civitai origin names/links and local previews can be retained when available.
|
||||
* **Parameter Notebook**: Browse the recipe's saved values, copy or expand long fields, create an edited parameter note, or read a fresh matching canvas into a new note. **Apply to Current Workflow** first checks that the recipe skeleton matches, then updates safe widgets; volatile values such as seeds are ignored. Stored notes can be deleted individually. Unknown third-party prompt roles can be labelled manually.
|
||||
* **Gallery and Versions**: Find historical outputs with the same node structure, open an image to compare parameter differences, set an output as the recipe cover, compare recipe versions, or restore an older version while archiving the current one.
|
||||
* **Sharing**: Export/import portable recipe packages with optional model-preview snapshots and history. Identity hashes can be removed during export. A preview snapshot is presentation data only—it never includes a model file.
|
||||
<details>
|
||||
<summary><b>📖 Click to Expand: Step-by-Step Operating Guide</b></summary>
|
||||
|
||||
#### 5. Settings Panel ⚙️
|
||||
* **Location**: The Gear icon (**⚙️**) at the bottom left.
|
||||
* **Operation**: Adjust the UI language and main UI font size. Open **Model Settings** to choose always-play or hover-play video covers and optimized card thumbnails or original covers. Detail pages continue to use the original cover. **Folder Manager** can hide folders from both the sidebar and background reads, and reorder them. **Help** contains the current safety and feature guide.
|
||||
<br/>
|
||||
|
||||
#### 6. Top Navigation & Prompt Notes 📑
|
||||
**Location**: The top tabs: **Models (📦)**, **Gallery (🖼️)**, **Creative Workspace (📑)**, and **Dock Side (◧)**.
|
||||
#### 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.
|
||||
|
||||
**Prompt Notes Operation**:
|
||||
1. Open **Creative Workspace (📑)**, choose **Prompt Notes**, then click the **➕ (New)** button and confirm.
|
||||
2. Select a Base Model (e.g., SD1.5). You can then select compatible Main Models and LoRAs.
|
||||
3. Click **📝 Edit Raw / Paste** to paste your prompts. Select the language at the top-left for bilingual translation.
|
||||
4. After confirming, the plugin will automatically chunk your prompt into tags. You can find/replace or directly edit tags.
|
||||
5. Click **🚀 Send to Canvas** to automatically wire and deploy the Checkpoint, LoRA, and CLIP Text Encode directly to your canvas!
|
||||
#### 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`).
|
||||
|
||||
---
|
||||
#### 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.
|
||||
|
||||
### 📦 Installation
|
||||
#### 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.
|
||||
|
||||
Open your terminal, go to your ComfyUI `custom_nodes` folder, and run:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
> **Note**: Restart ComfyUI after cloning. Alternatively, search for `Anomalous Model Browser` inside the ComfyUI Manager and click Install!
|
||||
#### 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.
|
||||
|
||||
---
|
||||
#### 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.
|
||||
|
||||
<h2 id="中文">🇨🇳 中文</h2>
|
||||
|
||||
> 一个综合性的 ComfyUI 创作工作台与模型管家。集成零依赖的 C 站信息抓取、支持自动修复的模型医生、可视化节点助手、无损工作流交互,以及支持参数复用的工作流配方与笔记本体系。
|
||||
#### 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.
|
||||
|
||||
> [!WARNING]
|
||||
> **测试功能与数据保护:** 工作流配方以及节点助手中由配方驱动的“参数笔记本”目前属于测试功能;节点助手的“动作”页不在测试范围内。更新插件、导入他人配方、恢复或删除配方数据、应用已保存参数之前,请先保存当前画布,并备份 ComfyUI 用户目录中的 `workflows/anomalous_recipes` 与 `workflows/anomalous_parameters`。配方中的模型预览快照不包含模型文件,不能替代模型备份。
|
||||
> **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.
|
||||
|
||||
### 🌟 核心功能特性
|
||||
|
||||
| 🚀 功能板块 | 💡 详细说明 |
|
||||
| :--- | :--- |
|
||||
| **全方位模型管理** | 通过扫描哈希值,自动获取模型的基础架构、封面、提示词、作者简介等信息。支持自定义名称、备注和封面。点开模型即刻呈现封面图标,支持看图一键替换模型,彻底告别复杂的模型路径! |
|
||||
| **节点智能修复 (🩺)** | 凡是由本插件导出的工作流和图片,在载入他人工作流时出现节点爆红,只需点开**模型医生 (🩺)**,即可自动识别正确的模型路径并一键替换。内置深度扫描与 C 站直达链接。 |
|
||||
| **节点助手与配方参数 (🤖)** | 选中画布节点后,可视化替换模型、向兼容链路安全插入 LoRA,或读取工作流配方保存的同类型节点参数。例如保存过的 K 采样器笔记可一键应用采样器、调度器、步数、CFG 与降噪,同时忽略易变的种子。 |
|
||||
| **工作流配方 (🧰)** | 保存完整工作流及封面、备注、标签、模型身份、提示词、采样参数、节点参数、匹配出图与版本历史。支持追加到画布、比较出图/版本参数、将他人的模型引用匹配到本地文件,以及导入导出便携配方包。 |
|
||||
| **智能笔记本 (📑)** | 基于本地基础架构,提供快速的架构匹配功能,极速选择兼容的主模型与 LoRA。内置强大的翻译功能,自动将提示词分块打上标签,实现双语对照,并支持一键发布组装到工作流画布中。 |
|
||||
| **原生图库管理 (🖼️)** | 无缝读取本地的 `output` 文件夹。支持鼠标滚轮缩放、一键查看和安全删除。更绝的是,您可以**直接将图片拖动到画布上,瞬间原地加载内嵌的工作流!** |
|
||||
| **其它便捷功能** | 点击模型卡片右上角的 `➕` 号,快捷将节点发布到画布。支持侧边栏停靠 (Dock),将界面吸附在左侧,为您的画布留出充足的操作空间。 |
|
||||
|
||||
### 📖 标准操作指南
|
||||
|
||||
为了让插件发挥最大效能,请按照以下流程进行操作:
|
||||
|
||||
#### 1. 前置准备 (扫描向导) 🔄
|
||||
* **具体位置**:界面左侧底部的 **扫描图标 (🔄)**。
|
||||
* **操作步骤**:这是最重要的一步!请先打开扫描向导,根据需求选择扫描配置并执行扫描(切记保持网络畅通)。扫描完成后,您的本地数据库建立完毕,插件的各项核心功能全面激活。
|
||||
|
||||
#### 2. 拯救爆红 (模型医生) 🩺
|
||||
* **具体位置**:左侧底部第二个 **听诊器按钮 (🩺)**。
|
||||
* **操作步骤**:当导入别人使用该插件输出的工作流或者图片时,若发现模型路径爆红报错,点击模型医生,它就能智能识别报错节点,并一键实现正确路径的替换。
|
||||
|
||||
#### 3. 选中交互 (节点助手) 🤖
|
||||
* **具体位置**:左侧底部第三个 **机器人按钮 (🤖)**。
|
||||
* **动作**:打开后在 ComfyUI 画布上选中节点。对于模型节点,“动作”页可以可视化替换当前模型,或在兼容的 MODEL + CLIP 链前后插入 LoRA。选择器提供模型预览、模型类别/基础模型标记、文件夹、完整路径搜索、排序与多分支安全保护。
|
||||
* **参数笔记本(测试)**:切换到“参数笔记本”,可以读取与工作流配方关联的参数笔记。内容会按配方分组,只展示和当前选中节点类型相同的保存记录。点击一条记录即可事务式应用到该节点;种子等每次运行都会变化的值会被忽略。
|
||||
|
||||
#### 4. 工作流配方与参数笔记本 🧰
|
||||
* **具体位置**:打开 **创作工作台 (📑)**,从“提示词笔记”切换到 **工作流配方**。
|
||||
* **保存与复用**:保存当前完整节点图,并附带封面、名称、标签、备注、模型引用、提示词、采样设置与受限节点摘要。配方可以追加到当前画布,不会覆盖当前内容。
|
||||
* **详细信息**:概览展示模型组成与复现信息。模型匹配根据哈希、文件大小与模型类别查找本地文件,不依赖原作者路径;可用时还会保留 C 站官方名称/链接与本地预览。
|
||||
* **参数笔记本**:查看配方保存的参数,复制或展开长内容,新建可编辑参数笔记,或者从骨架匹配的当前画布读取全新参数。点击“应用到当前工作流”时会先检查配方骨架,再更新安全控件;种子等易变值会被忽略。保存的参数笔记可以单独删除;无法可靠识别的第三方提示词节点可以手动标注正负角色。
|
||||
* **图库与版本**:查找节点结构相同的历史出图,打开图片比较参数差异,将出图设为配方封面,比较配方历史版本,或在自动归档当前版本后恢复旧版本。
|
||||
* **分享**:导入导出便携配方包,可选择包含模型预览快照与历史版本,也可在导出时移除身份哈希。预览快照只是展示数据,绝不会包含模型文件。
|
||||
|
||||
#### 5. 个性化配置 (设置面板) ⚙️
|
||||
* **具体位置**:左侧底部的 **齿轮按钮 (⚙️)**。
|
||||
* **操作步骤**:在此可以调节语言和主页面字体大小。进入 **模型设置** 后,可选择视频封面始终播放或悬停播放,也可选择卡片使用流畅缩略图或原始封面;模型详情页始终使用原始封面。**文件夹管理**可以让指定目录同时退出侧栏和后台读取,并支持调整顺序;**帮助**中会持续更新安全提醒与功能说明。
|
||||
|
||||
#### 6. 顶部导航与提示词笔记 📑
|
||||
**具体位置**:右侧顶部的按钮分别为 **模型 (📦)**、**图库 (🖼️)**、**创作工作台 (📑)**、**停靠侧边栏 (◧)**。
|
||||
|
||||
**提示词笔记操作步骤**:
|
||||
1. 打开**创作工作台 (📑)**,进入“提示词笔记”,点击新建 **➕** 号并确认。
|
||||
2. 首先选择基础模型(例如 SD1.5),系统会过滤出兼容的主模型与对应的 LoRA 模型供你选择。
|
||||
3. 点击下方的 **📝 纯文本/粘贴** 复制提示词进去,选择左上角的语言即可实现双语对照翻译。
|
||||
4. 确认后,插件会自动生成标签 (Tags),支持查找替换,也支持直接双击修改单个标签。
|
||||
5. 最后,点击下方的 **🚀 发送到画布**,系统会将打包好的主模型、LoRA 和 CLIP,一键全自动连线发布到画布!
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 📦 安装指南
|
||||
<h2 id="中文">🇨🇳 中文说明</h2>
|
||||
|
||||
打开命令行终端,进入 ComfyUI 的 `custom_nodes` 文件夹,执行以下命令:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
> **注意**:克隆完成后,重启 ComfyUI 即可。您也可以直接在 ComfyUI Manager(管理器)中搜索 `Anomalous Model Browser` 并一键点击安装!
|
||||
> **Anomalous Model Browser** 是为 ComfyUI 设计的综合创作工作台与模型管理套件。系统集成了基于文件哈希的元数据提取、工作流缺失模型自愈(模型医生)、画布节点可视化换模与 LoRA 插入(节点助手)、支持多态画布拖拽的统一素材库,以及工作流配方体系。
|
||||
|
||||
### 🎬 视频演示与教程
|
||||
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
|
||||
|
||||
### 🌟 核心特性速览
|
||||
|
||||
| 🚀 核心视图与工具 | 💡 详细说明 |
|
||||
| :--- | :--- |
|
||||
| **模型浏览 (📦 模型)** | 本地模型管理网格。左侧树状目录分类(checkpoints、unet、loras),卡片展示 C 站元数据、触发词、基础架构与高清封面,支持看图换模与自定义备注。 |
|
||||
| **出图图库 (🖼️ 图库)** | 原生读取本地 `output` 文件夹,支持滚轮缩放与安全删除;顶部搜索栏支持按提示词、模型名、LoRA、seed、文件名或模型哈希多维检索,生成可点击独立移除的搜索标签块;拖拽图片原地还原工作流。 |
|
||||
| **工作流配方 (🪡 工作流)** | 工作流配方工坊。支持保存完整工作流或局部子图,实时显示模型就绪状态(如“全部模型就绪”);支持一键在新画布打开或直接拖拽卡片到画布空白处载入。 |
|
||||
| **实用工具箱与底栏 (🧰)** | 侧边栏左下角第一个图标为工具箱,底栏直达 4 大常用工具(扫描向导、模型医生、节点助手、素材库)与全局设置;点击工具箱弹出浮窗面板,提供 5 大扩展工具(导入导出、提示词工坊、翻译助手、模型来源、提示词笔记)。 |
|
||||
| **节点智能修复 (模型医生 🩺)** | 导入他人工作流发生节点爆红时,自动比对本地模型 SHA256 哈希与字节大小,一键替换为有效路径,支持哈希透视比对。 |
|
||||
| **节点助手与参数预设 (🤖)** | 画布选中节点即可可视化选图换模型、向兼容链路插入 LoRA,或一键应用工作流配方中沉淀的节点参数(自动跳过易变种子)。 |
|
||||
| **统一素材库与多态拖拽 (✨)** | 原生 PNG 资产一键打包保存(完整工作流、模型血缘与节点参数);多态拖拽:拖至空白画布还原工作流或生成原生红绿提示词节点,拖至已有节点智能注入参数。 |
|
||||
|
||||
### 📦 快速安装
|
||||
|
||||
1. 在 ComfyUI 的 `custom_nodes` 目录下打开终端执行:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 点击安装*)
|
||||
3. **更新插件**:点击顶栏的 **!** 按钮,顶部会显示当前版本,点 **检查更新 / 切换版本** 进入版本面板。可以一键更新到最新发布版本、回退到以前的版本,或回到最新版,之后重启 ComfyUI 即可。插件不会自己联网检查更新。
|
||||
|
||||
可以按 **Ctrl + Shift + M**、点击画布上的悬浮 **📦**,或从 ComfyUI 顶部菜单 **扩展 → Anomalous Model Browser** 打开。**ComfyUI 设置 → Anomalous Model Browser → 界面** 会显示当前快捷键,并可直接打开 ComfyUI 原生录入窗口修改,因此冲突与保留按键检查仍然只有一套。该页面还可以让插件语言跟随 ComfyUI 或单独固定为中文/English,并严格三选一显示悬浮入口、运行按钮旁的原生顶部入口或仅使用扩展菜单。只有选择悬浮入口时才显示大小和样式选项。顶部栏重绘不会擅自改变所选模式,扩展菜单命令则始终保留,便于恢复设置。
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><b>📖 点击展开:标准操作指南(图文步骤)</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
#### 1. 顶部主工作区导航 (三大顶栏视图)
|
||||
插件顶部居中提供 3 个核心工作区入口:
|
||||
* **模型 (📦)**:主模型浏览器。左侧为模型文件夹分类树(`checkpoints`、`unet`、`loras` 等),中间展示模型卡片网格、封面图预览与触发词。
|
||||
* **图库 (🖼️)**:出图历史浏览器。原生读取本地 `output` 目录。顶部搜索栏支持输入提示词、模型名、LoRA、seed、文件名或模型哈希,回车即可生成独立的搜索标签块,点击标签块上的 `×` 可快速移除该过滤项;直接将图片拖拽至 ComfyUI 画布可原地恢复内嵌工作流。
|
||||
* **工作流 (🪡)**:工作流配方工坊。提供 `[全部]`、`[完整工作流]`、`[局部子图]` 子过滤器与标签筛选;点击右上角 `保存当前工作流` 即可将当前画布打包为配方;直接按住配方卡片拖拽到 ComfyUI 画布空白处释放即可立即载入。
|
||||
|
||||
#### 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 站封面、标签与架构信息。
|
||||
|
||||
#### 4. 拯救爆红 (🩺 模型医生)
|
||||
* **入口位置**:底部快捷栏第 3 个图标。
|
||||
* **操作步骤**:载入他人工作流或图片出现红框缺失报错时,点击模型医生即可自动比对本地模型哈希与字节大小,一键批量映射为本地正确路径。卡片上的“查看哈希”可展开内嵌指纹与磁盘文件的 SHA256 逐项比对面板。
|
||||
|
||||
#### 5. 选中交互与预设 (🤖 节点助手)
|
||||
* **入口位置**:底部快捷栏第 4 个图标。
|
||||
* **动作功能**:在画布选中模型节点后,可在“动作”页查看高清预览图与触发词,支持看图一键替换模型或在兼容链路前后插入 LoRA。
|
||||
* **参数预设**:切换至“参数预设”页,可读取工作流配方中同类型节点的保存参数并一键注入画布(自动保留种子等易变数值)。
|
||||
|
||||
#### 6. 统一素材库与多态画布拖拽 (✨ 素材库与多态拖拽)
|
||||
* **入口位置**:底部快捷栏第 5 个图标。
|
||||
* **保存素材**:在出图图库或配方详情中打开任意生成 PNG 的参数面板,可一键将图片、完整工作流、模型依赖与可复用节点参数打包收藏。
|
||||
* **多态画布拖拽**:
|
||||
- 将工作流素材直接拖拽至空白画布,即可原地恢复并打开完整工作流。
|
||||
- 将纯提示词素材拖拽至空白画布,会自动创建带有正负区分色(深红 `#532323` 为负向 / 墨绿 `#235327` 为正向)的原生 `CLIPTextEncode` 节点。
|
||||
- 将素材拖拽至画布已有节点上时,会自动通过语义探针将匹配参数注入目标节点中。
|
||||
* **本地存储**:素材存储于 ComfyUI 用户目录下的 `workflows/anomalous_materials`,支持原子写入。
|
||||
|
||||
#### 7. 全局设置面板 (⚙️ 设置)
|
||||
* **入口位置**:侧边栏左下角最右侧第 6 个图标(**齿轮图标 ⚙️**)。
|
||||
* **个性调节**:支持中英文界面切换、字体大小缩放、视频封面悬停/常开播放、缩略图优化与目录黑名单管理。
|
||||
|
||||
> [!WARNING]
|
||||
> **测试功能数据安全提醒:** 工作流配方、素材库与参数预设目前属于活跃测试阶段,更新插件前建议备份 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 📝 License & Branding (开源与品牌声明)
|
||||
|
||||
**Code License (代码授权)**
|
||||
The source code of this project is released under the [MIT License](LICENSE). You are free to use, modify, and distribute the code.
|
||||
本项目的源代码基于 MIT 许可证开源,您可以自由地使用、修改和分发代码。
|
||||
|
||||
**Branding & Trademarks (品牌与商标保护)**
|
||||
The MIT License covers the repository's code, documentation, stylesheets, and ordinary UI resources. The name **Anomalous Model Browser** and the official project logo identify official releases; the MIT License does not imply that a modified distribution is official or endorsed. Publicly distributed forks should use a distinct primary name and logo, while they may truthfully say that they are based on Anomalous Model Browser. See the [Trademark and Brand Policy](TRADEMARKS.md).
|
||||
|
||||
MIT 许可证适用于本仓库的代码、文档、样式表和普通 UI 资源。名称 **Anomalous Model Browser** 与官方项目 Logo 用于识别官方版本;采用 MIT 许可证并不表示修改后的发行版属于官方版本或获得认可。公开分发的修改版本应使用不同的主要名称与 Logo,但可以如实说明其基于 Anomalous Model Browser。详见[商标与品牌政策](TRADEMARKS.md)。
|
||||
* **Code License (代码授权)**: The source code is released under the [MIT License](LICENSE). 本项目源代码基于 MIT 许可证开源,可自由使用、修改与分发。
|
||||
* **Branding & Trademarks (商标与品牌保护)**: The name **Anomalous Model Browser** and the official logo identify official releases. Forks should use distinct names/branding. 详见 [Trademark and Brand Policy](TRADEMARKS.md)。
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
from .models import *
|
||||
from . import (
|
||||
folder_types, gallery_routes, materials, media_routes, model_catalog,
|
||||
model_media, model_metadata, model_resolution, recipe_packages, recipes,
|
||||
translation_routes, version_manager,
|
||||
)
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
from .notebooks import *
|
||||
from .parameters import *
|
||||
from .recipes import *
|
||||
from .recipe_packages import *
|
||||
from .utils 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):
|
||||
app.router.add_get('/anomalous/folders', api_get_folders)
|
||||
app.router.add_get('/anomalous/all_folder_types', api_get_all_folder_types)
|
||||
app.router.add_get('/anomalous/models', api_get_models)
|
||||
app.router.add_get('/anomalous/all_scan_models', api_get_all_scan_models)
|
||||
app.router.add_get('/anomalous/batch_select', api_batch_select)
|
||||
app.router.add_get('/anomalous/image', api_serve_image)
|
||||
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', api_find_model)
|
||||
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', api_delete_model)
|
||||
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', api_compatible_models)
|
||||
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)
|
||||
@@ -30,42 +44,57 @@ def setup_routes(app):
|
||||
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', api_get_recipes)
|
||||
app.router.add_get('/anomalous/recipe_full', api_get_recipe_full)
|
||||
app.router.add_get('/anomalous/recipe_asset', api_get_recipe_asset)
|
||||
app.router.add_get('/anomalous/recipe_gallery', api_get_recipe_gallery)
|
||||
app.router.add_get('/anomalous/recipe_parameter_gallery', api_get_recipe_parameter_gallery)
|
||||
app.router.add_get('/anomalous/recipe_gallery_compare', api_get_recipe_gallery_compare)
|
||||
app.router.add_post('/anomalous/save_recipe', api_save_recipe)
|
||||
app.router.add_post('/anomalous/update_recipe', api_update_recipe)
|
||||
app.router.add_post('/anomalous/set_recipe_gallery_cover', api_set_recipe_gallery_cover)
|
||||
app.router.add_post('/anomalous/delete_recipe', api_delete_recipe)
|
||||
app.router.add_get('/anomalous/recipe_history', api_get_recipe_history)
|
||||
app.router.add_get('/anomalous/recipe_version', api_get_recipe_version)
|
||||
app.router.add_post('/anomalous/restore_recipe_version', api_restore_recipe_version)
|
||||
app.router.add_post('/anomalous/refresh_recipe_identity', api_refresh_recipe_identity)
|
||||
app.router.add_post('/anomalous/export_recipe_package', api_export_recipe_package)
|
||||
app.router.add_post('/anomalous/import_recipe_package_inspect', api_import_recipe_package_inspect)
|
||||
app.router.add_post('/anomalous/import_recipe_package_commit', api_import_recipe_package_commit)
|
||||
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)
|
||||
|
||||
app.router.add_post('/anomalous/translate', api_translate)
|
||||
app.router.add_get('/anomalous/base_models', api_base_models)
|
||||
app.router.add_get('/anomalous/gallery_images', api_get_gallery_images)
|
||||
app.router.add_post('/anomalous/delete_gallery_image', api_delete_gallery_image)
|
||||
app.router.add_get('/anomalous/resolve_hash', api_resolve_hash)
|
||||
app.router.add_post('/anomalous/resolve_hash_batch', api_resolve_hash_batch)
|
||||
app.router.add_get('/anomalous/all_hashes', api_get_all_hashes)
|
||||
# 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', api_clear_cache)
|
||||
app.router.add_post('/anomalous/update_metadata', api_update_metadata)
|
||||
app.router.add_post('/anomalous/set_custom_cover', api_set_custom_cover)
|
||||
app.router.add_post('/anomalous/upload_custom_cover', api_upload_custom_cover)
|
||||
app.router.add_get('/anomalous/model_images', api_get_model_images)
|
||||
app.router.add_post('/anomalous/resolve_paths_to_previews', api_resolve_paths_to_previews)
|
||||
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)
|
||||
version_manager.register_routes(app)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Configured model-folder visibility and scan-scope helpers."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
|
||||
DEFAULT_PHYSICAL_FOLDER_NAMES = {
|
||||
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae'
|
||||
}
|
||||
|
||||
|
||||
def get_folder_view_mode():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
return cfg.get("folder_view_mode", "abstract")
|
||||
except:
|
||||
pass
|
||||
return "abstract"
|
||||
|
||||
|
||||
def get_all_physical_basenames():
|
||||
basenames = set()
|
||||
for t in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for p in paths:
|
||||
bn = os.path.basename(os.path.normpath(p))
|
||||
if bn: basenames.add(bn)
|
||||
except:
|
||||
pass
|
||||
return list(basenames)
|
||||
|
||||
|
||||
def get_active_physical_basenames():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
all_bns = get_all_physical_basenames()
|
||||
active = []
|
||||
configured = set()
|
||||
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
pfc = cfg.get("physical_folders_config")
|
||||
if pfc and isinstance(pfc, list):
|
||||
for item in pfc:
|
||||
bn = item.get("type")
|
||||
if bn in all_bns:
|
||||
configured.add(bn)
|
||||
if item.get("visible", True):
|
||||
active.append(bn)
|
||||
|
||||
for bn in all_bns:
|
||||
if bn not in configured and bn in DEFAULT_PHYSICAL_FOLDER_NAMES:
|
||||
active.append(bn)
|
||||
return active
|
||||
except:
|
||||
pass
|
||||
|
||||
return [bn for bn in all_bns if bn in DEFAULT_PHYSICAL_FOLDER_NAMES]
|
||||
|
||||
|
||||
def get_active_model_roots():
|
||||
"""Return the folder-manager-visible roots with stable model locators.
|
||||
|
||||
This is intentionally separate from the scanner scope. The source hub only
|
||||
needs directory discovery and must preserve ``type``/``path_idx`` so later
|
||||
metadata writes resolve back to the exact ComfyUI root.
|
||||
"""
|
||||
mode = get_folder_view_mode()
|
||||
active_types = set(get_active_folder_types()) if mode != "physical" else None
|
||||
active_basenames = (
|
||||
set(get_active_physical_basenames()) if mode == "physical" else None
|
||||
)
|
||||
roots = []
|
||||
seen_realpaths = set()
|
||||
|
||||
for folder_type in folder_paths.folder_names_and_paths.keys():
|
||||
if active_types is not None and folder_type not in active_types:
|
||||
continue
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type) or []
|
||||
except Exception:
|
||||
continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if active_basenames is not None:
|
||||
basename = os.path.basename(os.path.normpath(base_dir))
|
||||
if basename not in active_basenames:
|
||||
continue
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
real_dir = os.path.realpath(base_dir)
|
||||
real_key = os.path.normcase(real_dir)
|
||||
if real_key in seen_realpaths:
|
||||
continue
|
||||
seen_realpaths.add(real_key)
|
||||
roots.append({
|
||||
"type": folder_type,
|
||||
"path_idx": path_idx,
|
||||
"base_dir": real_dir,
|
||||
})
|
||||
return roots
|
||||
|
||||
|
||||
def get_active_scan_paths():
|
||||
mode = get_folder_view_mode()
|
||||
paths = set()
|
||||
|
||||
if mode == "physical":
|
||||
active_bns = set(get_active_physical_basenames())
|
||||
for t in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
ps = folder_paths.get_folder_paths(t)
|
||||
if not ps: continue
|
||||
for p in ps:
|
||||
if not os.path.exists(p): continue
|
||||
bn = os.path.basename(os.path.normpath(p))
|
||||
if bn in active_bns:
|
||||
paths.add(os.path.realpath(p))
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
active_types = get_active_folder_types()
|
||||
for t in active_types:
|
||||
try:
|
||||
ps = folder_paths.get_folder_paths(t)
|
||||
if not ps: continue
|
||||
for p in ps:
|
||||
if not os.path.exists(p): continue
|
||||
paths.add(os.path.realpath(p))
|
||||
except:
|
||||
pass
|
||||
|
||||
return list(paths)
|
||||
|
||||
|
||||
def get_active_folder_types():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
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']
|
||||
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
ftc = cfg.get("folder_types_config")
|
||||
if ftc and isinstance(ftc, list):
|
||||
active = []
|
||||
configured_types = set()
|
||||
for item in ftc:
|
||||
t = item.get("type")
|
||||
if t in all_types:
|
||||
configured_types.add(t)
|
||||
if item.get("visible", True):
|
||||
active.append(t)
|
||||
# For any newly registered types not in config, do not show them by default
|
||||
# unless they are in default_types
|
||||
for t in all_types:
|
||||
if t not in configured_types and t in default_types:
|
||||
active.append(t)
|
||||
return active
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback if no config exists
|
||||
active = [t for t in default_types if t in all_types]
|
||||
return active
|
||||
|
||||
|
||||
async def api_get_all_folder_types(request):
|
||||
mode = get_folder_view_mode()
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
result = []
|
||||
|
||||
if mode == "physical":
|
||||
all_bns = get_all_physical_basenames()
|
||||
configured = set()
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
pfc = cfg.get("physical_folders_config")
|
||||
if pfc and isinstance(pfc, list):
|
||||
for item in pfc:
|
||||
bn = item.get("type")
|
||||
if bn in all_bns:
|
||||
configured.add(bn)
|
||||
result.append({
|
||||
"type": bn,
|
||||
"visible": item.get("visible", True)
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
for bn in all_bns:
|
||||
if bn not in configured:
|
||||
result.append({
|
||||
"type": bn,
|
||||
"visible": bn in DEFAULT_PHYSICAL_FOLDER_NAMES
|
||||
})
|
||||
else:
|
||||
# Abstract mode
|
||||
all_types = list(folder_paths.folder_names_and_paths.keys())
|
||||
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
|
||||
configured_types = set()
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
ftc = cfg.get("folder_types_config")
|
||||
if ftc and isinstance(ftc, list):
|
||||
for item in ftc:
|
||||
t = item.get("type")
|
||||
if t in all_types:
|
||||
configured_types.add(t)
|
||||
result.append({
|
||||
"type": t,
|
||||
"visible": item.get("visible", True)
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
for t in all_types:
|
||||
if t not in configured_types:
|
||||
result.append({
|
||||
"type": t,
|
||||
"visible": t in default_types
|
||||
})
|
||||
|
||||
return web.json_response({"folder_types": result, "folder_view_mode": mode})
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Generated-output gallery indexing and mutation routes."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .image_search import filter_images
|
||||
from .path_utils import require_filename, resolve_within
|
||||
|
||||
|
||||
GALLERY_SNAPSHOT_SECONDS = 10
|
||||
MAX_CACHED_GALLERY_IMAGES = 50_000
|
||||
_gallery_snapshot_lock = threading.Lock()
|
||||
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
|
||||
|
||||
|
||||
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'}:
|
||||
continue
|
||||
try:
|
||||
mtime = os.path.getmtime(os.path.join(root, filename))
|
||||
except OSError:
|
||||
continue
|
||||
subfolder = os.path.relpath(root, output_dir)
|
||||
images.append({
|
||||
"filename": filename,
|
||||
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
|
||||
"type": "output", "mtime": mtime,
|
||||
})
|
||||
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
|
||||
return images
|
||||
|
||||
|
||||
def _gallery_images(output_dir, refresh=False):
|
||||
root = os.path.realpath(output_dir)
|
||||
with _gallery_snapshot_lock:
|
||||
if not refresh and _gallery_snapshot['root'] == root and time.monotonic() < _gallery_snapshot['expires']:
|
||||
return _gallery_snapshot['images']
|
||||
images = _collect_gallery_images(root)
|
||||
_gallery_snapshot.update(root=root, expires=time.monotonic() + GALLERY_SNAPSHOT_SECONDS,
|
||||
images=images if len(images) <= MAX_CACHED_GALLERY_IMAGES else [])
|
||||
if len(images) > MAX_CACHED_GALLERY_IMAGES:
|
||||
_gallery_snapshot['expires'] = 0
|
||||
return images
|
||||
|
||||
|
||||
def _invalidate_gallery_snapshot():
|
||||
with _gallery_snapshot_lock:
|
||||
_gallery_snapshot.update(root=None, expires=0, images=[])
|
||||
|
||||
|
||||
def _gallery_listing(output_dir, refresh, query):
|
||||
images = _gallery_images(output_dir, refresh)
|
||||
return filter_images(os.path.realpath(output_dir), images, query) if query else images
|
||||
|
||||
|
||||
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."""
|
||||
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)
|
||||
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})
|
||||
except (TypeError, ValueError):
|
||||
return web.json_response({"error": "Invalid pagination"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"error": "Could not list output images"}, status=500)
|
||||
|
||||
|
||||
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", "")
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
target_dir = resolve_within(output_dir, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
_invalidate_gallery_snapshot()
|
||||
return web.json_response({"status": "success"})
|
||||
else:
|
||||
return web.json_response({"status": "error", "message": "File not found"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Search output images by the generation info embedded in them.
|
||||
|
||||
Reads only the PNG text chunks before the pixel data (ComfyUI `prompt` /
|
||||
`workflow`, A1111/Forge `parameters`), caches one small record per file keyed by
|
||||
its mtime, and matches every query term against it. Terms that look like a
|
||||
hash also match model SHA256 values recorded in the workflow and, through the
|
||||
scanned model index, the local model files that carry that hash.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
MAX_CHUNK_BYTES = 32 * 1024 * 1024
|
||||
MAX_CACHED_RECORDS = 60_000
|
||||
MODEL_INDEX_SECONDS = 120
|
||||
HASH_TERM = re.compile(r"^[0-9a-f]{8,64}$", re.IGNORECASE)
|
||||
MODEL_FILE = re.compile(r"\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$", re.IGNORECASE)
|
||||
NUMERIC_KEYS = {"seed", "noise_seed", "steps", "cfg", "denoise", "width", "height"}
|
||||
|
||||
_record_cache = {}
|
||||
_cache_lock = threading.Lock()
|
||||
_model_index = {"expires": 0, "entries": {}}
|
||||
_model_index_lock = threading.Lock()
|
||||
|
||||
|
||||
def _decode_text(raw):
|
||||
"""tEXt is Latin-1 by spec, but ComfyUI/PIL may store UTF-8 bytes in it."""
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return raw.decode("latin-1", "replace")
|
||||
|
||||
|
||||
def read_png_text(path):
|
||||
"""Text chunks that precede IDAT, as {keyword: text}. Pixel data is never read."""
|
||||
chunks = {}
|
||||
with open(path, "rb") as handle:
|
||||
if handle.read(8) != PNG_SIGNATURE:
|
||||
return chunks
|
||||
while True:
|
||||
header = handle.read(8)
|
||||
if len(header) < 8:
|
||||
break
|
||||
length, kind = struct.unpack(">I4s", header)
|
||||
if kind in (b"IDAT", b"IEND") or length > MAX_CHUNK_BYTES:
|
||||
break
|
||||
data = handle.read(length)
|
||||
handle.seek(4, os.SEEK_CUR)
|
||||
try:
|
||||
if kind == b"tEXt":
|
||||
key, _, value = data.partition(b"\x00")
|
||||
chunks[key.decode("latin-1")] = _decode_text(value)
|
||||
elif kind == b"zTXt":
|
||||
key, _, rest = data.partition(b"\x00")
|
||||
chunks[key.decode("latin-1")] = _decode_text(zlib.decompress(rest[1:]))
|
||||
elif kind == b"iTXt":
|
||||
key, _, rest = data.partition(b"\x00")
|
||||
compressed, rest = rest[0], rest[2:]
|
||||
_, _, rest = rest.partition(b"\x00") # language tag
|
||||
_, _, text = rest.partition(b"\x00") # translated keyword
|
||||
chunks[key.decode("latin-1")] = (zlib.decompress(text) if compressed else text).decode("utf-8", "replace")
|
||||
except (zlib.error, IndexError, ValueError):
|
||||
continue
|
||||
return chunks
|
||||
|
||||
|
||||
def _normalise_model_ref(value):
|
||||
ref = str(value).replace("\\", "/").strip().lower()
|
||||
return {ref, ref.rsplit("/", 1)[-1]}
|
||||
|
||||
|
||||
def build_record(path):
|
||||
"""Searchable text, recorded model hashes and referenced model files for one image."""
|
||||
name = os.path.basename(path)
|
||||
texts = [name]
|
||||
hashes = set()
|
||||
models = set()
|
||||
if path.lower().endswith(".png"):
|
||||
try:
|
||||
chunks = read_png_text(path)
|
||||
except OSError:
|
||||
chunks = {}
|
||||
prompt = _load_json(chunks.get("prompt"))
|
||||
if isinstance(prompt, dict):
|
||||
for node in prompt.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
texts.append(str(node.get("class_type", "")))
|
||||
for key, value in (node.get("inputs") or {}).items():
|
||||
if isinstance(value, str):
|
||||
texts.append(value)
|
||||
if MODEL_FILE.search(value):
|
||||
models |= _normalise_model_ref(value)
|
||||
elif isinstance(value, (int, float)) and not isinstance(value, bool) and key in NUMERIC_KEYS:
|
||||
texts.append(f"{key}:{value} {value}")
|
||||
workflow = _load_json(chunks.get("workflow"))
|
||||
recorded = (workflow or {}).get("extra", {}).get("anomalous_hashes") if isinstance(workflow, dict) else None
|
||||
if isinstance(recorded, dict):
|
||||
for key, entry in recorded.items():
|
||||
sha = entry.get("hash") if isinstance(entry, dict) else entry
|
||||
if isinstance(sha, str) and sha:
|
||||
hashes.add(sha.upper())
|
||||
model_name = str(key).split("_", 1)[-1]
|
||||
if MODEL_FILE.search(model_name):
|
||||
models |= _normalise_model_ref(model_name)
|
||||
if chunks.get("parameters"):
|
||||
texts.append(chunks["parameters"])
|
||||
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models}
|
||||
|
||||
|
||||
def _load_json(raw):
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def cached_record(path, mtime=None):
|
||||
"""Record for one image; `mtime` from the gallery listing avoids a stat per file."""
|
||||
if mtime is None:
|
||||
try:
|
||||
mtime = os.stat(path).st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
key = mtime
|
||||
with _cache_lock:
|
||||
cached = _record_cache.get(path)
|
||||
if cached and cached[0] == key:
|
||||
return cached[1]
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
record = build_record(path)
|
||||
with _cache_lock:
|
||||
if len(_record_cache) >= MAX_CACHED_RECORDS:
|
||||
_record_cache.clear()
|
||||
_record_cache[path] = (key, record)
|
||||
return record
|
||||
|
||||
|
||||
def _local_models_for_hash(term):
|
||||
"""Model file refs whose scanned SHA256 starts with `term` (cached briefly)."""
|
||||
# Imported lazily: api.utils re-exports gallery_routes, which imports this module.
|
||||
from .model_resolution import collect_model_hash_index
|
||||
|
||||
with _model_index_lock:
|
||||
if time.monotonic() >= _model_index["expires"]:
|
||||
_model_index["entries"] = collect_model_hash_index()
|
||||
_model_index["expires"] = time.monotonic() + MODEL_INDEX_SECONDS
|
||||
entries = _model_index["entries"]
|
||||
wanted = term.upper()
|
||||
refs = set()
|
||||
for key, value in entries.items():
|
||||
sha = str((value or {}).get("hash") or "").upper()
|
||||
if sha and sha.startswith(wanted):
|
||||
refs |= _normalise_model_ref(key)
|
||||
return refs
|
||||
|
||||
|
||||
def parse_query(query):
|
||||
"""Search terms: a list keeps each entry as one phrase; a string splits on whitespace."""
|
||||
raw = query if isinstance(query, (list, tuple)) else str(query or "").split()
|
||||
terms = []
|
||||
for term in raw:
|
||||
term = " ".join(str(term).lower().split())
|
||||
if term and term not in terms:
|
||||
terms.append(term)
|
||||
return terms
|
||||
|
||||
|
||||
def filter_images(output_dir, images, query):
|
||||
"""Keep gallery entries whose record matches every term (phrases may contain spaces)."""
|
||||
terms = parse_query(query)
|
||||
if not terms:
|
||||
return images
|
||||
hash_refs = {term: _local_models_for_hash(term) for term in terms if HASH_TERM.match(term)}
|
||||
matched = []
|
||||
for image in images:
|
||||
record = cached_record(os.path.join(output_dir, image.get("subfolder", ""), image["filename"]), image.get("mtime"))
|
||||
if record is None:
|
||||
continue
|
||||
if all(_term_matches(term, record, hash_refs) for term in terms):
|
||||
matched.append(image)
|
||||
return matched
|
||||
|
||||
|
||||
def _term_matches(term, record, hash_refs):
|
||||
if term in record["text"]:
|
||||
return True
|
||||
if term in hash_refs:
|
||||
upper = term.upper()
|
||||
if any(sha.startswith(upper) for sha in record["hashes"]):
|
||||
return True
|
||||
return bool(hash_refs[term] & record["models"])
|
||||
return False
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Image inspection and asset storage for curated materials."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from .recipe_images import _embedded_workflow_payload, _output_source_path, _recipe_cover_webp_bytes
|
||||
from .recipe_schema import _build_model_references, _normalise_source_image
|
||||
from .workflow_schema import _validate_workflow
|
||||
from .material_schema import _node_blocks, _suggested_name
|
||||
from .utils import require_filename, resolve_within
|
||||
|
||||
|
||||
MAX_SOURCE_IMAGE_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def _material_assets_dir(materials_dir, filename, create=False):
|
||||
stem = os.path.splitext(require_filename(filename))[0]
|
||||
assets_dir = resolve_within(materials_dir, ".assets", stem)
|
||||
if create:
|
||||
os.makedirs(assets_dir, exist_ok=True)
|
||||
return assets_dir
|
||||
|
||||
|
||||
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)
|
||||
payload = _embedded_workflow_payload(source_path)
|
||||
workflow = payload.get("workflow") if isinstance(payload, dict) else None
|
||||
if not isinstance(workflow, dict):
|
||||
raise ValueError("Image has no reusable UI workflow")
|
||||
_validate_workflow(workflow)
|
||||
blocks = _node_blocks(workflow, include_values=False)
|
||||
references = _build_model_references({"workflow": workflow, "params": {}}, verify_identities=False)
|
||||
suggested_name = _suggested_name(workflow, source_path, references)
|
||||
return source, source_path, workflow, blocks, references, suggested_name
|
||||
|
||||
|
||||
def _store_assets(materials_dir, filename, source_path):
|
||||
source_size = os.path.getsize(source_path)
|
||||
if source_size > MAX_SOURCE_IMAGE_BYTES:
|
||||
raise ValueError("Source image is too large")
|
||||
digest = hashlib.sha256()
|
||||
with open(source_path, "rb") as source_file:
|
||||
for block in iter(lambda: source_file.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
assets_dir = _material_assets_dir(materials_dir, filename, create=True)
|
||||
source_asset = f"source-{digest.hexdigest()}.png"
|
||||
source_target = resolve_within(assets_dir, source_asset)
|
||||
if not os.path.exists(source_target):
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".source-", suffix=".tmp", dir=assets_dir)
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copyfile(source_path, temp_path)
|
||||
os.replace(temp_path, source_target)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
preview = _recipe_cover_webp_bytes(source_path)
|
||||
preview_asset = None
|
||||
if preview:
|
||||
data, width, height = preview
|
||||
preview_asset = f"preview-{hashlib.sha256(data).hexdigest()}.webp"
|
||||
preview_target = resolve_within(assets_dir, preview_asset)
|
||||
if not os.path.exists(preview_target):
|
||||
with open(preview_target, "wb") as preview_file:
|
||||
preview_file.write(data)
|
||||
else:
|
||||
width = height = None
|
||||
return {
|
||||
"source_asset_id": source_asset,
|
||||
"preview_asset_id": preview_asset,
|
||||
"preview_width": width,
|
||||
"preview_height": height,
|
||||
"source_sha256": digest.hexdigest(),
|
||||
"source_size": source_size,
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Validation and record-shaping helpers for curated materials."""
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from .notebooks import MAX_NOTEBOOK_BYTES
|
||||
from .recipe_schema import _build_model_references
|
||||
from .workflow_schema import _parameter_signature, _volatile_widget_indexes, _workflow_fingerprint
|
||||
|
||||
|
||||
MAX_MATERIAL_NAME_LENGTH = 120
|
||||
MODEL_PRIORITY = {"checkpoint": 0, "unet": 1, "lora": 2}
|
||||
_PROMPT_NODE_TYPES = {"cliptextencode"}
|
||||
_PROMPT_CONSUMERS = {
|
||||
"ksampler": {"positive": "positive", "negative": "negative"},
|
||||
"ksampleradvanced": {"positive": "positive", "negative": "negative"},
|
||||
"cfgguider": {"positive": "positive", "negative": "negative"},
|
||||
"basicguider": {"conditioning": "positive"},
|
||||
"dualcfgguider": {"cond1": "positive", "cond2": "positive", "negative": "negative"},
|
||||
}
|
||||
_PROMPT_PASSTHROUGH = {
|
||||
"conditioningaverage", "conditioningcombine", "conditioningconcat",
|
||||
"conditioningmultiply", "conditioningsetarea", "conditioningsetareapercentage",
|
||||
"conditioningsetareastrength", "conditioningsetmask", "conditioningsettimesteprange",
|
||||
"conditioningzeroout",
|
||||
}
|
||||
_PROMPT_ROLES = {"positive", "negative", "both", "ignored", "unknown"}
|
||||
_MAX_PROMPT_UPSTREAM = 96
|
||||
|
||||
|
||||
def _node_blocks(workflow, include_values=True, selected_ids=None):
|
||||
blocks = []
|
||||
occurrences = {}
|
||||
for node in workflow.get("nodes", []):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_type = str(node.get("type") or "").strip()
|
||||
if not node_type:
|
||||
continue
|
||||
occurrences[node_type] = occurrences.get(node_type, 0) + 1
|
||||
if selected_ids is not None and str(node.get("id")) not in selected_ids:
|
||||
continue
|
||||
block = {
|
||||
"node_id": node.get("id"),
|
||||
"type": node_type,
|
||||
"title": str(node.get("title") or node_type),
|
||||
"occurrence": occurrences[node_type],
|
||||
"widget_count": len(node.get("widgets_values") or []),
|
||||
"volatile_widget_indexes": list(_volatile_widget_indexes(node)),
|
||||
}
|
||||
if include_values:
|
||||
block["widgets_values"] = copy.deepcopy(node.get("widgets_values") or [])
|
||||
block["properties"] = copy.deepcopy(node.get("properties") or {})
|
||||
if node.get("mode") is not None:
|
||||
block["mode"] = node.get("mode")
|
||||
blocks.append(block)
|
||||
return blocks
|
||||
|
||||
|
||||
def _normalise_selected_node_ids(workflow, raw_node_ids):
|
||||
"""Validate an optional node selection while preserving workflow ID types."""
|
||||
if raw_node_ids is None:
|
||||
return None
|
||||
if not isinstance(raw_node_ids, list) or not raw_node_ids:
|
||||
raise ValueError("Selected material nodes are required")
|
||||
|
||||
workflow_nodes = [node for node in workflow.get("nodes", []) if isinstance(node, dict)]
|
||||
existing = {str(node.get("id")): node.get("id") for node in workflow_nodes if node.get("id") is not None}
|
||||
selected = []
|
||||
seen = set()
|
||||
for raw_node_id in raw_node_ids:
|
||||
key = str(raw_node_id)
|
||||
if key not in existing:
|
||||
raise ValueError("Selected material node does not exist")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
selected.append(existing[key])
|
||||
if not selected:
|
||||
raise ValueError("Selected material nodes are required")
|
||||
return selected
|
||||
|
||||
|
||||
def _material_node_ids(material):
|
||||
selection = material.get("selection") if isinstance(material, dict) else None
|
||||
if not isinstance(selection, dict) or selection.get("scope") != "nodes":
|
||||
return None
|
||||
node_ids = selection.get("node_ids")
|
||||
if not isinstance(node_ids, list):
|
||||
return None
|
||||
return {str(node_id) for node_id in node_ids}
|
||||
|
||||
|
||||
def _material_node_blocks(material, include_values=True):
|
||||
if material.get("kind") in ("prompt_note_bundle", "prompt_text", "prompt_plan"):
|
||||
return []
|
||||
return _node_blocks(material.get("workflow") or {}, include_values=include_values,
|
||||
selected_ids=_material_node_ids(material))
|
||||
|
||||
|
||||
def _prompt_excerpt(workflow):
|
||||
for node in workflow.get("nodes", []):
|
||||
if not isinstance(node, dict) or "cliptextencode" not in str(node.get("type") or "").lower():
|
||||
continue
|
||||
for value in node.get("widgets_values") or []:
|
||||
if isinstance(value, str) and value.strip():
|
||||
compact = re.sub(r"\s+", " ", value).strip()
|
||||
return compact[:20]
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_workflow_params(workflow):
|
||||
params = {}
|
||||
prompts = []
|
||||
if not isinstance(workflow, dict):
|
||||
return params, prompts
|
||||
|
||||
for node in workflow.get("nodes", []):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
ntype = str(node.get("type") or "").strip()
|
||||
ntype_lower = ntype.lower()
|
||||
widgets = node.get("widgets_values") or []
|
||||
|
||||
if ntype_lower in ("ksampler", "ksampleradvanced") and isinstance(widgets, (list, tuple)):
|
||||
is_adv = ntype_lower == "ksampleradvanced"
|
||||
offset = 1 if is_adv else 0
|
||||
if len(widgets) > offset and widgets[offset] is not None:
|
||||
params.setdefault("seed", widgets[offset])
|
||||
if len(widgets) > 2 + offset and widgets[2 + offset] is not None:
|
||||
params.setdefault("steps", widgets[2 + offset])
|
||||
if len(widgets) > 3 + offset and widgets[3 + offset] is not None:
|
||||
params.setdefault("cfg", widgets[3 + offset])
|
||||
if len(widgets) > 4 + offset and widgets[4 + offset] is not None:
|
||||
params.setdefault("sampler_name", widgets[4 + offset])
|
||||
if len(widgets) > 5 + offset and widgets[5 + offset] is not None:
|
||||
params.setdefault("scheduler", widgets[5 + offset])
|
||||
if len(widgets) > 6 + offset and widgets[6 + offset] is not None:
|
||||
params.setdefault("denoise", widgets[6 + offset])
|
||||
elif ntype_lower == "emptylatentimage" and isinstance(widgets, (list, tuple)):
|
||||
if len(widgets) >= 2 and widgets[0] and widgets[1]:
|
||||
params.setdefault("resolution", f"{widgets[0]}×{widgets[1]}")
|
||||
|
||||
if "cliptextencode" in ntype_lower and isinstance(widgets, (list, tuple)):
|
||||
for val in widgets:
|
||||
if isinstance(val, str) and val.strip():
|
||||
cleaned = val.strip()
|
||||
if cleaned not in prompts:
|
||||
prompts.append(cleaned)
|
||||
|
||||
return params, prompts
|
||||
|
||||
|
||||
def _norm_node_name(value):
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def _is_prompt_node_type(node_type):
|
||||
name = _norm_node_name(node_type)
|
||||
return name in _PROMPT_NODE_TYPES or "cliptextencode" in name
|
||||
|
||||
|
||||
def _first_prompt_text(node):
|
||||
for value in node.get("widgets_values") or []:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _title_prompt_role(node):
|
||||
title = str(node.get("title") or "").lower()
|
||||
if "neg" in title or "负向" in title or "反向" in title:
|
||||
return "negative"
|
||||
if "pos" in title or "正向" in title:
|
||||
return "positive"
|
||||
return None
|
||||
|
||||
|
||||
def _workflow_link_origin(workflow, link_id):
|
||||
if link_id is None:
|
||||
return None
|
||||
links = workflow.get("links")
|
||||
if isinstance(links, list):
|
||||
for link in links:
|
||||
if isinstance(link, (list, tuple)) and len(link) > 1 and link[0] == link_id:
|
||||
return link[1]
|
||||
if isinstance(link, dict) and link.get("id") == link_id:
|
||||
return link.get("origin_id")
|
||||
elif isinstance(links, dict):
|
||||
link = links.get(link_id)
|
||||
if link is None:
|
||||
link = links.get(str(link_id))
|
||||
if isinstance(link, dict):
|
||||
return link.get("origin_id")
|
||||
if isinstance(link, (list, tuple)) and len(link) > 1:
|
||||
return link[1]
|
||||
return None
|
||||
|
||||
|
||||
def _collect_prompt_nodes(workflow, nodes_by_id, start_id):
|
||||
queue = [start_id]
|
||||
visited = set()
|
||||
found = []
|
||||
while queue and len(visited) < _MAX_PROMPT_UPSTREAM:
|
||||
node_id = queue.pop(0)
|
||||
if node_id is None or node_id in visited:
|
||||
continue
|
||||
visited.add(node_id)
|
||||
node = nodes_by_id.get(str(node_id))
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if _is_prompt_node_type(node.get("type")):
|
||||
if node not in found:
|
||||
found.append(node)
|
||||
continue
|
||||
if _norm_node_name(node.get("type")) not in _PROMPT_PASSTHROUGH:
|
||||
continue
|
||||
for inbound in node.get("inputs") or []:
|
||||
if not isinstance(inbound, dict):
|
||||
continue
|
||||
input_type = _norm_node_name(inbound.get("type"))
|
||||
input_name = _norm_node_name(inbound.get("name"))
|
||||
if input_type != "conditioning" and "conditioning" not in input_name:
|
||||
continue
|
||||
origin_id = _workflow_link_origin(workflow, inbound.get("link"))
|
||||
if origin_id is not None and origin_id not in visited:
|
||||
queue.append(origin_id)
|
||||
return found
|
||||
|
||||
|
||||
def _normalise_prompt_role_overrides(raw):
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
result = {}
|
||||
for key, value in list(raw.items())[:80]:
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
role = value.get("role")
|
||||
if role not in _PROMPT_ROLES:
|
||||
continue
|
||||
node_type = value.get("nodeType")
|
||||
if node_type is not None and (not isinstance(node_type, str) or len(node_type) > 200):
|
||||
continue
|
||||
result[str(key)] = {
|
||||
"role": role,
|
||||
"nodeType": node_type if isinstance(node_type, str) else None,
|
||||
"source": "manual",
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _override_prompt_role(node, overrides):
|
||||
if not isinstance(overrides, dict) or not isinstance(node, dict):
|
||||
return None
|
||||
entry = overrides.get(str(node.get("id")))
|
||||
if not isinstance(entry, dict) or entry.get("role") not in _PROMPT_ROLES:
|
||||
return None
|
||||
expected = entry.get("nodeType")
|
||||
if expected and expected != node.get("type"):
|
||||
return None
|
||||
return entry["role"]
|
||||
|
||||
|
||||
def _prompt_roles_for_workflow(workflow, overrides=None):
|
||||
roles = {}
|
||||
if not isinstance(workflow, dict):
|
||||
return roles
|
||||
nodes = [node for node in workflow.get("nodes") or [] if isinstance(node, dict)]
|
||||
nodes_by_id = {str(node.get("id")): node for node in nodes if node.get("id") is not None}
|
||||
positive_ids = set()
|
||||
negative_ids = set()
|
||||
for node in nodes:
|
||||
mapping = _PROMPT_CONSUMERS.get(_norm_node_name(node.get("type")))
|
||||
if not mapping:
|
||||
continue
|
||||
for input_name, role in mapping.items():
|
||||
for inbound in node.get("inputs") or []:
|
||||
if not isinstance(inbound, dict) or _norm_node_name(inbound.get("name")) != input_name:
|
||||
continue
|
||||
origin_id = _workflow_link_origin(workflow, inbound.get("link"))
|
||||
for prompt_node in _collect_prompt_nodes(workflow, nodes_by_id, origin_id):
|
||||
node_id = str(prompt_node.get("id"))
|
||||
if role == "negative":
|
||||
negative_ids.add(node_id)
|
||||
else:
|
||||
positive_ids.add(node_id)
|
||||
for node in nodes:
|
||||
if not _is_prompt_node_type(node.get("type")):
|
||||
continue
|
||||
node_id = str(node.get("id"))
|
||||
in_positive = node_id in positive_ids
|
||||
in_negative = node_id in negative_ids
|
||||
if in_positive and in_negative:
|
||||
automatic_role, automatic_source = "both", "topology"
|
||||
elif in_positive:
|
||||
automatic_role, automatic_source = "positive", "topology"
|
||||
elif in_negative:
|
||||
automatic_role, automatic_source = "negative", "topology"
|
||||
else:
|
||||
titled = _title_prompt_role(node)
|
||||
automatic_role = titled or "unknown"
|
||||
automatic_source = "title" if titled else "unresolved"
|
||||
override = _override_prompt_role(node, overrides)
|
||||
roles[node_id] = {
|
||||
"role": override or automatic_role,
|
||||
"source": "manual" if override else automatic_source,
|
||||
"automatic_role": automatic_role,
|
||||
}
|
||||
return roles
|
||||
|
||||
|
||||
def _prompt_groups_from_roles(workflow, roles, selected_ids=None):
|
||||
positive = []
|
||||
negative = []
|
||||
if not isinstance(workflow, dict) or not isinstance(roles, dict):
|
||||
return {"positive": positive, "negative": negative}
|
||||
for node in workflow.get("nodes") or []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_id = str(node.get("id"))
|
||||
if selected_ids is not None and node_id not in selected_ids:
|
||||
continue
|
||||
info = roles.get(node_id)
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
text = _first_prompt_text(node)
|
||||
if not text:
|
||||
continue
|
||||
role = info.get("role")
|
||||
if role in ("positive", "both") and text not in positive:
|
||||
positive.append(text)
|
||||
if role in ("negative", "both") and text not in negative:
|
||||
negative.append(text)
|
||||
return {"positive": positive, "negative": negative}
|
||||
|
||||
|
||||
def _display_model_name(reference):
|
||||
value = str(reference.get("saved_value") or "").replace("\\", "/").split("/")[-1]
|
||||
return re.sub(r"\.(?:safetensors|ckpt|pt|bin|sft)$", "", value, flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def _suggested_name(workflow, source_path, references=None):
|
||||
stem = os.path.splitext(os.path.basename(source_path))[0] if source_path else ""
|
||||
if stem:
|
||||
return f"{stem} · 快照"[:MAX_MATERIAL_NAME_LENGTH]
|
||||
if references is None:
|
||||
references = _build_model_references({"workflow": workflow, "params": {}}, verify_identities=False)
|
||||
references.sort(key=lambda item: MODEL_PRIORITY.get(item.get("category"), 99))
|
||||
model_name = _display_model_name(references[0]) if references else "工作流快照"
|
||||
return f"{model_name} · 快照"[:MAX_MATERIAL_NAME_LENGTH]
|
||||
|
||||
|
||||
def _recipe_link_fingerprint(recipe):
|
||||
if not isinstance(recipe, dict):
|
||||
return ""
|
||||
fingerprint = recipe.get("workflow_fingerprint")
|
||||
workflow_hash = fingerprint.get("value") if isinstance(fingerprint, dict) else ""
|
||||
if not workflow_hash and isinstance(recipe.get("workflow"), dict):
|
||||
workflow_hash = (_workflow_fingerprint(recipe["workflow"]) or {}).get("value") or ""
|
||||
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
|
||||
overrides = params.get("promptRoleOverrides") if isinstance(params.get("promptRoleOverrides"), dict) else {}
|
||||
payload = json.dumps(
|
||||
{"workflow": workflow_hash, "promptRoleOverrides": overrides},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _material_summary(filename, material):
|
||||
image = material.get("image") or {}
|
||||
source = material.get("source") if isinstance(material.get("source"), dict) else {}
|
||||
blocks = _material_node_blocks(material, include_values=False)
|
||||
summary = {
|
||||
"filename": filename,
|
||||
"id": material.get("id"),
|
||||
"name": material.get("name") or "未命名素材",
|
||||
"kind": material.get("kind"),
|
||||
"tags": _normalise_material_tags(material.get("tags") or []),
|
||||
"source_sha256": image.get("source_sha256", ""),
|
||||
"timestamp": material.get("timestamp", 0),
|
||||
"node_count": len(blocks),
|
||||
"node_types": sorted({block["type"] for block in blocks}),
|
||||
"selection": copy.deepcopy(material.get("selection")),
|
||||
"image": {
|
||||
"preview_asset_id": image.get("preview_asset_id"),
|
||||
"source_asset_id": image.get("source_asset_id"),
|
||||
"width": image.get("preview_width"),
|
||||
"height": image.get("preview_height"),
|
||||
},
|
||||
"capabilities": list(material.get("capabilities") or []),
|
||||
"source": {
|
||||
"type": source.get("type"),
|
||||
"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 ""),
|
||||
}
|
||||
if source.get("recipe_filename"):
|
||||
summary["source_recipe"] = {
|
||||
"filename": source.get("recipe_filename"),
|
||||
"name": str(source.get("recipe_name") or "").strip(),
|
||||
"fingerprint": str(source.get("recipe_fingerprint") or ""),
|
||||
}
|
||||
return summary
|
||||
|
||||
|
||||
def _workflow_hashes_for_blocks(workflow, blocks):
|
||||
hashes = (workflow.get("extra") or {}).get("anomalous_hashes") if isinstance(workflow, dict) else None
|
||||
if not isinstance(hashes, dict):
|
||||
return {}
|
||||
prefixes = tuple(f"{block.get('node_id')}_" for block in blocks)
|
||||
return {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in hashes.items()
|
||||
if isinstance(key, str) and key.startswith(prefixes)
|
||||
}
|
||||
|
||||
|
||||
def _normalise_material_tags(tags):
|
||||
if not isinstance(tags, list) or len(tags) > 20:
|
||||
raise ValueError("Invalid material tags")
|
||||
result = []
|
||||
seen = set()
|
||||
for tag in tags:
|
||||
if not isinstance(tag, str) or not tag.strip() or len(tag.strip()) > 60:
|
||||
raise ValueError("Invalid material tag")
|
||||
tag = tag.strip()
|
||||
if tag.casefold() not in seen:
|
||||
result.append(tag)
|
||||
seen.add(tag.casefold())
|
||||
return result
|
||||
|
||||
|
||||
def _normalise_prompt_note(data, prompt_only=False):
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid prompt note")
|
||||
if len(json.dumps(data, ensure_ascii=False, allow_nan=False).encode("utf-8")) > MAX_NOTEBOOK_BYTES:
|
||||
raise ValueError("Prompt note is too large")
|
||||
result = {}
|
||||
for key in ("promptEn", "promptZh", "targetLang"):
|
||||
value = data.get(key, "")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Invalid prompt text")
|
||||
result[key] = value
|
||||
translations = data.get("translations", {})
|
||||
if not isinstance(translations, dict) or any(not isinstance(value, str) for value in translations.values()):
|
||||
raise ValueError("Invalid prompt translations")
|
||||
result["translations"] = copy.deepcopy(translations)
|
||||
if not prompt_only:
|
||||
base = data.get("baseModel", "")
|
||||
main = data.get("mainModel")
|
||||
loras = data.get("loras", [])
|
||||
if not isinstance(base, str) or (main is not None and not isinstance(main, dict)):
|
||||
raise ValueError("Invalid prompt models")
|
||||
if not isinstance(loras, list) or len(loras) > 100 or any(not isinstance(model, dict) for model in loras):
|
||||
raise ValueError("Invalid prompt LoRAs")
|
||||
for model in ([main] if main is not None else []) + loras:
|
||||
if not isinstance(model.get("filename"), str) or not model["filename"]:
|
||||
raise ValueError("Invalid prompt model filename")
|
||||
result.update(baseModel=base, mainModel=copy.deepcopy(main), loras=copy.deepcopy(loras))
|
||||
return result
|
||||
|
||||
|
||||
def _normalise_prompt_plan(plan):
|
||||
if not isinstance(plan, dict) or len(json.dumps(plan, ensure_ascii=False, allow_nan=False).encode("utf-8")) > MAX_NOTEBOOK_BYTES:
|
||||
raise ValueError("Invalid prompt plan")
|
||||
parts = plan.get("parts", [])
|
||||
if not isinstance(parts, list) or len(parts) > 100:
|
||||
raise ValueError("Invalid prompt parts")
|
||||
result = {"parts": []}
|
||||
if "version" in plan and isinstance(plan["version"], int):
|
||||
result["version"] = plan["version"]
|
||||
for key in ("positive", "negative"):
|
||||
if not isinstance(plan.get(key, ""), str):
|
||||
raise ValueError("Invalid prompt text")
|
||||
result[key] = plan.get(key, "")
|
||||
for part in parts:
|
||||
if not isinstance(part, dict) or part.get("category") not in ("general", "specific", "base", "style", "subject", "trigger") or not isinstance(part.get("enabled", True), bool):
|
||||
raise ValueError("Invalid prompt part")
|
||||
item = {"category": part["category"], "enabled": part.get("enabled", True)}
|
||||
if "role" in part and part["role"] in ("positive", "negative"):
|
||||
item["role"] = part["role"]
|
||||
if "id" in part and isinstance(part["id"], str) and len(part["id"]) <= 120:
|
||||
item["id"] = part["id"]
|
||||
for key in ("name", "positive", "negative"):
|
||||
if not isinstance(part.get(key, ""), str):
|
||||
raise ValueError("Invalid prompt part text")
|
||||
item[key] = part.get(key, "")
|
||||
if len(item["name"]) > 120:
|
||||
raise ValueError("Prompt part name is too long")
|
||||
result["parts"].append(item)
|
||||
return result
|
||||
|
||||
|
||||
def _material_category(material):
|
||||
if material.get("kind") == "image_workflow_snapshot":
|
||||
return "workflow"
|
||||
if material.get("kind") in ("prompt_text", "prompt_note_bundle", "prompt_plan"):
|
||||
return "prompts"
|
||||
types = material.get("node_types", [])
|
||||
if types and all(_is_prompt_node_type(value) for value in types):
|
||||
return "prompts"
|
||||
return "params"
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Single-owner persistence, cache, and locking for curated materials."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
|
||||
import folder_paths
|
||||
|
||||
from .material_assets import _material_assets_dir, _store_assets
|
||||
from .material_schema import (
|
||||
_is_prompt_node_type,
|
||||
_material_category,
|
||||
_material_summary,
|
||||
_normalise_prompt_note,
|
||||
_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 .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()
|
||||
|
||||
|
||||
def get_materials_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", "default")
|
||||
materials_dir = os.path.join(user_dir, "workflows", "anomalous_materials")
|
||||
os.makedirs(materials_dir, exist_ok=True)
|
||||
return materials_dir
|
||||
|
||||
|
||||
def _read_material(path):
|
||||
if os.path.getsize(path) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Material snapshot is too large")
|
||||
with open(path, "r", encoding="utf-8") as material_file:
|
||||
value = json.load(material_file)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("Invalid material snapshot")
|
||||
if value.get("kind") == "prompt_plan":
|
||||
_normalise_prompt_plan(value.get("plan"))
|
||||
elif value.get("kind") in ("prompt_note_bundle", "prompt_text"):
|
||||
_normalise_prompt_note(value.get("note"), value["kind"] == "prompt_text")
|
||||
elif not isinstance(value.get("workflow"), dict):
|
||||
raise ValueError("Invalid material snapshot")
|
||||
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 {}
|
||||
record = {"recipe_filename": recipe_filename}
|
||||
try:
|
||||
path = resolve_within(get_recipes_dir(), require_filename(recipe_filename))
|
||||
recipe = _read_recipe(path)
|
||||
except (OSError, ValueError, json.JSONDecodeError, TypeError):
|
||||
return record
|
||||
if not isinstance(recipe, dict):
|
||||
return record
|
||||
name = str(recipe.get("name") or "").strip()
|
||||
if name:
|
||||
record["recipe_name"] = name[:120]
|
||||
fingerprint = _recipe_link_fingerprint(recipe)
|
||||
if fingerprint:
|
||||
record["recipe_fingerprint"] = fingerprint
|
||||
return record
|
||||
|
||||
|
||||
def _live_recipe_source(source):
|
||||
if not isinstance(source, dict):
|
||||
return None
|
||||
filename = source.get("recipe_filename")
|
||||
if not isinstance(filename, str) or not filename:
|
||||
return None
|
||||
info = {
|
||||
"filename": filename,
|
||||
"name": str(source.get("recipe_name") or "").strip(),
|
||||
"status": "missing",
|
||||
}
|
||||
try:
|
||||
path = resolve_within(get_recipes_dir(), require_filename(filename))
|
||||
if not os.path.isfile(path):
|
||||
return info
|
||||
recipe = _read_recipe(path)
|
||||
except (OSError, ValueError, json.JSONDecodeError, TypeError):
|
||||
return info
|
||||
if isinstance(recipe, dict):
|
||||
live_name = str(recipe.get("name") or "").strip()
|
||||
if live_name:
|
||||
info["name"] = live_name[:120]
|
||||
snapshot = str(source.get("recipe_fingerprint") or "")
|
||||
current = _recipe_link_fingerprint(recipe)
|
||||
if snapshot and current and snapshot != current:
|
||||
info["status"] = "modified"
|
||||
else:
|
||||
info["status"] = "current"
|
||||
return info
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _cached_material_summary(path, signature):
|
||||
return _material_summary(os.path.basename(path), _read_material(path))
|
||||
|
||||
|
||||
def _with_live_recipe_source(summary):
|
||||
snapshot = summary.get("source_recipe")
|
||||
if not isinstance(snapshot, dict) or not snapshot.get("filename"):
|
||||
return summary
|
||||
summary["source_recipe"] = _live_recipe_source({
|
||||
"recipe_filename": snapshot.get("filename"),
|
||||
"recipe_name": snapshot.get("name"),
|
||||
"recipe_fingerprint": snapshot.get("fingerprint"),
|
||||
})
|
||||
return summary
|
||||
|
||||
|
||||
def _summary_for_path(path, stat=None):
|
||||
stat = stat or os.stat(path)
|
||||
signature = (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
|
||||
return _with_live_recipe_source(copy.deepcopy(_cached_material_summary(path, signature)))
|
||||
|
||||
|
||||
def _list_materials(materials_dir):
|
||||
result = []
|
||||
try:
|
||||
materials_dir = os.path.realpath(materials_dir)
|
||||
entries = os.scandir(materials_dir)
|
||||
except OSError:
|
||||
return result
|
||||
with entries:
|
||||
for entry in entries:
|
||||
if not entry.is_file() or not entry.name.endswith(".json") or entry.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
path = resolve_within(materials_dir, entry.name) if entry.is_symlink() else entry.path
|
||||
result.append(copy.deepcopy(_summary_for_path(path, entry.stat())))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
result.sort(key=lambda item: (item.get("timestamp", 0), item["filename"]), reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
def _persist_material(materials_dir, filename, source_path, material, allow_duplicate=False):
|
||||
with _material_write_lock:
|
||||
return _persist_material_files(materials_dir, filename, source_path, material, allow_duplicate)
|
||||
|
||||
|
||||
def _persist_parameter_material(materials_dir, filename, material, allow_duplicate=False):
|
||||
with _material_write_lock:
|
||||
target = resolve_within(materials_dir, filename)
|
||||
if os.path.exists(target):
|
||||
raise ValueError("Material already exists")
|
||||
if not allow_duplicate:
|
||||
selection = sorted(str(value) for value in (material.get("selection") or {}).get("node_ids", []))
|
||||
fingerprint = str((material.get("source") or {}).get("parameter_signature") or "")
|
||||
for summary in _list_materials(materials_dir):
|
||||
candidate_selection = sorted(str(value) for value in (summary.get("selection") or {}).get("node_ids", []))
|
||||
if (summary.get("kind") == material.get("kind")
|
||||
and summary.get("source_fingerprint") == fingerprint
|
||||
and candidate_selection == selection):
|
||||
return summary
|
||||
_atomic_write_json(target, material)
|
||||
return None
|
||||
|
||||
|
||||
def _persist_material_files(materials_dir, filename, source_path, material, allow_duplicate):
|
||||
target = resolve_within(materials_dir, filename)
|
||||
assets_target = _material_assets_dir(materials_dir, filename)
|
||||
if os.path.exists(target) or os.path.exists(assets_target):
|
||||
raise ValueError("Material already exists")
|
||||
with tempfile.TemporaryDirectory(prefix=".save-", dir=materials_dir) as staging:
|
||||
material["image"] = _store_assets(staging, filename, source_path)
|
||||
if not allow_duplicate:
|
||||
selection = sorted(str(value) for value in (material.get("selection") or {}).get("node_ids", []))
|
||||
for summary in _list_materials(materials_dir):
|
||||
candidate_selection = sorted(str(value) for value in (summary.get("selection") or {}).get("node_ids", []))
|
||||
if summary.get("source_sha256") == material["image"]["source_sha256"] and summary.get("kind") == material.get("kind") and candidate_selection == selection:
|
||||
return summary
|
||||
staged_json = resolve_within(staging, filename)
|
||||
_atomic_write_json(staged_json, material)
|
||||
os.makedirs(os.path.dirname(assets_target), exist_ok=True)
|
||||
os.replace(_material_assets_dir(staging, filename), assets_target)
|
||||
try:
|
||||
os.replace(staged_json, target)
|
||||
except OSError:
|
||||
shutil.rmtree(assets_target)
|
||||
raise
|
||||
|
||||
|
||||
def _query_materials(materials_dir, query):
|
||||
materials = _list_materials(materials_dir)
|
||||
all_tags = sorted({tag for material in materials for tag in material.get("tags", [])}, key=str.casefold)
|
||||
search = query.get("q", "").strip().casefold()
|
||||
tag = query.get("tag", "").strip().casefold()
|
||||
kind = query.get("kind", "")
|
||||
category = query.get("category", "all")
|
||||
if category not in ("all", "workflow", "params", "prompts"):
|
||||
raise ValueError("Invalid material category")
|
||||
node_type = query.get("node_type", "")
|
||||
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"
|
||||
):
|
||||
raise ValueError("Invalid material filter")
|
||||
materials = [material for material in materials
|
||||
if (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 (not node_type or node_type in material["node_types"])]
|
||||
total = len(materials)
|
||||
limit = min(100, max(1, int(query.get("limit", 48))))
|
||||
pages = max(1, (total + limit - 1) // limit)
|
||||
page = min(pages, max(1, int(query.get("page", 1))))
|
||||
# Existing non-paginated callers retain their response; the library requests pages.
|
||||
if "page" in query or "limit" in query:
|
||||
materials = materials[(page - 1) * limit:page * limit]
|
||||
return {"status": "success", "materials": materials, "total": total,
|
||||
"page": page, "pages": pages, "tags": all_tags}
|
||||
|
||||
|
||||
def _update_material_details(materials_dir, filename, name, tags, prompt_role_overrides=None,
|
||||
update_prompt_roles=False):
|
||||
with _material_write_lock:
|
||||
path = resolve_within(materials_dir, filename)
|
||||
material = _read_material(path)
|
||||
material.update(name=name, tags=tags)
|
||||
if update_prompt_roles:
|
||||
if prompt_role_overrides:
|
||||
material["promptRoleOverrides"] = prompt_role_overrides
|
||||
else:
|
||||
material.pop("promptRoleOverrides", None)
|
||||
_atomic_write_json(path, material)
|
||||
return _with_live_recipe_source(_material_summary(filename, 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)
|
||||
@@ -0,0 +1,574 @@
|
||||
"""HTTP routes and compatibility facade for curated material modules."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from . import material_schema as _schema
|
||||
from . import material_store as _store
|
||||
from .material_assets import _inspect_source_image, _material_assets_dir, _store_assets
|
||||
from .material_schema import (
|
||||
MAX_MATERIAL_NAME_LENGTH,
|
||||
_extract_workflow_params,
|
||||
_is_prompt_node_type,
|
||||
_material_category,
|
||||
_material_node_blocks,
|
||||
_material_node_ids,
|
||||
_material_summary,
|
||||
_node_blocks,
|
||||
_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_store import get_recipes_dir
|
||||
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
|
||||
|
||||
|
||||
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
|
||||
_store_summary_for_path = _store._summary_for_path
|
||||
_store_list_materials = _store._list_materials
|
||||
_store_persist_material = _store._persist_material
|
||||
_store_persist_parameter_material = _store._persist_parameter_material
|
||||
_store_query_materials = _store._query_materials
|
||||
_store_update_material_details = _store._update_material_details
|
||||
_store_delete_material = _store._delete_material
|
||||
|
||||
|
||||
def get_materials_dir():
|
||||
return _store_get_materials_dir()
|
||||
|
||||
|
||||
def _sync_store_compatibility():
|
||||
"""Keep historical monkeypatch points effective while storage has one owner."""
|
||||
_store._atomic_write_json = _atomic_write_json
|
||||
_store._read_material = _read_material
|
||||
_store._material_summary = _material_summary
|
||||
_store._normalise_prompt_note = _normalise_prompt_note
|
||||
_store._normalise_prompt_plan = _normalise_prompt_plan
|
||||
_store._material_assets_dir = _material_assets_dir
|
||||
_store._store_assets = _store_assets
|
||||
_store.get_materials_dir = get_materials_dir
|
||||
|
||||
|
||||
def _normalise_prompt_note(data, prompt_only=False):
|
||||
_schema.MAX_NOTEBOOK_BYTES = MAX_NOTEBOOK_BYTES
|
||||
return _schema._normalise_prompt_note(data, prompt_only)
|
||||
|
||||
|
||||
def _normalise_prompt_plan(plan):
|
||||
_schema.MAX_NOTEBOOK_BYTES = MAX_NOTEBOOK_BYTES
|
||||
return _schema._normalise_prompt_plan(plan)
|
||||
|
||||
|
||||
def _read_material(path):
|
||||
_store._normalise_prompt_note = _normalise_prompt_note
|
||||
_store._normalise_prompt_plan = _normalise_prompt_plan
|
||||
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)
|
||||
|
||||
|
||||
def _live_recipe_source(source):
|
||||
_store.get_recipes_dir = get_recipes_dir
|
||||
return _store_live_recipe_source(source)
|
||||
|
||||
|
||||
_cached_material_summary = _store._cached_material_summary
|
||||
|
||||
|
||||
def _with_live_recipe_source(summary):
|
||||
_store._live_recipe_source = _live_recipe_source
|
||||
return _store_with_live_recipe_source(summary)
|
||||
|
||||
|
||||
def _summary_for_path(path, stat=None):
|
||||
_sync_store_compatibility()
|
||||
_store._with_live_recipe_source = _with_live_recipe_source
|
||||
return _store_summary_for_path(path, stat)
|
||||
|
||||
|
||||
def _list_materials(materials_dir):
|
||||
_sync_store_compatibility()
|
||||
_store._summary_for_path = _summary_for_path
|
||||
return _store_list_materials(materials_dir)
|
||||
|
||||
|
||||
def _persist_material(materials_dir, filename, source_path, material, allow_duplicate=False):
|
||||
_sync_store_compatibility()
|
||||
_store._list_materials = _list_materials
|
||||
return _store_persist_material(materials_dir, filename, source_path, material, allow_duplicate)
|
||||
|
||||
|
||||
def _persist_parameter_material(materials_dir, filename, material, allow_duplicate=False):
|
||||
_sync_store_compatibility()
|
||||
_store._list_materials = _list_materials
|
||||
return _store_persist_parameter_material(materials_dir, filename, material, allow_duplicate)
|
||||
|
||||
|
||||
def _query_materials(materials_dir, query):
|
||||
_sync_store_compatibility()
|
||||
_store._list_materials = _list_materials
|
||||
return _store_query_materials(materials_dir, query)
|
||||
|
||||
|
||||
def _update_material_details(materials_dir, filename, name, tags, prompt_role_overrides=None,
|
||||
update_prompt_roles=False):
|
||||
_sync_store_compatibility()
|
||||
_store._with_live_recipe_source = _with_live_recipe_source
|
||||
return _store_update_material_details(
|
||||
materials_dir, filename, name, tags, prompt_role_overrides, update_prompt_roles
|
||||
)
|
||||
|
||||
|
||||
def _delete_material(materials_dir, filename, path):
|
||||
_sync_store_compatibility()
|
||||
return _store_delete_material(materials_dir, filename, path)
|
||||
|
||||
|
||||
async def api_inspect_image_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
source, source_path, workflow, blocks, references, suggested_name = await asyncio.to_thread(
|
||||
_inspect_source_image, payload.get("source_image")
|
||||
)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Image has no reusable UI workflow"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not inspect output image"}, status=500)
|
||||
sampling_params, prompts = _extract_workflow_params(workflow)
|
||||
prompt_roles = _prompt_roles_for_workflow(workflow)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"source_image": source,
|
||||
"suggested_name": suggested_name,
|
||||
"node_count": len(blocks),
|
||||
"node_blocks": blocks,
|
||||
"workflow": workflow,
|
||||
"model_references": references,
|
||||
"prompt_excerpt": _prompt_excerpt(workflow),
|
||||
"params": sampling_params,
|
||||
"prompts": prompts,
|
||||
"prompt_roles": prompt_roles,
|
||||
"prompt_groups": _prompt_groups_from_roles(workflow, prompt_roles),
|
||||
})
|
||||
|
||||
|
||||
async def api_save_image_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
source, source_path, workflow, blocks, references, suggested_name = await asyncio.to_thread(
|
||||
_inspect_source_image, payload.get("source_image")
|
||||
)
|
||||
name = payload.get("name") or suggested_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", []))
|
||||
allow_duplicate = payload.get("allow_duplicate", False)
|
||||
if not isinstance(allow_duplicate, bool):
|
||||
raise ValueError("Invalid duplicate preference")
|
||||
selected_node_ids = _normalise_selected_node_ids(workflow, payload.get("selected_node_ids"))
|
||||
selected_id_keys = {str(node_id) for node_id in selected_node_ids or []}
|
||||
if selected_node_ids is not None:
|
||||
blocks = [block for block in blocks if str(block.get("node_id")) in selected_id_keys]
|
||||
references = [
|
||||
reference for reference in references
|
||||
if str(reference.get("node_id")) in selected_id_keys
|
||||
]
|
||||
material_id = uuid.uuid4().hex
|
||||
filename = f"material_{int(time.time())}_{material_id}.json"
|
||||
materials_dir = get_materials_dir()
|
||||
now = int(time.time() * 1000)
|
||||
source_record = {"type": "generated_image", "image": source}
|
||||
recipe_filename = payload.get("recipe_filename")
|
||||
if isinstance(recipe_filename, str) and recipe_filename.strip():
|
||||
try:
|
||||
recipe_filename = require_filename(recipe_filename.strip())
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
recipe_filename = ""
|
||||
if recipe_filename.endswith(".json"):
|
||||
source_record.update(_snapshot_recipe_source(recipe_filename))
|
||||
prompt_role_overrides = _normalise_prompt_role_overrides(payload.get("promptRoleOverrides"))
|
||||
material = {
|
||||
"schema_version": MATERIAL_SCHEMA_VERSION,
|
||||
"id": material_id,
|
||||
"kind": "image_node_selection" if selected_node_ids is not None else "image_workflow_snapshot",
|
||||
"name": name,
|
||||
"tags": tags,
|
||||
"timestamp": now,
|
||||
"source": source_record,
|
||||
"workflow": workflow,
|
||||
"model_references": references,
|
||||
"capabilities": (
|
||||
["apply_node_parameters", "reference_image"]
|
||||
if selected_node_ids is not None
|
||||
else ["open_workflow", "apply_node_parameters", "reference_image"]
|
||||
),
|
||||
}
|
||||
if selected_node_ids is not None:
|
||||
material["selection"] = {"scope": "nodes", "node_ids": selected_node_ids}
|
||||
if prompt_role_overrides:
|
||||
material["promptRoleOverrides"] = prompt_role_overrides
|
||||
duplicate = await asyncio.to_thread(_persist_material, materials_dir, filename, source_path, material, allow_duplicate)
|
||||
if duplicate:
|
||||
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Could not save image material"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save image 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_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()
|
||||
name = payload.get("name", "")
|
||||
if not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
|
||||
raise ValueError("Invalid plan name")
|
||||
plan = _normalise_prompt_plan(payload.get("plan"))
|
||||
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(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},
|
||||
"capabilities": ["compose_prompt"], "selection": {"scope": "prompt_plan"}}
|
||||
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 plan"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save prompt plan"}, status=500)
|
||||
|
||||
|
||||
async def api_get_materials(request):
|
||||
try:
|
||||
payload = await asyncio.to_thread(_query_materials, get_materials_dir(), dict(request.query))
|
||||
return web.json_response(payload)
|
||||
except (TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid material filter"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not list materials"}, status=500)
|
||||
|
||||
|
||||
async def api_update_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
name = payload.get("name", "")
|
||||
if not filename.endswith(".json") or filename.startswith(".") or not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
|
||||
raise ValueError("Invalid material details")
|
||||
tags = _normalise_material_tags(payload.get("tags", []))
|
||||
update_prompt_roles = "promptRoleOverrides" in payload
|
||||
prompt_role_overrides = (
|
||||
_normalise_prompt_role_overrides(payload.get("promptRoleOverrides"))
|
||||
if update_prompt_roles else None
|
||||
)
|
||||
material = await asyncio.to_thread(
|
||||
_update_material_details,
|
||||
get_materials_dir(),
|
||||
filename,
|
||||
name.strip(),
|
||||
tags,
|
||||
prompt_role_overrides,
|
||||
update_prompt_roles,
|
||||
)
|
||||
return web.json_response({"status": "success", "material": material})
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid material details"}, 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)
|
||||
|
||||
|
||||
def _material_detail_response(path, include_workflow):
|
||||
material = _read_material(path)
|
||||
can_open = (material.get("kind") == "image_workflow_snapshot"
|
||||
and (material.get("selection") or {}).get("scope") != "nodes"
|
||||
and "open_workflow" in material.get("capabilities", []))
|
||||
if include_workflow == "1" and not can_open:
|
||||
return web.json_response({"status": "error", "message": "Material cannot open a full workflow"}, status=403)
|
||||
workflow = material.get("workflow") or {}
|
||||
prompt_roles = _prompt_roles_for_workflow(workflow, material.get("promptRoleOverrides"))
|
||||
prompt_groups = _prompt_groups_from_roles(workflow, prompt_roles, _material_node_ids(material))
|
||||
blocks = _material_node_blocks(material, include_values=True) if include_workflow != "1" else []
|
||||
if include_workflow == "0" or not can_open:
|
||||
material.pop("workflow", None)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"data": material,
|
||||
"source_recipe": _live_recipe_source(material.get("source") or {}),
|
||||
"node_blocks": blocks,
|
||||
"workflow_hashes": _workflow_hashes_for_blocks(workflow, blocks),
|
||||
"prompt_roles": prompt_roles,
|
||||
"prompt_groups": prompt_groups,
|
||||
})
|
||||
|
||||
|
||||
async def api_get_material_full(request):
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid material filename")
|
||||
include_workflow = request.query.get("include_workflow")
|
||||
if include_workflow not in (None, "0", "1"):
|
||||
raise ValueError("Invalid workflow inclusion flag")
|
||||
return await asyncio.to_thread(_material_detail_response,
|
||||
resolve_within(get_materials_dir(), filename), include_workflow)
|
||||
except (AttributeError, 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 read material"}, status=500)
|
||||
|
||||
|
||||
async def api_get_materials_by_node_type(request):
|
||||
node_type = request.query.get("type", "")
|
||||
if not isinstance(node_type, str) or not node_type.strip() or len(node_type) > 200:
|
||||
return web.json_response({"status": "error", "message": "Invalid node type"}, status=400)
|
||||
materials_dir = get_materials_dir()
|
||||
|
||||
def collect():
|
||||
matches = []
|
||||
for summary in _list_materials(materials_dir):
|
||||
if node_type not in summary["node_types"]:
|
||||
continue
|
||||
try:
|
||||
material = _read_material(resolve_within(materials_dir, summary["filename"]))
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
blocks = [block for block in _material_node_blocks(material, include_values=True) if block["type"] == node_type]
|
||||
if blocks:
|
||||
matches.append({
|
||||
"filename": summary["filename"],
|
||||
"name": summary["name"],
|
||||
"kind": summary["kind"],
|
||||
"timestamp": summary["timestamp"],
|
||||
"blocks": blocks,
|
||||
"workflow_hashes": _workflow_hashes_for_blocks(material["workflow"], blocks),
|
||||
})
|
||||
return matches
|
||||
|
||||
matches = await asyncio.to_thread(collect)
|
||||
return web.json_response({"status": "success", "materials": matches})
|
||||
|
||||
|
||||
async def api_get_material_asset(request):
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
asset_id = require_filename(request.query.get("asset", ""))
|
||||
if not filename.endswith(".json") or not asset_id.lower().endswith((".png", ".webp")):
|
||||
raise ValueError("Invalid material asset")
|
||||
materials_dir = get_materials_dir()
|
||||
await asyncio.to_thread(_summary_for_path, resolve_within(materials_dir, filename))
|
||||
asset_path = resolve_within(_material_assets_dir(materials_dir, filename), asset_id)
|
||||
if not os.path.isfile(asset_path):
|
||||
raise FileNotFoundError
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid material asset"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Material asset not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read material asset"}, status=500)
|
||||
return web.FileResponse(asset_path)
|
||||
|
||||
|
||||
async def api_delete_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid material filename")
|
||||
materials_dir = get_materials_dir()
|
||||
path = resolve_within(materials_dir, filename)
|
||||
await asyncio.to_thread(_delete_material, materials_dir, filename, path)
|
||||
except (AttributeError, 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 delete material"}, status=500)
|
||||
return web.json_response({"status": "success"})
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Model cover, thumbnail-cache, and generated-image lookup routes."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .path_utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
|
||||
|
||||
CARD_THUMBNAIL_EDGE = 512
|
||||
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
|
||||
_thumbnail_cleanup_lock = threading.Lock()
|
||||
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
|
||||
_thumbnail_last_cleanup = 0.0
|
||||
_thumbnail_created_since_cleanup = 0
|
||||
|
||||
|
||||
def _thumbnail_cache_directory():
|
||||
try:
|
||||
base_dir = folder_paths.get_temp_directory()
|
||||
except Exception:
|
||||
base_dir = None
|
||||
if not isinstance(base_dir, (str, os.PathLike)):
|
||||
base_dir = tempfile.gettempdir()
|
||||
cache_dir = os.path.join(base_dir, "anomalous_model_browser", "card_thumbnails")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
|
||||
def _prune_thumbnail_cache(cache_dir, created=False):
|
||||
"""Occasionally cap the derived thumbnail cache without touching source covers."""
|
||||
global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup
|
||||
now = time.monotonic()
|
||||
with _thumbnail_cleanup_lock:
|
||||
if created:
|
||||
_thumbnail_created_since_cleanup += 1
|
||||
now = time.monotonic()
|
||||
if (
|
||||
now - _thumbnail_last_cleanup < 3600
|
||||
and _thumbnail_created_since_cleanup < 64
|
||||
):
|
||||
return
|
||||
_thumbnail_last_cleanup = now
|
||||
_thumbnail_created_since_cleanup = 0
|
||||
entries = []
|
||||
total_size = 0
|
||||
try:
|
||||
with os.scandir(cache_dir) as iterator:
|
||||
for entry in iterator:
|
||||
if not entry.is_file() or not entry.name.endswith('.webp'):
|
||||
continue
|
||||
try:
|
||||
stat = entry.stat()
|
||||
except OSError:
|
||||
continue
|
||||
total_size += stat.st_size
|
||||
entries.append((stat.st_mtime_ns, stat.st_size, entry.path))
|
||||
except OSError:
|
||||
return
|
||||
if total_size <= CARD_THUMBNAIL_CACHE_LIMIT:
|
||||
return
|
||||
target_size = int(CARD_THUMBNAIL_CACHE_LIMIT * 0.8)
|
||||
for _, size, path in sorted(entries):
|
||||
try:
|
||||
os.remove(path)
|
||||
total_size -= size
|
||||
except OSError:
|
||||
pass
|
||||
if total_size <= target_size:
|
||||
break
|
||||
|
||||
|
||||
def _build_card_thumbnail_impl(source_path):
|
||||
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
source_stat = os.stat(source_path)
|
||||
cache_key = "\0".join((
|
||||
os.path.realpath(source_path),
|
||||
str(source_stat.st_size),
|
||||
str(source_stat.st_mtime_ns),
|
||||
str(getattr(source_stat, 'st_ctime_ns', 0)),
|
||||
str(CARD_THUMBNAIL_EDGE),
|
||||
))
|
||||
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
|
||||
cache_dir = _thumbnail_cache_directory()
|
||||
cached_path = os.path.join(cache_dir, f"{digest}.webp")
|
||||
if os.path.isfile(cached_path):
|
||||
try:
|
||||
os.utime(cached_path, None)
|
||||
except OSError:
|
||||
pass
|
||||
_prune_thumbnail_cache(cache_dir)
|
||||
return cached_path
|
||||
|
||||
with Image.open(source_path) as image:
|
||||
if getattr(image, 'is_animated', False):
|
||||
return source_path
|
||||
image = ImageOps.exif_transpose(image)
|
||||
if max(image.size) <= CARD_THUMBNAIL_EDGE:
|
||||
return source_path
|
||||
resampling = getattr(Image, 'Resampling', Image).LANCZOS
|
||||
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
|
||||
if image.mode not in ('RGB', 'RGBA'):
|
||||
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
|
||||
temp_path = (
|
||||
f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
)
|
||||
try:
|
||||
image.save(temp_path, format='WEBP', quality=84, method=4)
|
||||
os.replace(temp_path, cached_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
_prune_thumbnail_cache(cache_dir, created=True)
|
||||
return cached_path
|
||||
except Exception:
|
||||
return source_path
|
||||
|
||||
|
||||
def _build_card_thumbnail(source_path):
|
||||
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
|
||||
with _thumbnail_generation_slots:
|
||||
return _build_card_thumbnail_impl(source_path)
|
||||
|
||||
|
||||
async def api_serve_image(request):
|
||||
"""Dedicated image serving endpoint for model preview images."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
subfolder = request.query.get('subfolder', '')
|
||||
filename = request.query.get('filename', '')
|
||||
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
return web.Response(status=400, text='Invalid request')
|
||||
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
return web.Response(status=404, text='Image not found')
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
content_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.avif': 'image/avif',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo'
|
||||
}
|
||||
content_type = content_types.get(ext)
|
||||
if content_type is None:
|
||||
return web.Response(status=415, text='Unsupported media type')
|
||||
|
||||
served_path = file_path
|
||||
if (
|
||||
request.query.get('variant') == 'card'
|
||||
and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS
|
||||
):
|
||||
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
|
||||
if served_path != file_path:
|
||||
content_type = 'image/webp'
|
||||
|
||||
headers = {'Content-Type': content_type}
|
||||
if request.query.get('t'):
|
||||
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||
return web.FileResponse(served_path, headers=headers)
|
||||
|
||||
|
||||
async def api_clear_cache(request):
|
||||
try:
|
||||
from .metadata import clear_metadata_cache
|
||||
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
clear_metadata_cache()
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
def read_png_text_fast(path):
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
signature = f.read(8)
|
||||
if signature != b'\x89PNG\r\n\x1a\n':
|
||||
return None
|
||||
while True:
|
||||
length_bytes = f.read(4)
|
||||
if not length_bytes: break
|
||||
length = struct.unpack('>I', length_bytes)[0]
|
||||
chunk_type = f.read(4)
|
||||
if chunk_type == b'tEXt':
|
||||
data = f.read(length)
|
||||
keyword, text = data.split(b'\0', 1)
|
||||
if keyword == b'prompt':
|
||||
return text.decode('utf-8', errors='ignore')
|
||||
else:
|
||||
f.seek(length, 1)
|
||||
f.seek(4, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def api_get_model_images(request):
|
||||
model_name = request.rel_url.query.get('model_name', '')
|
||||
if not model_name:
|
||||
return web.json_response({'images': []})
|
||||
|
||||
base_target = os.path.basename(model_name).lower()
|
||||
|
||||
# We will search the output directory
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.exists(output_dir):
|
||||
return web.json_response({'images': []})
|
||||
|
||||
def find_images():
|
||||
matched_images = []
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for file in files:
|
||||
if not file.lower().endswith('.png'):
|
||||
continue
|
||||
full_path = os.path.join(root, file)
|
||||
prompt_text = read_png_text_fast(full_path)
|
||||
if not prompt_text:
|
||||
continue
|
||||
try:
|
||||
prompt_data = json.loads(prompt_text)
|
||||
matched = any(
|
||||
os.path.basename(v).lower() == base_target
|
||||
for node in prompt_data.values()
|
||||
if isinstance(node, dict) and 'class_type' in node
|
||||
for v in node.get('inputs', {}).values()
|
||||
if isinstance(v, str)
|
||||
)
|
||||
if not matched:
|
||||
continue
|
||||
rel_path = os.path.relpath(root, output_dir).replace('\\', '/')
|
||||
if rel_path == '.':
|
||||
rel_path = ''
|
||||
url = f'/view?filename={urllib.parse.quote(file)}&type=output'
|
||||
if rel_path:
|
||||
url += f'&subfolder={urllib.parse.quote(rel_path)}'
|
||||
matched_images.append({'url': url, 'mtime': os.path.getmtime(full_path)})
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
pass
|
||||
matched_images.sort(key=lambda x: x['mtime'], reverse=True)
|
||||
return matched_images
|
||||
|
||||
matched_images = await asyncio.to_thread(find_images)
|
||||
|
||||
return web.json_response({'images': matched_images})
|
||||
@@ -10,45 +10,10 @@ from functools import lru_cache
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
|
||||
def _read_safetensors_hash(file_path):
|
||||
"""Extract hash from safetensors header metadata (O(1) fast read, no full-file SHA256)."""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header_size_bytes = f.read(8)
|
||||
if len(header_size_bytes) < 8:
|
||||
return None
|
||||
header_size = struct.unpack('<Q', header_size_bytes)[0]
|
||||
if header_size > 100 * 1024 * 1024:
|
||||
return None
|
||||
|
||||
header_json_bytes = f.read(header_size)
|
||||
header_str = header_json_bytes.decode('utf-8')
|
||||
header_json = json.loads(header_str)
|
||||
|
||||
metadata = header_json.get('__metadata__', {})
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
if 'modelspec.hash.sha256' in metadata:
|
||||
return metadata['modelspec.hash.sha256']
|
||||
if 'modelspec.hash.blake3' in metadata:
|
||||
return metadata['modelspec.hash.blake3']
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _extract_safetensors_hash_cached(file_path, signature):
|
||||
return _read_safetensors_hash(file_path)
|
||||
|
||||
|
||||
def _extract_safetensors_hash(file_path):
|
||||
normalized_path = os.path.realpath(file_path)
|
||||
return _extract_safetensors_hash_cached(normalized_path, _file_signature(normalized_path))
|
||||
|
||||
try:
|
||||
from ..model_identity import sidecar_file_hash
|
||||
except ImportError:
|
||||
from model_identity import sidecar_file_hash
|
||||
|
||||
def _select_info_file(data, file_path):
|
||||
"""Select the Civitai file entry that actually describes ``file_path``.
|
||||
@@ -106,82 +71,90 @@ def _select_info_hash(data, file_path):
|
||||
hashes = selected.get("hashes", {})
|
||||
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
|
||||
|
||||
|
||||
def _positive_civitai_id(value):
|
||||
"""Return a real Civitai identifier, excluding offline sentinel values."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
def _read_metadata(file_path):
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
metadata = {
|
||||
"name": os.path.basename(base_path),
|
||||
"description": "",
|
||||
"notes": "",
|
||||
"trainedWords": [],
|
||||
"baseModel": "",
|
||||
"civitai_url": "",
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
metadata = {
|
||||
"name": os.path.basename(base_path),
|
||||
"description": "",
|
||||
"notes": "",
|
||||
"trainedWords": [],
|
||||
"baseModel": "",
|
||||
"civitai_url": "",
|
||||
"source_url": "",
|
||||
"hash": "",
|
||||
"hash_algorithm": "sha256",
|
||||
"hash_scope": "file",
|
||||
"hash_source": "",
|
||||
"custom_name": "",
|
||||
"custom_notes": ""
|
||||
}
|
||||
|
||||
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
|
||||
for info_file in info_files:
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
name = data.get("name", "")
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
model_name = data["model"].get("name", "")
|
||||
name = f"{model_name} - {name}".strip(' -')
|
||||
|
||||
description = data.get("description", "") or ""
|
||||
if not description and "model" in data and isinstance(data["model"], dict):
|
||||
description = data["model"].get("description", "")
|
||||
notes = data.get("notes", "")
|
||||
trained_words = data.get("trainedWords", [])
|
||||
base_model = data.get("baseModel", "")
|
||||
|
||||
model_id = data.get("modelId", "")
|
||||
version_id = data.get("id", "")
|
||||
civitai_url = ""
|
||||
if model_id:
|
||||
# Handle Civitai's new mature content policy
|
||||
nsfw_level = data.get("nsfwLevel", 1)
|
||||
is_nsfw = False
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
is_nsfw = data["model"].get("nsfw", False)
|
||||
|
||||
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
|
||||
civitai_url = f"https://{domain}/models/{model_id}"
|
||||
if version_id:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
|
||||
|
||||
selected_file = _select_info_file(data, file_path)
|
||||
hash_val = ""
|
||||
if selected_file:
|
||||
hashes = selected_file.get("hashes", {})
|
||||
if isinstance(hashes, dict):
|
||||
hash_val = str(hashes.get("SHA256", ""))
|
||||
"custom_notes": ""
|
||||
}
|
||||
|
||||
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
|
||||
for info_file in info_files:
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
name = data.get("name", "")
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
model_name = data["model"].get("name", "")
|
||||
name = f"{model_name} - {name}".strip(' -')
|
||||
|
||||
description = data.get("description", "") or ""
|
||||
if not description and "model" in data and isinstance(data["model"], dict):
|
||||
description = data["model"].get("description", "")
|
||||
notes = data.get("notes", "")
|
||||
trained_words = data.get("trainedWords", [])
|
||||
base_model = data.get("baseModel", "")
|
||||
|
||||
model_id = _positive_civitai_id(data.get("modelId"))
|
||||
version_id = _positive_civitai_id(data.get("id"))
|
||||
civitai_url = ""
|
||||
if model_id is not None:
|
||||
# Handle Civitai's new mature content policy
|
||||
nsfw_level = data.get("nsfwLevel", 1)
|
||||
is_nsfw = False
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
is_nsfw = data["model"].get("nsfw", False)
|
||||
|
||||
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
|
||||
civitai_url = f"https://{domain}/models/{model_id}"
|
||||
if version_id is not None:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
|
||||
|
||||
selected_file = _select_info_file(data, file_path)
|
||||
hash_val, hash_source = sidecar_file_hash(data, file_path, selected_file)
|
||||
if hash_val:
|
||||
metadata["hash_source"] = hash_source
|
||||
|
||||
if name: metadata["name"] = name
|
||||
if description: metadata["description"] = description
|
||||
if notes: metadata["notes"] = notes
|
||||
if trained_words: metadata["trainedWords"] = trained_words
|
||||
if base_model: metadata["baseModel"] = base_model
|
||||
if civitai_url: metadata["civitai_url"] = civitai_url
|
||||
if model_id: metadata["model_id"] = model_id
|
||||
if version_id: metadata["version_id"] = version_id
|
||||
if model_id is not None: metadata["model_id"] = model_id
|
||||
if version_id is not None: metadata["version_id"] = version_id
|
||||
if hash_val: metadata["hash"] = hash_val
|
||||
if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"]
|
||||
if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"]
|
||||
if "anomalous_source_url" in data and data["anomalous_source_url"]: metadata["source_url"] = data["anomalous_source_url"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: if no hash found from .info files, try extracting from safetensors header directly
|
||||
if not metadata["hash"] and file_path.endswith('.safetensors'):
|
||||
header_hash = _extract_safetensors_hash(file_path)
|
||||
if header_hash:
|
||||
metadata["hash"] = header_hash
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -220,7 +193,6 @@ def get_metadata(file_path):
|
||||
|
||||
def clear_metadata_cache():
|
||||
_get_metadata_cached.cache_clear()
|
||||
_extract_safetensors_hash_cached.cache_clear()
|
||||
|
||||
|
||||
def get_metadata_cache_info():
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
"""Model folder discovery, listing, search, and presentation lookup."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .metadata import get_metadata
|
||||
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
from .model_media import _cache_token, _preview_url_for_model
|
||||
from .folder_types import (
|
||||
get_active_folder_types, get_active_model_roots,
|
||||
get_active_physical_basenames, get_folder_view_mode,
|
||||
)
|
||||
from .path_utils import resolve_folder_subdir
|
||||
|
||||
def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, page, limit):
|
||||
"""Collect one folder in a worker thread with a single directory listing."""
|
||||
try:
|
||||
with os.scandir(target_dir) as iterator:
|
||||
file_entries = {}
|
||||
for entry in iterator:
|
||||
try:
|
||||
if entry.is_file():
|
||||
file_entries[entry.name] = entry
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
return {"models": [], "total": 0, "page": page, "limit": limit}
|
||||
|
||||
valid_files = sorted(
|
||||
(name for name in file_entries if name.endswith(('.safetensors', '.ckpt', '.pt'))),
|
||||
key=str.lower,
|
||||
)
|
||||
total = len(valid_files)
|
||||
if limit > 0:
|
||||
start = max(0, (page - 1) * limit)
|
||||
sliced = valid_files[start:start + limit]
|
||||
else:
|
||||
sliced = valid_files
|
||||
|
||||
q_type = urllib.parse.quote(folder_type)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder and rel_subfolder != '/' else ""
|
||||
models = []
|
||||
for filename in sliced:
|
||||
file_path = os.path.join(target_dir, filename)
|
||||
metadata = get_metadata(file_path)
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
preview_file = next(
|
||||
(base_name + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if base_name + suffix in file_entries),
|
||||
None,
|
||||
)
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
try:
|
||||
preview_version = file_entries[preview_file].stat().st_mtime_ns
|
||||
except OSError:
|
||||
preview_version = 0
|
||||
preview_url = (
|
||||
f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}"
|
||||
f"&filename={q_file}&t={preview_version}"
|
||||
)
|
||||
try:
|
||||
model_stat = file_entries[filename].stat()
|
||||
size_bytes = model_stat.st_size
|
||||
size_mb = round(size_bytes / (1024 * 1024), 2)
|
||||
except OSError:
|
||||
size_mb = 0
|
||||
size_bytes = 0
|
||||
models.append({
|
||||
"filename": filename,
|
||||
"file_path": os.path.abspath(file_path),
|
||||
"size_mb": size_mb,
|
||||
"size_bytes": size_bytes,
|
||||
"metadata": metadata,
|
||||
"preview_url": preview_url,
|
||||
"type": folder_type,
|
||||
"path_idx": path_idx,
|
||||
"subfolder": rel_subfolder,
|
||||
})
|
||||
return {"models": models, "total": total, "page": page, "limit": limit}
|
||||
|
||||
|
||||
def _collect_folders():
|
||||
mode = get_folder_view_mode()
|
||||
result = []
|
||||
seen_dirs = set()
|
||||
|
||||
if mode == "physical":
|
||||
active_bns = get_active_physical_basenames()
|
||||
all_paths_info = []
|
||||
|
||||
for t in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if not os.path.exists(base_dir): continue
|
||||
real_path = os.path.realpath(base_dir)
|
||||
if real_path in seen_dirs: continue
|
||||
seen_dirs.add(real_path)
|
||||
|
||||
bn = os.path.basename(os.path.normpath(base_dir))
|
||||
all_paths_info.append({
|
||||
"t": t,
|
||||
"path_idx": path_idx,
|
||||
"base_dir": base_dir,
|
||||
"bn": bn
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
for target_bn in active_bns:
|
||||
matched = [p for p in all_paths_info if p["bn"] == target_bn]
|
||||
if not matched: continue
|
||||
|
||||
for item in matched:
|
||||
base_dir = item["base_dir"]
|
||||
tree = {}
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
|
||||
rel = os.path.relpath(root, base_dir)
|
||||
if rel == '.':
|
||||
rel = '/'
|
||||
else:
|
||||
rel = '/' + rel.replace('\\', '/')
|
||||
tree[rel] = {
|
||||
"path": rel,
|
||||
"name": os.path.basename(root) if rel != '/' else '[Root]',
|
||||
"has_models": has_models,
|
||||
"model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
|
||||
}
|
||||
|
||||
label = item["bn"]
|
||||
if len(matched) > 1:
|
||||
label += f" ({item['path_idx'] + 1})"
|
||||
|
||||
result.append({
|
||||
"type": item["t"],
|
||||
"path_idx": item["path_idx"],
|
||||
"label": label,
|
||||
"folders": tree
|
||||
})
|
||||
else:
|
||||
types = get_active_folder_types()
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths:
|
||||
continue
|
||||
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
real_path = os.path.realpath(base_dir)
|
||||
if real_path in seen_dirs:
|
||||
continue
|
||||
seen_dirs.add(real_path)
|
||||
|
||||
tree = {}
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
|
||||
rel = os.path.relpath(root, base_dir)
|
||||
if rel == '.':
|
||||
rel = '/'
|
||||
else:
|
||||
rel = '/' + rel.replace('\\', '/')
|
||||
tree[rel] = {
|
||||
"path": rel,
|
||||
"name": os.path.basename(root) if rel != '/' else '[Root]',
|
||||
"has_models": has_models,
|
||||
"model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
|
||||
}
|
||||
|
||||
try:
|
||||
folder_basename = os.path.basename(os.path.normpath(base_dir))
|
||||
if not folder_basename:
|
||||
folder_basename = t
|
||||
except:
|
||||
folder_basename = t
|
||||
|
||||
label = folder_basename
|
||||
|
||||
basenames = []
|
||||
try:
|
||||
basenames = [os.path.basename(os.path.normpath(p)) for p in paths]
|
||||
except:
|
||||
pass
|
||||
if basenames.count(folder_basename) > 1:
|
||||
label += f" ({path_idx + 1})"
|
||||
|
||||
result.append({
|
||||
"type": t,
|
||||
"path_idx": path_idx,
|
||||
"label": label,
|
||||
"folders": tree
|
||||
})
|
||||
|
||||
return {"folders": result}
|
||||
|
||||
|
||||
async def api_get_folders(request):
|
||||
return web.json_response(await asyncio.to_thread(_collect_folders))
|
||||
|
||||
|
||||
async def api_get_models(request):
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
page = int(request.query.get('page', 1))
|
||||
limit = int(request.query.get('limit', 0))
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"models": [], "total": 0})
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
except (ValueError, KeyError):
|
||||
return web.Response(status=400, text='Invalid subfolder')
|
||||
rel_subfolder = "" if subfolder == '/' else subfolder.strip('/\\')
|
||||
if not os.path.exists(target_dir):
|
||||
return web.json_response({"models": [], "total": 0})
|
||||
payload = await asyncio.to_thread(
|
||||
_collect_folder_models,
|
||||
target_dir,
|
||||
folder_type,
|
||||
path_idx,
|
||||
rel_subfolder,
|
||||
page,
|
||||
limit,
|
||||
)
|
||||
return web.json_response(payload)
|
||||
|
||||
|
||||
def _iter_search_models():
|
||||
for folder_type in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for filename in files:
|
||||
if filename.lower().endswith(MODEL_EXTENSIONS + ('.sft',)):
|
||||
yield folder_type, path_idx, base_dir, os.path.join(root, filename)
|
||||
|
||||
|
||||
def _find_model_sync(search):
|
||||
normalized_search = search.replace('\\', '/').lower()
|
||||
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
|
||||
normalized_path = file_path.replace(os.sep, '/').lower()
|
||||
if normalized_search in os.path.basename(file_path).lower() or normalized_search in normalized_path:
|
||||
return _model_info_for_path(folder_type, path_idx, base_dir, file_path)
|
||||
|
||||
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
|
||||
metadata = get_metadata(file_path)
|
||||
searchable_names = (metadata.get('custom_name', ''), metadata.get('name', ''))
|
||||
if any(normalized_search in str(name).lower() for name in searchable_names if name):
|
||||
return _model_info_for_path(folder_type, path_idx, base_dir, file_path)
|
||||
return None
|
||||
|
||||
|
||||
async def api_find_model(request):
|
||||
search = request.query.get('search', '').strip()
|
||||
if not search:
|
||||
return web.json_response({"status": "error", "message": "No search query provided"})
|
||||
|
||||
result = await asyncio.to_thread(_find_model_sync, search)
|
||||
if not result:
|
||||
return web.json_response({"status": "error", "message": "Model not found"})
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"model": result,
|
||||
"type": result["type"],
|
||||
"path_idx": result["path_idx"],
|
||||
"subfolder": result["subfolder"],
|
||||
})
|
||||
|
||||
|
||||
# Prompt Notes ask for this base model to list models whose metadata names none.
|
||||
UNLABELED_BASE_MODEL = "__unlabeled__"
|
||||
|
||||
|
||||
def _normalised_base(value):
|
||||
return str(value or "").strip().lower().replace(" ", "")
|
||||
|
||||
|
||||
def _walk_model_files(target_types):
|
||||
"""Yield (folder_type, path_idx, base_dir, root, filename, metadata) once per real file."""
|
||||
seen_files = set()
|
||||
for folder_type in target_types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
continue
|
||||
for path_idx, base_dir in enumerate(paths or []):
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for filename in files:
|
||||
if not filename.lower().endswith(MODEL_EXTENSIONS):
|
||||
continue
|
||||
file_path = os.path.join(root, filename)
|
||||
real_path = os.path.realpath(file_path)
|
||||
if real_path in seen_files:
|
||||
continue
|
||||
seen_files.add(real_path)
|
||||
yield folder_type, path_idx, base_dir, root, filename, get_metadata(file_path)
|
||||
|
||||
|
||||
def _base_matches(requested, metadata):
|
||||
model_base = _normalised_base(metadata.get("baseModel"))
|
||||
if requested == UNLABELED_BASE_MODEL:
|
||||
return not model_base
|
||||
wanted = _normalised_base(requested)
|
||||
return bool(wanted and model_base and (wanted in model_base or model_base in wanted))
|
||||
|
||||
|
||||
def _compatible_models_sync(base_model, target_types):
|
||||
compatible_models = []
|
||||
for folder_type, path_idx, base_dir, root, filename, meta in _walk_model_files(target_types):
|
||||
if not _base_matches(base_model, meta):
|
||||
continue
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
rel_subfolder = '/' if rel_subfolder == '.' else '/' + rel_subfolder.replace('\\', '/')
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
preview_file = None
|
||||
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
preview_file = base_name + ext
|
||||
break
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
|
||||
preview_url = (
|
||||
f"/anomalous/image?type={urllib.parse.quote(folder_type)}&path_idx={path_idx}"
|
||||
f"&subfolder={q_sub}&filename={urllib.parse.quote(preview_file)}"
|
||||
)
|
||||
try:
|
||||
size_bytes = os.path.getsize(os.path.join(root, filename))
|
||||
except OSError:
|
||||
size_bytes = 0
|
||||
compatible_models.append({
|
||||
"type": folder_type,
|
||||
"path_idx": path_idx,
|
||||
"subfolder": rel_subfolder,
|
||||
"filename": filename,
|
||||
"size_mb": round(size_bytes / (1024 * 1024), 1),
|
||||
"size_bytes": size_bytes,
|
||||
"preview_url": preview_url,
|
||||
"metadata": meta,
|
||||
})
|
||||
return compatible_models
|
||||
|
||||
|
||||
async def api_compatible_models(request):
|
||||
base_model = request.query.get('base_model', '')
|
||||
if not base_model:
|
||||
return web.json_response({"models": []})
|
||||
target_types = [t.strip() for t in request.query.get('target_type', 'loras').split(',') if t.strip()]
|
||||
models = await asyncio.to_thread(_compatible_models_sync, base_model, target_types)
|
||||
return web.json_response({"models": models})
|
||||
|
||||
|
||||
def _base_models_sync(target_types):
|
||||
base_models = set()
|
||||
unlabeled = False
|
||||
for *_, meta in _walk_model_files(target_types):
|
||||
label = str(meta.get("baseModel") or "").strip()
|
||||
if label:
|
||||
base_models.add(label)
|
||||
else:
|
||||
unlabeled = True
|
||||
return sorted(base_models), unlabeled
|
||||
|
||||
|
||||
async def api_base_models(request):
|
||||
base_models, unlabeled = await asyncio.to_thread(_base_models_sync, get_active_folder_types())
|
||||
return web.json_response({"base_models": base_models, "unlabeled": unlabeled})
|
||||
|
||||
|
||||
def _model_info_for_path(folder_type, path_idx, base_dir, file_path):
|
||||
root = os.path.dirname(file_path)
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.':
|
||||
rel_subfolder = '/'
|
||||
else:
|
||||
rel_subfolder = '/' + rel_subfolder.replace(os.sep, '/')
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
except OSError:
|
||||
size_bytes = 0
|
||||
return {
|
||||
"type": folder_type,
|
||||
"path_idx": path_idx,
|
||||
"subfolder": rel_subfolder,
|
||||
"filename": os.path.basename(file_path),
|
||||
"file_path": os.path.abspath(file_path),
|
||||
"size_bytes": size_bytes,
|
||||
"size_mb": round(size_bytes / (1024 * 1024), 2),
|
||||
"preview_url": _preview_url_for_model(folder_type, path_idx, base_dir, file_path),
|
||||
"metadata": get_metadata(file_path),
|
||||
}
|
||||
|
||||
|
||||
def _allowed_folder_types(requested_types=None):
|
||||
available = list(folder_paths.folder_names_and_paths.keys())
|
||||
if requested_types is None:
|
||||
return available
|
||||
if not isinstance(requested_types, list):
|
||||
return []
|
||||
allowed = set(available)
|
||||
return list(dict.fromkeys(
|
||||
folder_type
|
||||
for folder_type in requested_types
|
||||
if isinstance(folder_type, str) and folder_type in allowed
|
||||
))
|
||||
|
||||
|
||||
def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False):
|
||||
requested = [
|
||||
(path, path.replace('\\', '/').lower(), path.replace('\\', '/'))
|
||||
for path in paths
|
||||
if isinstance(path, str)
|
||||
]
|
||||
exact_results = {}
|
||||
roots = []
|
||||
for folder_type in _allowed_folder_types(folder_types):
|
||||
try:
|
||||
folder_dirs = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
continue
|
||||
for path_idx, base_dir in enumerate(folder_dirs or []):
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
real_base_dir = os.path.realpath(base_dir)
|
||||
roots.append((folder_type, path_idx, real_base_dir))
|
||||
for original, _, relative_path in requested:
|
||||
relative = relative_path.replace('/', os.sep)
|
||||
candidate = os.path.realpath(os.path.join(real_base_dir, relative))
|
||||
try:
|
||||
if os.path.commonpath((real_base_dir, candidate)) != real_base_dir:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
if os.path.isfile(candidate) and candidate.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
||||
exact_results[original] = _model_info_for_path(folder_type, path_idx, base_dir, candidate)
|
||||
|
||||
unresolved = [(original, normalized) for original, normalized, _ in requested if original not in exact_results]
|
||||
if exact_only or not unresolved:
|
||||
return exact_results
|
||||
|
||||
wanted_relpaths = {normalized for _, normalized in unresolved}
|
||||
wanted_basenames = {normalized.rsplit('/', 1)[-1] for _, normalized in unresolved}
|
||||
rel_matches = {}
|
||||
basename_matches = {}
|
||||
for folder_type, path_idx, base_dir in roots:
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for filename in files:
|
||||
if not filename.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
||||
continue
|
||||
rel_path = os.path.relpath(os.path.join(root, filename), base_dir).replace(os.sep, '/').lower()
|
||||
basename = filename.lower()
|
||||
if rel_path not in wanted_relpaths and basename not in wanted_basenames:
|
||||
continue
|
||||
candidate_info = _model_info_for_path(
|
||||
folder_type,
|
||||
path_idx,
|
||||
base_dir,
|
||||
os.path.join(root, filename),
|
||||
)
|
||||
if rel_path in wanted_relpaths:
|
||||
rel_matches[rel_path] = candidate_info
|
||||
if basename in wanted_basenames:
|
||||
basename_matches[basename] = candidate_info
|
||||
|
||||
model_info = dict(exact_results)
|
||||
for original, normalized in unresolved:
|
||||
if normalized in rel_matches:
|
||||
model_info[original] = rel_matches[normalized]
|
||||
else:
|
||||
basename = normalized.rsplit('/', 1)[-1]
|
||||
if basename in basename_matches:
|
||||
model_info[original] = basename_matches[basename]
|
||||
return model_info
|
||||
|
||||
|
||||
def _resolve_paths_to_previews_sync(paths, folder_types=None, exact_only=False):
|
||||
model_info = _resolve_paths_to_model_info_sync(paths, folder_types, exact_only)
|
||||
return {path: item.get("preview_url", "") for path, item in model_info.items()}
|
||||
|
||||
|
||||
async def api_resolve_paths_to_previews(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
paths = data.get('paths', [])
|
||||
folder_types = data.get('folder_types')
|
||||
context_requests = data.get('context_requests', [])
|
||||
exact_only = data.get('exact_only') is True
|
||||
except:
|
||||
return web.json_response({"previews": {}, "models": {}, "context_models": {}})
|
||||
|
||||
model_info = await asyncio.to_thread(_resolve_paths_to_model_info_sync, paths, folder_types, exact_only)
|
||||
context_models = {}
|
||||
if isinstance(context_requests, list):
|
||||
for item in context_requests[:16]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
context_path = item.get('path')
|
||||
if not isinstance(context_path, str) or not context_path:
|
||||
continue
|
||||
resolved = await asyncio.to_thread(
|
||||
_resolve_paths_to_model_info_sync,
|
||||
[context_path],
|
||||
item.get('folder_types'),
|
||||
item.get('exact_only') is True or exact_only,
|
||||
)
|
||||
if context_path in resolved:
|
||||
context_key = item.get('key')
|
||||
if not isinstance(context_key, str) or not context_key:
|
||||
context_key = context_path
|
||||
context_models[context_key] = resolved[context_path]
|
||||
|
||||
previews = {path: item.get("preview_url", "") for path, item in model_info.items()}
|
||||
return web.json_response({
|
||||
"previews": previews,
|
||||
"models": model_info,
|
||||
"context_models": context_models,
|
||||
})
|
||||
|
||||
|
||||
def _collect_all_scan_models(page, limit):
|
||||
source_library_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf'}
|
||||
all_tuples = []
|
||||
for model_root in get_active_model_roots():
|
||||
t = model_root["type"]
|
||||
path_idx = model_root["path_idx"]
|
||||
base_dir = model_root["base_dir"]
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if os.path.splitext(f)[1].lower() in source_library_extensions:
|
||||
all_tuples.append((t, path_idx, root, base_dir, f))
|
||||
|
||||
all_tuples.sort(key=lambda x: (x[0], x[4].lower()))
|
||||
total = len(all_tuples)
|
||||
|
||||
if limit > 0:
|
||||
start = (page - 1) * limit
|
||||
end = start + limit
|
||||
sliced = all_tuples[start:end]
|
||||
else:
|
||||
sliced = all_tuples
|
||||
|
||||
all_models = []
|
||||
for t, path_idx, root, base_dir, f in sliced:
|
||||
file_path = os.path.join(root, f)
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.': rel_subfolder = ''
|
||||
else: rel_subfolder = rel_subfolder.replace('\\', '/')
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
size_mb = round(size_bytes / (1024 * 1024), 2)
|
||||
except:
|
||||
size_bytes = 0; size_mb = 0
|
||||
meta = get_metadata(file_path)
|
||||
base_name = os.path.splitext(f)[0]
|
||||
preview_file = None
|
||||
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
preview_file = base_name + ext
|
||||
break
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
q_type = urllib.parse.quote(t)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder and rel_subfolder != '/' else ""
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
mtime = _cache_token(os.path.join(root, preview_file))
|
||||
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={mtime}"
|
||||
all_models.append({
|
||||
"type": t, "path_idx": path_idx, "subfolder": rel_subfolder,
|
||||
"filename": f, "size_mb": size_mb, "size_bytes": size_bytes,
|
||||
"preview_url": preview_url, "metadata": meta
|
||||
})
|
||||
return {"models": all_models, "total": total, "page": page, "limit": limit}
|
||||
|
||||
|
||||
async def api_get_all_scan_models(request):
|
||||
page = int(request.query.get('page', 1))
|
||||
limit = int(request.query.get('limit', 0))
|
||||
payload = await asyncio.to_thread(_collect_all_scan_models, page, limit)
|
||||
return web.json_response(payload)
|
||||
|
||||
|
||||
async def api_batch_select(request):
|
||||
folder_key = request.query.get('folderKey', 'ALL')
|
||||
action = request.query.get('action', 'all')
|
||||
|
||||
def matches_condition(file_path, root, base_name):
|
||||
if action == 'all':
|
||||
return True
|
||||
elif action == 'no_preview':
|
||||
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
return False
|
||||
return True
|
||||
elif action == 'no_desc':
|
||||
info_file = file_path + '.info'
|
||||
civitai_info = os.path.join(root, base_name + '.civitai.info')
|
||||
if os.path.exists(civitai_info):
|
||||
try:
|
||||
with open(civitai_info, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('description', '').strip():
|
||||
return False
|
||||
except: pass
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('description', '').strip():
|
||||
return False
|
||||
except: pass
|
||||
return True
|
||||
return False
|
||||
|
||||
results = {}
|
||||
|
||||
if folder_key == 'ALL':
|
||||
target_types = get_active_folder_types()
|
||||
seen_dirs = set()
|
||||
for t in target_types:
|
||||
try: paths = folder_paths.get_folder_paths(t)
|
||||
except Exception: continue
|
||||
if not paths: continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if base_dir in seen_dirs: continue
|
||||
seen_dirs.add(base_dir)
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
||||
file_path = os.path.join(root, f)
|
||||
base_name = os.path.splitext(f)[0]
|
||||
if matches_condition(file_path, root, base_name):
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.': rel_subfolder = ''
|
||||
else: rel_subfolder = rel_subfolder.replace('\\', '/')
|
||||
fkey = f"{t}|{path_idx}|{rel_subfolder}"
|
||||
if fkey not in results: results[fkey] = []
|
||||
results[fkey].append(f)
|
||||
else:
|
||||
parts = folder_key.split('|')
|
||||
if len(parts) >= 3:
|
||||
t = parts[0]
|
||||
path_idx = int(parts[1])
|
||||
subfolder = parts[2]
|
||||
|
||||
try: paths = folder_paths.get_folder_paths(t)
|
||||
except Exception: paths = []
|
||||
|
||||
if paths and path_idx < len(paths):
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(t, path_idx, subfolder)
|
||||
except ValueError:
|
||||
return web.json_response({"selected": {}})
|
||||
|
||||
if os.path.exists(target_dir):
|
||||
try: entries = os.listdir(target_dir)
|
||||
except: entries = []
|
||||
for f in entries:
|
||||
if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
||||
file_path = os.path.join(target_dir, f)
|
||||
if os.path.isfile(file_path):
|
||||
base_name = os.path.splitext(f)[0]
|
||||
if matches_condition(file_path, target_dir, base_name):
|
||||
if folder_key not in results: results[folder_key] = []
|
||||
results[folder_key].append(f)
|
||||
|
||||
return web.json_response({"selected": results})
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Shared model catalog, media, and recovery constants."""
|
||||
|
||||
MEDIA_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi')
|
||||
MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.bin')
|
||||
PREVIEW_SUFFIXES = tuple(f'.preview{ext}' for ext in MEDIA_EXTENSIONS)
|
||||
CIVITAI_BACKUP_SUFFIXES = tuple(f'.civitai_bak{ext}' for ext in MEDIA_EXTENSIONS)
|
||||
SIDECAR_SUFFIXES = (
|
||||
'.info', '.civitai.info', '.json', '.txt', '.yaml',
|
||||
*MEDIA_EXTENSIONS,
|
||||
*PREVIEW_SUFFIXES,
|
||||
*CIVITAI_BACKUP_SUFFIXES,
|
||||
)
|
||||
RESOLVABLE_MODEL_TYPES = (
|
||||
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet',
|
||||
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision',
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Model preview lookup and bounded custom-cover operations."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
|
||||
def _cache_token(file_path):
|
||||
try:
|
||||
return os.stat(file_path).st_mtime_ns
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png'):
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
|
||||
# Always save custom covers with a .preview.[ext] suffix so standard nodes recognize them as covers.
|
||||
if source_ext.startswith('.preview.'):
|
||||
preview_ext = source_ext
|
||||
else:
|
||||
preview_ext = f".preview{source_ext}"
|
||||
|
||||
dest_path = os.path.join(target_dir, f"{base_name}{preview_ext}")
|
||||
|
||||
# Delete any existing .preview.* files to ensure only one custom cover is active
|
||||
for ext in PREVIEW_SUFFIXES:
|
||||
p = os.path.join(target_dir, f"{base_name}{ext}")
|
||||
if os.path.exists(p) and p != dest_path:
|
||||
try: os.remove(p)
|
||||
except: pass
|
||||
|
||||
await save_func(dest_path)
|
||||
|
||||
|
||||
async def api_set_custom_cover(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
filename = data.get('filename', '')
|
||||
source_image = data.get('source_image', '')
|
||||
try: path_idx = int(data.get('path_idx', 0))
|
||||
except: path_idx = 0
|
||||
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
src_path = resolve_within(output_dir, source_image)
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if not os.path.exists(src_path):
|
||||
return web.json_response({"status": "error", "message": "Source image not found in output directory"})
|
||||
|
||||
source_ext = os.path.splitext(src_path)[1].lower()
|
||||
if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}:
|
||||
return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415)
|
||||
|
||||
async def save_copy(dest_path):
|
||||
import shutil
|
||||
import asyncio
|
||||
await asyncio.to_thread(shutil.copy2, src_path, dest_path)
|
||||
|
||||
await _handle_custom_cover(target_dir, filename, save_copy, source_ext)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
async def api_upload_custom_cover(request):
|
||||
try:
|
||||
data = await request.post()
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
filename = data.get('filename', '')
|
||||
try: path_idx = int(data.get('path_idx', 0))
|
||||
except: path_idx = 0
|
||||
|
||||
image_field = data.get('image')
|
||||
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if image_field is None:
|
||||
return web.json_response({"status": "error", "message": "Image is required"}, status=400)
|
||||
|
||||
image_data = image_field.file.read()
|
||||
if len(image_data) > 100 * 1024 * 1024:
|
||||
return web.json_response({"status": "error", "message": "Cover file is too large"}, status=413)
|
||||
|
||||
upload_filename = image_field.filename
|
||||
source_ext = os.path.splitext(upload_filename)[1].lower()
|
||||
if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}:
|
||||
return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415)
|
||||
|
||||
async def save_upload(dest_path):
|
||||
def write_file():
|
||||
with open(dest_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
import asyncio
|
||||
await asyncio.to_thread(write_file)
|
||||
|
||||
await _handle_custom_cover(target_dir, filename, save_upload, source_ext)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
def _preview_url_for_model(folder_type, path_idx, base_dir, file_path):
|
||||
root = os.path.dirname(file_path)
|
||||
filename = os.path.basename(file_path)
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
preview_file = next(
|
||||
(
|
||||
base_name + suffix
|
||||
for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
|
||||
if os.path.isfile(os.path.join(root, base_name + suffix))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not preview_file:
|
||||
return ""
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.':
|
||||
rel_subfolder = '/'
|
||||
q_type = urllib.parse.quote(folder_type)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder)
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
version = _cache_token(os.path.join(root, preview_file))
|
||||
return f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={version}"
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Model metadata, physical rename, deletion, and sidecar lifecycle."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ..model_policies import is_physical_rename_protected
|
||||
except ImportError:
|
||||
from model_policies import is_physical_rename_protected
|
||||
from .model_constants import (
|
||||
CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, MODEL_EXTENSIONS,
|
||||
PREVIEW_SUFFIXES, SIDECAR_SUFFIXES,
|
||||
)
|
||||
from .model_catalog import _resolve_paths_to_model_info_sync
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
|
||||
def _first_existing_sidecar(base_path, suffixes):
|
||||
for suffix in suffixes:
|
||||
candidate = f"{base_path}{suffix}"
|
||||
if os.path.isfile(candidate):
|
||||
return candidate, suffix
|
||||
return None, None
|
||||
|
||||
|
||||
def _reset_model_cover(base_path):
|
||||
"""Restore a recoverable cover without destroying the only existing image."""
|
||||
backup_path, backup_suffix = _first_existing_sidecar(base_path, CIVITAI_BACKUP_SUFFIXES)
|
||||
original_path, _ = _first_existing_sidecar(base_path, MEDIA_EXTENSIONS)
|
||||
preview_paths = [
|
||||
f"{base_path}{suffix}"
|
||||
for suffix in PREVIEW_SUFFIXES
|
||||
if os.path.isfile(f"{base_path}{suffix}")
|
||||
]
|
||||
|
||||
if backup_path:
|
||||
media_ext = backup_suffix[len('.civitai_bak'):]
|
||||
restored_path = f"{base_path}.preview{media_ext}"
|
||||
temp_path = f"{restored_path}.anomalous_tmp"
|
||||
try:
|
||||
# Copy first. If the backup cannot be read, the active custom cover
|
||||
# remains untouched. os.replace then makes the actual restore atomic.
|
||||
shutil.copy2(backup_path, temp_path)
|
||||
os.replace(temp_path, restored_path)
|
||||
for preview_path in preview_paths:
|
||||
if preview_path != restored_path and os.path.isfile(preview_path):
|
||||
os.remove(preview_path)
|
||||
except Exception as exc:
|
||||
try:
|
||||
if os.path.isfile(temp_path):
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
return False, 'restore_failed', str(exc)
|
||||
return True, 'civitai_backup', None
|
||||
|
||||
if original_path:
|
||||
try:
|
||||
for preview_path in preview_paths:
|
||||
os.remove(preview_path)
|
||||
except Exception as exc:
|
||||
return False, 'restore_failed', str(exc)
|
||||
return True, 'original_cover', None
|
||||
|
||||
if preview_paths:
|
||||
# There is no recoverable source. Keep the current cover instead of
|
||||
# turning a harmless Reset click into irreversible image loss.
|
||||
return False, 'preserved_current', 'No Civitai backup or original cover exists.'
|
||||
|
||||
return True, 'no_cover', None
|
||||
|
||||
|
||||
async def api_delete_model(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:
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
filename = data.get('filename', '')
|
||||
try:
|
||||
path_idx = int(data.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
model_path = resolve_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if not os.path.exists(model_path):
|
||||
return web.json_response({"status": "error", "message": "Model file not found"})
|
||||
|
||||
# 1. 优先尝试删除你点击的主模型文件
|
||||
try:
|
||||
os.remove(model_path)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "being used" in error_msg or "WinError 32" in error_msg or "Permission" in error_msg:
|
||||
error_msg = "文件被占用 (正在被 ComfyUI 使用)。请先重启 ComfyUI 或在工作流中卸载该模型后再删除!"
|
||||
return web.json_response({"status": "error", "message": f"主模型删除失败: {error_msg}"})
|
||||
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
|
||||
# 2. 主模型成功删除后,再清理配套的垃圾文件
|
||||
# Sidecars are keyed by stem, not by the main model extension. If a
|
||||
# second real model shares this stem, preserve the shared sidecars for
|
||||
# the survivor instead of treating that model as cleanup debris.
|
||||
shared_stem_in_use = any(
|
||||
os.path.isfile(os.path.join(target_dir, base_name + model_ext))
|
||||
for model_ext in MODEL_EXTENSIONS
|
||||
)
|
||||
deleted_files = [filename]
|
||||
if not shared_stem_in_use:
|
||||
for suffix in SIDECAR_SUFFIXES:
|
||||
file_to_del = os.path.join(target_dir, base_name + suffix)
|
||||
if os.path.isfile(file_to_del):
|
||||
try:
|
||||
os.remove(file_to_del)
|
||||
deleted_files.append(base_name + suffix)
|
||||
except Exception as e:
|
||||
print(f"[Anomalous Browser] Warning: Failed to delete {file_to_del}: {e}")
|
||||
|
||||
# 3. 修正:前端期待的成功状态是 "success" 而不是 "ok"
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"deleted": deleted_files,
|
||||
"sidecars_preserved": shared_stem_in_use,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
async def api_update_metadata(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
raw_filename = data.get('filename', '')
|
||||
custom_name = data.get('custom_name', '')
|
||||
custom_notes = data.get('custom_notes', '')
|
||||
custom_source_url = data.get('custom_source_url', None)
|
||||
physical_rename_requested = data.get('physical_rename', False)
|
||||
try: path_idx = int(data.get('path_idx', 0))
|
||||
except: path_idx = 0
|
||||
|
||||
# Normalize filename and subfolder if directory separators are included
|
||||
extracted_filename = raw_filename
|
||||
if isinstance(raw_filename, str) and ('/' in raw_filename or '\\' in raw_filename):
|
||||
if subfolder in (None, '', '/'):
|
||||
extracted_sub = os.path.dirname(raw_filename).replace('\\', '/')
|
||||
if extracted_sub:
|
||||
subfolder = '/' + extracted_sub.strip('/')
|
||||
extracted_filename = os.path.basename(raw_filename)
|
||||
|
||||
file_path = None
|
||||
target_dir = None
|
||||
filename = extracted_filename
|
||||
try:
|
||||
filename = require_filename(extracted_filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
# Fallback: attempt to locate model across known model directories
|
||||
search_keys = [k for k in [raw_filename, extracted_filename] if k and isinstance(k, str)]
|
||||
fallback_resolved = _resolve_paths_to_model_info_sync(search_keys)
|
||||
resolved_info = fallback_resolved.get(raw_filename) or fallback_resolved.get(extracted_filename)
|
||||
if resolved_info:
|
||||
resolved_path = resolved_info.get("file_path")
|
||||
if resolved_path and os.path.exists(resolved_path):
|
||||
file_path = resolved_path
|
||||
target_dir = os.path.dirname(file_path)
|
||||
folder_type = resolved_info.get("type", folder_type)
|
||||
path_idx = resolved_info.get("path_idx", path_idx)
|
||||
filename = resolved_info.get("filename", filename)
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return web.json_response({"status": "error", "message": "Model not found"})
|
||||
|
||||
physical_rename_skipped = physical_rename_requested and is_physical_rename_protected(
|
||||
folder_type=folder_type,
|
||||
folder_path=target_dir,
|
||||
)
|
||||
physical_rename = physical_rename_requested and not physical_rename_skipped
|
||||
|
||||
base_name = os.path.splitext(file_path)[0]
|
||||
model_ext = os.path.splitext(file_path)[1]
|
||||
info_file = f"{base_name}.civitai.info"
|
||||
|
||||
info_data = {}
|
||||
if os.path.exists(info_file):
|
||||
parsed = False
|
||||
for enc in ['utf-8', 'utf-8-sig', 'mbcs', 'latin-1']:
|
||||
try:
|
||||
with open(info_file, 'r', encoding=enc) as f:
|
||||
info_data = json.load(f)
|
||||
parsed = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not parsed:
|
||||
return web.json_response({"status": "error", "message": "Failed to parse existing .civitai.info file due to encoding or corruption. Rename aborted to prevent data loss."})
|
||||
|
||||
if 'custom_name' in data:
|
||||
info_data["anomalous_custom_name"] = custom_name
|
||||
if 'custom_notes' in data:
|
||||
info_data["anomalous_custom_notes"] = custom_notes
|
||||
if custom_source_url is not None:
|
||||
info_data["anomalous_source_url"] = str(custom_source_url).strip()
|
||||
|
||||
reset_cover = data.get('reset_cover', False)
|
||||
cover_reset = None
|
||||
cover_reset_source = None
|
||||
cover_reset_warning = None
|
||||
if reset_cover:
|
||||
cover_reset, cover_reset_source, cover_reset_warning = _reset_model_cover(base_name)
|
||||
|
||||
with open(info_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(info_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
new_filename = filename
|
||||
|
||||
if physical_rename and custom_name:
|
||||
import re
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', custom_name).strip(' .')
|
||||
if not safe_name:
|
||||
return web.json_response({"status": "error", "message": "The physical filename cannot be empty."}, status=400)
|
||||
new_file_path = os.path.join(target_dir, f"{safe_name}{model_ext}")
|
||||
|
||||
if new_file_path != file_path and not os.path.exists(new_file_path):
|
||||
os.rename(file_path, new_file_path)
|
||||
|
||||
for suffix in SIDECAR_SUFFIXES:
|
||||
old_sidecar = f"{base_name}{suffix}"
|
||||
if os.path.isfile(old_sidecar):
|
||||
os.rename(old_sidecar, os.path.join(target_dir, f"{safe_name}{suffix}"))
|
||||
|
||||
new_filename = f"{safe_name}{model_ext}"
|
||||
elif os.path.exists(new_file_path) and new_file_path != file_path:
|
||||
return web.json_response({"status": "error", "message": "A file with the target physical name already exists."})
|
||||
|
||||
response_data = {
|
||||
"status": "success",
|
||||
"new_filename": new_filename,
|
||||
"physical_rename_skipped": physical_rename_skipped,
|
||||
}
|
||||
if reset_cover:
|
||||
response_data.update({
|
||||
"cover_reset": cover_reset,
|
||||
"cover_reset_source": cover_reset_source,
|
||||
"cover_reset_warning": cover_reset_warning,
|
||||
})
|
||||
return web.json_response(response_data)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Cryptographic and size-based missing-model resolution."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import threading
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
from ..model_identity import computed_file_identity, normalise_sha256
|
||||
except ImportError:
|
||||
from model_identity import computed_file_identity, normalise_sha256
|
||||
try:
|
||||
from ..model_policies import requires_hash_for_model_recovery
|
||||
except ImportError:
|
||||
from model_policies import requires_hash_for_model_recovery
|
||||
from .metadata import get_metadata
|
||||
from .model_constants import MODEL_EXTENSIONS, RESOLVABLE_MODEL_TYPES
|
||||
from .utils import atomic_write_json
|
||||
|
||||
def _parse_resolution_types(expected_types_raw):
|
||||
if not expected_types_raw:
|
||||
return RESOLVABLE_MODEL_TYPES
|
||||
requested = tuple(dict.fromkeys(
|
||||
value.strip() for value in str(expected_types_raw).split(',') if value.strip()
|
||||
))
|
||||
if not requested or any(value not in RESOLVABLE_MODEL_TYPES for value in requested):
|
||||
raise ValueError("Invalid model type")
|
||||
return requested
|
||||
|
||||
|
||||
def _collect_resolution_candidates(types):
|
||||
candidates = []
|
||||
seen_realpaths = set()
|
||||
for folder_type in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
continue
|
||||
for base_dir in paths or []:
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for filename in files:
|
||||
if not filename.lower().endswith(MODEL_EXTENSIONS):
|
||||
continue
|
||||
file_path = os.path.join(root, filename)
|
||||
real_path = os.path.realpath(file_path)
|
||||
if real_path in seen_realpaths:
|
||||
continue
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
except OSError:
|
||||
continue
|
||||
seen_realpaths.add(real_path)
|
||||
candidates.append({
|
||||
"type": folder_type,
|
||||
"filename": os.path.relpath(file_path, base_dir).replace('\\', '/'),
|
||||
"path": file_path,
|
||||
"size": file_size,
|
||||
})
|
||||
return candidates
|
||||
|
||||
|
||||
def _candidate_hashes(candidate):
|
||||
if "hashes" in candidate:
|
||||
return candidate["hashes"]
|
||||
values = set()
|
||||
metadata = candidate.get("metadata")
|
||||
if metadata is None:
|
||||
metadata = get_metadata(candidate["path"])
|
||||
candidate["metadata"] = metadata
|
||||
meta_hash = normalise_sha256(metadata.get("hash", ""))
|
||||
if meta_hash:
|
||||
values.add(str(meta_hash).upper())
|
||||
candidate["hashes"] = values
|
||||
return values
|
||||
|
||||
|
||||
def _resolved_payload(candidate, **details):
|
||||
payload = {
|
||||
"found": True,
|
||||
"type": candidate["type"],
|
||||
"filename": candidate["filename"],
|
||||
}
|
||||
payload.update(details)
|
||||
return payload
|
||||
|
||||
|
||||
def _size_candidate_payload(candidate, require_hash=False):
|
||||
"""Expose one in-category size match for explicit user confirmation only."""
|
||||
return {
|
||||
"found": False,
|
||||
"confirmation_required": True,
|
||||
"matched_by_size": True,
|
||||
"hash_required": bool(require_hash),
|
||||
"type": candidate["type"],
|
||||
"filename": candidate["filename"],
|
||||
"size": candidate["size"],
|
||||
}
|
||||
|
||||
|
||||
def _compute_and_save_fallback_info(file_path, file_hash):
|
||||
try:
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
info_path = base_path + ".info"
|
||||
civitai_info_path = base_path + ".civitai.info"
|
||||
if os.path.exists(info_path) or os.path.exists(civitai_info_path):
|
||||
info_path = info_path if os.path.exists(info_path) else civitai_info_path
|
||||
with open(info_path, encoding='utf-8') as source:
|
||||
info_data = json.load(source)
|
||||
else:
|
||||
from scraper import infer_base_model_from_header
|
||||
filename = os.path.basename(file_path)
|
||||
inferred_base = infer_base_model_from_header(file_path) if file_path.lower().endswith('.safetensors') else ""
|
||||
if inferred_base == 'Unknown':
|
||||
inferred_base = ""
|
||||
info_data = {
|
||||
"id": -1,
|
||||
"modelId": -1,
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"baseModel": inferred_base,
|
||||
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
|
||||
"model": {
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"type": "LORA" if "lora" in file_path.lower() else "Checkpoint"
|
||||
},
|
||||
"files": [{"hashes": {"SHA256": file_hash.lower()}}]
|
||||
}
|
||||
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
|
||||
atomic_write_json(info_path, info_data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_from_candidates(candidates, target_hash="", target_size=None, filename_query="", require_hash=False):
|
||||
target_hash = str(target_hash or "").strip().upper()
|
||||
# A saved filename/path is not identity evidence. It is intentionally
|
||||
# ignored here; exact path lookup for previews lives in the bounded
|
||||
# resolve_paths_to_previews endpoint and does not activate a model match.
|
||||
|
||||
has_target_hash = bool(target_hash and target_hash != "UNKNOWN")
|
||||
size_matches = [
|
||||
candidate for candidate in candidates
|
||||
if target_size is not None and candidate["size"] == target_size
|
||||
]
|
||||
|
||||
if has_target_hash and target_size is not None:
|
||||
combined_matches = [candidate for candidate in size_matches if target_hash in _candidate_hashes(candidate)]
|
||||
if len(combined_matches) == 1:
|
||||
return _resolved_payload(combined_matches[0], matched_by_hash=True, matched_by_size=True)
|
||||
if len(combined_matches) > 1:
|
||||
return {"found": False, "ambiguous": True}
|
||||
|
||||
# If no combined match found from static cache, but there are size-matching candidates without hash metadata,
|
||||
# dynamically compute their hash on-demand to test against target_hash
|
||||
unhashed_size_matches = [c for c in size_matches if not _candidate_hashes(c)]
|
||||
if unhashed_size_matches:
|
||||
from scraper import calculate_sha256
|
||||
for candidate in unhashed_size_matches:
|
||||
try:
|
||||
computed_hash = calculate_sha256(candidate["path"]).upper()
|
||||
candidate.setdefault("hashes", set()).add(computed_hash)
|
||||
if computed_hash == target_hash:
|
||||
_compute_and_save_fallback_info(candidate["path"], computed_hash)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dynamic_matches = [c for c in size_matches if target_hash in c.get("hashes", set())]
|
||||
if len(dynamic_matches) == 1:
|
||||
return _resolved_payload(dynamic_matches[0], matched_by_hash=True, matched_by_size=True)
|
||||
if len(dynamic_matches) > 1:
|
||||
return {"found": False, "ambiguous": True}
|
||||
|
||||
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
|
||||
if hash_matches or size_matches:
|
||||
return {"found": False, "identity_conflict": True}
|
||||
return {"found": False}
|
||||
|
||||
if has_target_hash:
|
||||
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
|
||||
if len(hash_matches) == 1:
|
||||
return _resolved_payload(hash_matches[0], matched_by_hash=True)
|
||||
if len(hash_matches) > 1:
|
||||
return {"found": False, "ambiguous": True}
|
||||
|
||||
if target_size is not None:
|
||||
if len(size_matches) == 1:
|
||||
return _size_candidate_payload(size_matches[0], require_hash=require_hash)
|
||||
if len(size_matches) > 1:
|
||||
return {"found": False, "ambiguous": True}
|
||||
if require_hash:
|
||||
return {"found": False, "hash_required": True}
|
||||
return {"found": False}
|
||||
|
||||
|
||||
async def api_resolve_hash(request):
|
||||
target_hash = request.query.get("hash", "").strip().upper()
|
||||
size_str = request.query.get("size", "").strip()
|
||||
filename_query = request.query.get("filename", "").strip()
|
||||
target_size = int(size_str) if size_str.isdigit() else None
|
||||
if not target_hash and target_size is None and not filename_query:
|
||||
return web.json_response({"found": False})
|
||||
try:
|
||||
types = _parse_resolution_types(request.query.get("type", "").strip())
|
||||
except ValueError as exc:
|
||||
return web.json_response({"found": False, "error": str(exc)}, status=400)
|
||||
|
||||
def resolve_one():
|
||||
candidates = _collect_resolution_candidates(types)
|
||||
return _resolve_from_candidates(
|
||||
candidates,
|
||||
target_hash,
|
||||
target_size,
|
||||
filename_query,
|
||||
require_hash=requires_hash_for_model_recovery(types),
|
||||
)
|
||||
|
||||
return web.json_response(await asyncio.to_thread(resolve_one))
|
||||
|
||||
|
||||
async def api_resolve_hash_batch(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
items = data.get("items", [])
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON body"}, status=400)
|
||||
if not isinstance(items, list) or len(items) > 256:
|
||||
return web.json_response({"error": "items must be a list with at most 256 entries"}, status=400)
|
||||
|
||||
parsed_items = []
|
||||
try:
|
||||
for index, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Invalid batch item")
|
||||
size_value = item.get("size")
|
||||
size_string = str(size_value).strip() if size_value is not None else ""
|
||||
parsed_items.append({
|
||||
"key": str(item.get("key", index)),
|
||||
"hash": str(item.get("hash", "")).strip().upper(),
|
||||
"size": int(size_string) if size_string.isdigit() else None,
|
||||
"types": _parse_resolution_types(str(item.get("type", "")).strip()),
|
||||
})
|
||||
except (TypeError, ValueError) as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
|
||||
def resolve_batch():
|
||||
candidate_groups = {}
|
||||
results = []
|
||||
for item in parsed_items:
|
||||
types = item["types"]
|
||||
if types not in candidate_groups:
|
||||
candidate_groups[types] = _collect_resolution_candidates(types)
|
||||
result = _resolve_from_candidates(
|
||||
candidate_groups[types],
|
||||
item["hash"],
|
||||
item["size"],
|
||||
require_hash=requires_hash_for_model_recovery(types),
|
||||
)
|
||||
results.append({"key": item["key"], "result": result})
|
||||
return results
|
||||
|
||||
return web.json_response({"results": await asyncio.to_thread(resolve_batch)})
|
||||
|
||||
|
||||
def collect_model_hash_index():
|
||||
"""Scanned models keyed by relative path and basename -> {hash, size, url}.
|
||||
|
||||
Ambiguous keys (same name, different hash) are dropped. Shared by the
|
||||
all-hashes endpoint and the output gallery's hash search.
|
||||
"""
|
||||
hashes = {}
|
||||
ambiguous_keys = set()
|
||||
|
||||
def add_hash(key, value):
|
||||
if key in ambiguous_keys:
|
||||
return
|
||||
existing = hashes.get(key)
|
||||
if existing is None or existing == value:
|
||||
hashes[key] = value
|
||||
else:
|
||||
hashes.pop(key, None)
|
||||
ambiguous_keys.add(key)
|
||||
|
||||
types = RESOLVABLE_MODEL_TYPES
|
||||
seen_dirs = set()
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for base_dir in paths:
|
||||
if base_dir in seen_dirs: continue
|
||||
seen_dirs.add(base_dir)
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith(MODEL_EXTENSIONS):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
except Exception:
|
||||
size_bytes = 0
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
hash_val = ""
|
||||
if meta and meta.get("hash"):
|
||||
hash_val = meta["hash"]
|
||||
model_url = ""
|
||||
if meta:
|
||||
model_url = meta.get("source_url") or meta.get("civitai_url") or ""
|
||||
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
||||
rel_path = rel_path[2:]
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
basename = os.path.basename(file_path)
|
||||
|
||||
val = {"hash": hash_val, "size": size_bytes, "url": model_url}
|
||||
add_hash(rel_path, val)
|
||||
add_hash(basename, val)
|
||||
except Exception:
|
||||
pass
|
||||
return hashes
|
||||
|
||||
|
||||
async def api_get_all_hashes(request):
|
||||
"""
|
||||
Returns a dictionary of all scanned models with their hash and size.
|
||||
Keyed by both relative path and basename for maximum frontend resilience.
|
||||
"""
|
||||
hashes = await asyncio.to_thread(collect_model_hash_index)
|
||||
return web.json_response(hashes)
|
||||
@@ -1,77 +1,136 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
from .utils import require_filename, resolve_within
|
||||
from .utils import atomic_write_json, require_filename, resolve_within
|
||||
|
||||
|
||||
MAX_NOTEBOOK_BYTES = 2 * 1024 * 1024
|
||||
LEGACY_NOTEBOOKS_DIR = os.path.join(os.path.dirname(__file__), "notebooks")
|
||||
_notebooks_lock = threading.RLock()
|
||||
|
||||
|
||||
def _read_notebook(path):
|
||||
if os.path.getsize(path) > MAX_NOTEBOOK_BYTES:
|
||||
raise ValueError("Notebook is too large")
|
||||
with open(path, encoding="utf-8") as source:
|
||||
data = json.load(source)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid notebook")
|
||||
return data
|
||||
|
||||
|
||||
def _migrate_notebooks(directory):
|
||||
marker = resolve_within(directory, ".legacy_imported.json")
|
||||
if os.path.exists(marker):
|
||||
return
|
||||
skipped = []
|
||||
if os.path.isdir(LEGACY_NOTEBOOKS_DIR):
|
||||
for filename in sorted(os.listdir(LEGACY_NOTEBOOKS_DIR)):
|
||||
if not filename.endswith(".json") or filename.startswith("."):
|
||||
continue
|
||||
try:
|
||||
source = resolve_within(LEGACY_NOTEBOOKS_DIR, require_filename(filename))
|
||||
data = _read_notebook(source)
|
||||
except (OSError, ValueError):
|
||||
skipped.append(filename)
|
||||
logging.warning("Anomalous: could not migrate notebook %s; original preserved", filename)
|
||||
continue
|
||||
target = resolve_within(directory, filename)
|
||||
if os.path.exists(target):
|
||||
try:
|
||||
if _read_notebook(target) == data:
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:12]
|
||||
recovered = f"{os.path.splitext(filename)[0][:100]}_legacy_{digest}.json"
|
||||
target = resolve_within(directory, recovered)
|
||||
if os.path.exists(target):
|
||||
if _read_notebook(target) != data:
|
||||
raise ValueError("Conflicting migrated notebook")
|
||||
continue
|
||||
atomic_write_json(target, data, MAX_NOTEBOOK_BYTES)
|
||||
atomic_write_json(marker, {"version": 1, "skipped": skipped})
|
||||
|
||||
|
||||
def get_notebooks_dir():
|
||||
user_dir = folder_paths.get_user_directory()
|
||||
directory = os.path.join(user_dir, "workflows", "anomalous_notebooks")
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
with _notebooks_lock:
|
||||
_migrate_notebooks(directory)
|
||||
return directory
|
||||
|
||||
|
||||
def _list_notebooks():
|
||||
with _notebooks_lock:
|
||||
directory = get_notebooks_dir()
|
||||
notebooks = []
|
||||
for filename in sorted(os.listdir(directory)):
|
||||
if not filename.endswith(".json") or filename.startswith("."):
|
||||
continue
|
||||
try:
|
||||
data = _read_notebook(resolve_within(directory, filename))
|
||||
notebooks.append({"filename": filename, "name": data.get("name", filename[:-5]), "data": data})
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return notebooks
|
||||
|
||||
|
||||
async def api_get_notebooks(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
if not os.path.exists(nb_dir):
|
||||
os.makedirs(nb_dir)
|
||||
|
||||
notebooks = []
|
||||
for f in os.listdir(nb_dir):
|
||||
if f.endswith('.json'):
|
||||
try:
|
||||
with open(os.path.join(nb_dir, f), 'r', encoding='utf-8') as file:
|
||||
data = json.load(file)
|
||||
notebooks.append({
|
||||
"filename": f,
|
||||
"name": data.get("name", f.replace('.json', '')),
|
||||
"data": data
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"notebooks": notebooks})
|
||||
try:
|
||||
notebooks = await asyncio.to_thread(_list_notebooks)
|
||||
return web.json_response({"notebooks": notebooks})
|
||||
except (OSError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Could not load notebooks"}, status=500)
|
||||
|
||||
|
||||
def _save_notebook(filename, data):
|
||||
with _notebooks_lock:
|
||||
atomic_write_json(resolve_within(get_notebooks_dir(), filename), data, MAX_NOTEBOOK_BYTES)
|
||||
|
||||
|
||||
async def api_save_notebook(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
if not filename.endswith('.json'):
|
||||
filename += '.json'
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
if not os.path.exists(nb_dir):
|
||||
os.makedirs(nb_dir)
|
||||
|
||||
file_path = resolve_within(nb_dir, filename)
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data.get("data", {}), f, indent=4, ensure_ascii=False)
|
||||
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if filename.startswith("."):
|
||||
raise ValueError("Invalid notebook filename")
|
||||
if not filename.endswith(".json"):
|
||||
filename += ".json"
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid notebook")
|
||||
await asyncio.to_thread(_save_notebook, filename, data)
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save notebook"}, status=500)
|
||||
|
||||
|
||||
def _delete_notebook(filename):
|
||||
with _notebooks_lock:
|
||||
os.remove(resolve_within(get_notebooks_dir(), filename))
|
||||
|
||||
|
||||
async def api_delete_notebook(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
file_path = resolve_within(nb_dir, filename)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return web.json_response({"status": "success"})
|
||||
return web.json_response({"status": "error", "message": "File not found"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json") or filename.startswith("."):
|
||||
raise ValueError("Invalid notebook filename")
|
||||
await asyncio.to_thread(_delete_notebook, filename)
|
||||
return web.json_response({"status": "success"})
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Notebook not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not delete notebook"}, status=500)
|
||||
|
||||
@@ -8,7 +8,10 @@ import re
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
from .utils import require_filename, resolve_within
|
||||
from .recipes import MAX_RECIPE_BYTES, _parameter_signature, _parameter_gallery_images, get_recipes_dir, _read_recipe
|
||||
from .recipe_constants import MAX_RECIPE_BYTES
|
||||
from .recipe_images import _parameter_gallery_images
|
||||
from .recipe_store import get_recipes_dir, _read_recipe
|
||||
from .workflow_schema import _parameter_signature
|
||||
|
||||
def get_parameters_dir():
|
||||
# Store parameter notebooks in the user directory
|
||||
@@ -300,6 +303,52 @@ async def api_delete_parameter(request):
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
|
||||
async def api_rename_parameter(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = require_filename(data.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
new_name = str(data.get("name", "")).strip()
|
||||
if not new_name:
|
||||
raise ValueError("Name cannot be empty")
|
||||
if len(new_name) > 200:
|
||||
new_name = new_name[:200]
|
||||
except (ValueError, AttributeError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter data"}, status=400)
|
||||
|
||||
try:
|
||||
parameters_dir = get_parameters_dir()
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
if not os.path.exists(file_path):
|
||||
return web.json_response({"status": "error", "message": "Parameter notebook not found"}, status=404)
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
nb_data = json.load(f)
|
||||
|
||||
if not isinstance(nb_data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter file format"}, status=400)
|
||||
|
||||
nb_data["name"] = new_name
|
||||
nb_data["timestamp"] = int(time.time() * 1000)
|
||||
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".parameter-", suffix=".tmp", dir=parameters_dir)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(nb_data, f, ensure_ascii=False, separators=(",", ":"))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(temp_path, file_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
_invalidate_parameter_notebooks_cache()
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not update parameter notebook"}, status=500)
|
||||
|
||||
return web.json_response({"status": "success", "filename": filename, "name": new_name})
|
||||
|
||||
async def api_get_parameter_gallery(request):
|
||||
"""Find recent output PNGs whose embedded parameters match one parameter notebook."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Filesystem containment and atomic JSON helpers."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import folder_paths
|
||||
|
||||
from .recipe_constants import MODEL_FILE_SUFFIXES
|
||||
|
||||
|
||||
def resolve_within(base_dir, *parts):
|
||||
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
|
||||
base_real = os.path.realpath(base_dir)
|
||||
candidate = os.path.realpath(os.path.join(base_real, *parts))
|
||||
try:
|
||||
if os.path.commonpath([base_real, candidate]) != base_real:
|
||||
raise ValueError("Path escapes the configured directory")
|
||||
except ValueError:
|
||||
raise ValueError("Path escapes the configured directory")
|
||||
return candidate
|
||||
|
||||
|
||||
def _model_roots():
|
||||
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
continue
|
||||
for path_index, base_dir in enumerate(paths or []):
|
||||
if os.path.isdir(base_dir):
|
||||
yield folder_type, path_index, os.path.realpath(base_dir)
|
||||
|
||||
|
||||
def _resolve_exact_model_reference(saved_value):
|
||||
"""Resolve one saved model value without recursive scanning or hashing."""
|
||||
if not isinstance(saved_value, str) or not saved_value.strip():
|
||||
return None
|
||||
relative_value = saved_value.replace("/", os.sep)
|
||||
for folder_type, path_index, base_dir in _model_roots():
|
||||
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
|
||||
try:
|
||||
if os.path.commonpath((base_dir, candidate)) != base_dir:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
|
||||
continue
|
||||
return {
|
||||
"path": candidate,
|
||||
"folder_type": folder_type,
|
||||
"path_index": path_index,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_folder_root(folder_type, path_idx=0):
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
|
||||
raise ValueError("Invalid folder path")
|
||||
return os.path.realpath(paths[path_idx])
|
||||
|
||||
|
||||
def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
|
||||
base_dir = get_folder_root(folder_type, path_idx)
|
||||
relative = "" if subfolder in (None, "", "/") else str(subfolder).strip("/\\")
|
||||
return base_dir, resolve_within(base_dir, relative)
|
||||
|
||||
|
||||
def require_filename(filename):
|
||||
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
|
||||
raise ValueError("Invalid filename")
|
||||
return filename
|
||||
|
||||
|
||||
def atomic_write_json(path, value, max_bytes=12 * 1024 * 1024):
|
||||
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > max_bytes:
|
||||
raise ValueError("JSON record is too large")
|
||||
fd, temporary = tempfile.mkstemp(prefix=".write-", suffix=".tmp", dir=os.path.dirname(path))
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as output:
|
||||
output.write(encoded)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.remove(temporary)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Shared bounded constants for Workflow Recipe domains."""
|
||||
|
||||
import re
|
||||
|
||||
MAX_NAME_LENGTH = 120
|
||||
MAX_TAGS = 20
|
||||
MAX_TAG_LENGTH = 60
|
||||
MAX_NOTES_LENGTH = 3000
|
||||
MAX_MODEL_NOTE_LENGTH = 1000
|
||||
MAX_THUMBNAIL_LENGTH = 1_500_000
|
||||
MAX_SOURCE_SUBFOLDER_LENGTH = 500
|
||||
MAX_RECIPE_BYTES = 12 * 1024 * 1024
|
||||
MAX_HISTORY_VERSIONS = 20
|
||||
MAX_PREVIEW_SNAPSHOTS = 12
|
||||
MAX_PREVIEW_SNAPSHOT_BYTES = 96 * 1024
|
||||
MAX_PREVIEW_SNAPSHOT_TOTAL_BYTES = 1_250_000
|
||||
MAX_PREVIEW_SOURCE_BYTES = 20 * 1024 * 1024
|
||||
MAX_WORKFLOW_NODES = 5_000
|
||||
MAX_WORKFLOW_LINKS = 30_000
|
||||
MAX_WORKFLOW_GROUPS = 2_000
|
||||
MAX_WIDGET_VALUES_PER_NODE = 2_048
|
||||
MAX_RECIPE_GALLERY_SCAN = 200
|
||||
MAX_RECIPE_GALLERY_RESULTS = 200
|
||||
MAX_EMBEDDED_WORKFLOW_BYTES = 3 * 1024 * 1024
|
||||
MAX_RECIPE_COVER_SOURCE_BYTES = 64 * 1024 * 1024
|
||||
MAX_RECIPE_COVER_BYTES = 256 * 1024
|
||||
SAFE_THUMBNAIL_PREFIXES = (
|
||||
"data:image/jpeg;base64,",
|
||||
"data:image/png;base64,",
|
||||
"data:image/webp;base64,",
|
||||
)
|
||||
MODEL_FILE_SUFFIXES = (".safetensors", ".ckpt", ".pt", ".bin", ".sft")
|
||||
STATIC_PREVIEW_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif")
|
||||
STATIC_PREVIEW_SUFFIXES = tuple(f".preview{extension}" for extension in STATIC_PREVIEW_EXTENSIONS)
|
||||
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE)
|
||||
VOLATILE_WORKFLOW_KEYS = frozenset({
|
||||
"seed",
|
||||
"noise_seed",
|
||||
"random_seed",
|
||||
"variation_seed",
|
||||
"batch_size",
|
||||
"batch_index",
|
||||
"batch_num",
|
||||
"last_seed",
|
||||
})
|
||||
VERIFIABLE_RECIPE_MODEL_CATEGORIES = frozenset({
|
||||
"checkpoint",
|
||||
"lora",
|
||||
"unet",
|
||||
"controlnet",
|
||||
"vae",
|
||||
"text_encoder",
|
||||
"clip_vision",
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Contained Recipe image assets and bounded output-gallery inspection."""
|
||||
|
||||
from io import BytesIO
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
import os
|
||||
|
||||
import folder_paths
|
||||
from PIL import Image
|
||||
|
||||
from .path_utils import _resolve_exact_model_reference
|
||||
from .recipe_constants import *
|
||||
from .utils import require_filename, resolve_within
|
||||
from .workflow_schema import _node_type, _parameter_signature, _workflow_node_signature
|
||||
|
||||
def _recipe_assets_dir(recipes_dir, filename, create=False):
|
||||
stem = os.path.splitext(require_filename(filename))[0]
|
||||
assets_dir = resolve_within(recipes_dir, ".assets", stem)
|
||||
if create:
|
||||
os.makedirs(assets_dir, exist_ok=True)
|
||||
return assets_dir
|
||||
|
||||
|
||||
def _preview_source_path(saved_value):
|
||||
resolved = _resolve_exact_model_reference(saved_value)
|
||||
if not resolved:
|
||||
return None
|
||||
base_path = os.path.splitext(resolved["path"])[0]
|
||||
for suffix in STATIC_PREVIEW_SUFFIXES + STATIC_PREVIEW_EXTENSIONS:
|
||||
candidate = f"{base_path}{suffix}"
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _thumbnail_webp_bytes(source_path):
|
||||
try:
|
||||
if os.path.getsize(source_path) > MAX_PREVIEW_SOURCE_BYTES:
|
||||
return None
|
||||
with Image.open(source_path) as image:
|
||||
image.thumbnail((320, 320))
|
||||
if image.mode not in ("RGB", "RGBA"):
|
||||
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||
width, height = image.size
|
||||
for quality in (76, 62, 48):
|
||||
output = BytesIO()
|
||||
image.save(output, format="WEBP", quality=quality, method=4)
|
||||
data = output.getvalue()
|
||||
if len(data) <= MAX_PREVIEW_SNAPSHOT_BYTES:
|
||||
return data, width, height
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _recipe_cover_webp_bytes(source_path):
|
||||
"""Create a portable recipe-cover asset without retaining the full output image."""
|
||||
try:
|
||||
if os.path.getsize(source_path) > MAX_RECIPE_COVER_SOURCE_BYTES:
|
||||
return None
|
||||
with Image.open(source_path) as image:
|
||||
image.thumbnail((640, 640))
|
||||
if image.mode not in ("RGB", "RGBA"):
|
||||
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||
width, height = image.size
|
||||
for quality in (84, 72, 60, 48):
|
||||
output = BytesIO()
|
||||
image.save(output, format="WEBP", quality=quality, method=4)
|
||||
data = output.getvalue()
|
||||
if len(data) <= MAX_RECIPE_COVER_BYTES:
|
||||
return data, width, height
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _output_source_path(source_image):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
target_dir = resolve_within(output_dir, source_image.get("subfolder", ""))
|
||||
source_path = resolve_within(target_dir, source_image["filename"])
|
||||
if not os.path.isfile(source_path):
|
||||
raise FileNotFoundError
|
||||
return source_path
|
||||
|
||||
|
||||
def _store_recipe_gallery_cover(recipes_dir, filename, source_image):
|
||||
source_path = _output_source_path(source_image)
|
||||
cover = _recipe_cover_webp_bytes(source_path)
|
||||
if not cover:
|
||||
raise ValueError("Could not create recipe cover")
|
||||
data, width, height = cover
|
||||
asset_id = f"cover-{hashlib.sha256(data).hexdigest()}.webp"
|
||||
asset_path = resolve_within(_recipe_assets_dir(recipes_dir, filename, create=True), asset_id)
|
||||
if not os.path.exists(asset_path):
|
||||
with open(asset_path, "wb") as asset_file:
|
||||
asset_file.write(data)
|
||||
return {
|
||||
"asset_id": asset_id,
|
||||
"media_type": "image/webp",
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
def _decode_embedded_json(value):
|
||||
if isinstance(value, bytes):
|
||||
if len(value) > MAX_EMBEDDED_WORKFLOW_BYTES:
|
||||
return None
|
||||
value = value.decode("utf-8")
|
||||
if not isinstance(value, str) or len(value.encode("utf-8")) > MAX_EMBEDDED_WORKFLOW_BYTES:
|
||||
return None
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _embedded_workflow_payload(image_path):
|
||||
"""Read bounded workflow/prompt metadata from one PNG without loading pixels."""
|
||||
try:
|
||||
with Image.open(image_path) as image:
|
||||
return {
|
||||
"workflow": _decode_embedded_json(image.info.get("workflow")),
|
||||
"prompt": _decode_embedded_json(image.info.get("prompt")),
|
||||
}
|
||||
except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _embedded_workflow_node_signature(image_path):
|
||||
payload = _embedded_workflow_payload(image_path)
|
||||
if not payload:
|
||||
return None
|
||||
workflow = payload.get("workflow") or payload.get("prompt")
|
||||
if not workflow:
|
||||
return None
|
||||
return _workflow_node_signature(workflow)["value"]
|
||||
|
||||
|
||||
def _embedded_parameter_signature(image_path):
|
||||
payload = _embedded_workflow_payload(image_path)
|
||||
if not payload:
|
||||
return None
|
||||
workflow = payload.get("workflow") or payload.get("prompt")
|
||||
if not workflow:
|
||||
return None
|
||||
return _parameter_signature(workflow)["value"]
|
||||
|
||||
|
||||
def _recent_output_pngs(output_dir, limit=MAX_RECIPE_GALLERY_SCAN):
|
||||
"""Return a bounded newest-first PNG list without trusting request paths."""
|
||||
newest = []
|
||||
try:
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for name in files:
|
||||
if not name.lower().endswith(".png"):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
candidate = (stat.st_mtime_ns, path)
|
||||
if len(newest) < limit:
|
||||
heapq.heappush(newest, candidate)
|
||||
elif candidate > newest[0]:
|
||||
heapq.heapreplace(newest, candidate)
|
||||
except OSError:
|
||||
return []
|
||||
return sorted(newest, reverse=True)
|
||||
|
||||
|
||||
def _recipe_gallery_images(node_signature):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.isdir(output_dir):
|
||||
return [], 0
|
||||
matches = []
|
||||
scanned = 0
|
||||
for mtime_ns, image_path in _recent_output_pngs(output_dir):
|
||||
scanned += 1
|
||||
if _embedded_workflow_node_signature(image_path) != node_signature:
|
||||
continue
|
||||
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
|
||||
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
|
||||
matches.append({
|
||||
"filename": os.path.basename(image_path),
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
"mtime": mtime_ns // 1_000_000,
|
||||
})
|
||||
if len(matches) >= MAX_RECIPE_GALLERY_RESULTS:
|
||||
break
|
||||
return matches, scanned
|
||||
|
||||
|
||||
def _parameter_gallery_images(parameter_signature):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.isdir(output_dir):
|
||||
return [], 0
|
||||
matches = []
|
||||
scanned = 0
|
||||
for mtime_ns, image_path in _recent_output_pngs(output_dir):
|
||||
scanned += 1
|
||||
if _embedded_parameter_signature(image_path) != parameter_signature:
|
||||
continue
|
||||
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
|
||||
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
|
||||
matches.append({
|
||||
"filename": os.path.basename(image_path),
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
"mtime": mtime_ns // 1_000_000,
|
||||
})
|
||||
if len(matches) >= MAX_RECIPE_GALLERY_RESULTS:
|
||||
break
|
||||
return matches, scanned
|
||||
|
||||
|
||||
def _bounded_gallery_value(value, depth=0):
|
||||
"""Keep image-embedded parameter details useful without echoing huge metadata."""
|
||||
if depth > 5:
|
||||
return "…"
|
||||
if isinstance(value, str):
|
||||
return value if len(value) <= 2000 else f"{value[:1997]}..."
|
||||
if isinstance(value, (int, float, bool)) or value is None:
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [_bounded_gallery_value(item, depth + 1) for item in value[:64]]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): _bounded_gallery_value(nested, depth + 1)
|
||||
for key, nested in list(value.items())[:96]
|
||||
}
|
||||
return str(value)[:2000]
|
||||
|
||||
|
||||
def _gallery_parameter_records(workflow):
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
nodes = workflow.get("nodes")
|
||||
is_ui_workflow = isinstance(nodes, list)
|
||||
values = nodes if is_ui_workflow else workflow.values()
|
||||
records = []
|
||||
occurrence = {}
|
||||
for node in values:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_type = _node_type(node) or str(node.get("class_type") or "").strip()
|
||||
if not node_type:
|
||||
continue
|
||||
occurrence[node_type] = occurrence.get(node_type, 0) + 1
|
||||
if is_ui_workflow:
|
||||
parameters = {"widgets": _bounded_gallery_value(node.get("widgets_values", []))}
|
||||
node_id = node.get("id")
|
||||
else:
|
||||
parameters = {"inputs": _bounded_gallery_value(node.get("inputs", {}))}
|
||||
node_id = None
|
||||
records.append({
|
||||
"type": node_type,
|
||||
"index": occurrence[node_type],
|
||||
"node_id": node_id,
|
||||
"parameters": parameters,
|
||||
})
|
||||
return records
|
||||
|
||||
|
||||
def _gallery_parameter_diff(recipe_workflow, image_payload):
|
||||
recipe_records = _gallery_parameter_records(recipe_workflow)
|
||||
embedded_workflow = image_payload.get("workflow") or image_payload.get("prompt") if image_payload else None
|
||||
image_records = _gallery_parameter_records(embedded_workflow)
|
||||
recipe_map = {(item["type"], item["index"]): item for item in recipe_records}
|
||||
image_map = {(item["type"], item["index"]): item for item in image_records}
|
||||
changes = []
|
||||
for key in sorted(set(recipe_map) | set(image_map), key=lambda item: (item[0].casefold(), item[1])):
|
||||
recipe_item = recipe_map.get(key)
|
||||
image_item = image_map.get(key)
|
||||
if recipe_item is None or image_item is None or recipe_item["parameters"] != image_item["parameters"]:
|
||||
changes.append({
|
||||
"type": key[0],
|
||||
"index": key[1],
|
||||
"recipe": recipe_item["parameters"] if recipe_item else None,
|
||||
"image": image_item["parameters"] if image_item else None,
|
||||
})
|
||||
return {
|
||||
"recipe_nodes": recipe_records,
|
||||
"image_nodes": image_records,
|
||||
"changes": changes,
|
||||
"comparable": bool(image_records) and all(
|
||||
isinstance(item.get("workflow"), dict) for item in [image_payload or {}]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _attach_preview_snapshots(recipe, recipes_dir, filename, references):
|
||||
if not recipe.get("presentation", {}).get("save_model_preview_snapshots"):
|
||||
return
|
||||
assets_dir = _recipe_assets_dir(recipes_dir, filename, create=True)
|
||||
total_bytes = 0
|
||||
for reference in references[:MAX_PREVIEW_SNAPSHOTS]:
|
||||
source_path = _preview_source_path(reference.get("saved_value"))
|
||||
if not source_path:
|
||||
continue
|
||||
thumbnail = _thumbnail_webp_bytes(source_path)
|
||||
if not thumbnail:
|
||||
continue
|
||||
data, width, height = thumbnail
|
||||
if total_bytes + len(data) > MAX_PREVIEW_SNAPSHOT_TOTAL_BYTES:
|
||||
break
|
||||
asset_id = f"{hashlib.sha256(data).hexdigest()}.webp"
|
||||
asset_path = resolve_within(assets_dir, asset_id)
|
||||
if not os.path.exists(asset_path):
|
||||
with open(asset_path, "wb") as asset_file:
|
||||
asset_file.write(data)
|
||||
total_bytes += len(data)
|
||||
reference["preview"] = {
|
||||
"snapshot_asset_id": asset_id,
|
||||
"media_type": "image/webp",
|
||||
"width": width,
|
||||
"height": height,
|
||||
"captured_at": recipe["timestamp"],
|
||||
}
|
||||
@@ -14,11 +14,15 @@ import zipfile
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import recipes as recipe_store
|
||||
from .recipes import get_recipes_dir
|
||||
from . import recipe_images, recipe_schema, recipe_store
|
||||
from .recipe_store import get_recipes_dir
|
||||
from .utils import require_filename, resolve_within
|
||||
|
||||
|
||||
# Release gates: package transfers stay closed until validation is complete.
|
||||
# Keep the format implementation for compatibility testing.
|
||||
RECIPE_PACKAGE_EXPORT_ENABLED = False
|
||||
RECIPE_PACKAGE_IMPORT_ENABLED = False
|
||||
PACKAGE_VERSION = 1
|
||||
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
|
||||
MAX_ENTRY_COUNT = 256
|
||||
@@ -155,12 +159,14 @@ def _validate_manifest(manifest, archive, names):
|
||||
raise ValueError("Package checksum mismatch")
|
||||
|
||||
|
||||
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True):
|
||||
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True, include_model_notes=True):
|
||||
value = json.loads(json.dumps(recipe, ensure_ascii=False))
|
||||
# A source_image points into the exporting machine's output directory and
|
||||
# is not portable package data. The bounded thumbnail remains the cover.
|
||||
value["source_image"] = None
|
||||
for reference in value.get("params", {}).get("model_references", []):
|
||||
if not include_model_notes:
|
||||
reference.pop("user_note", None)
|
||||
if not include_snapshots:
|
||||
reference.pop("preview", None)
|
||||
if not include_identity:
|
||||
@@ -209,7 +215,8 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
|
||||
include_snapshots = options.get("include_snapshots") is True
|
||||
include_history = options.get("include_history") is True
|
||||
include_identity = options.get("include_identity", True) is True
|
||||
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity)
|
||||
include_model_notes = options.get("include_model_notes", True) is True
|
||||
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity, include_model_notes)
|
||||
history_recipes = []
|
||||
if include_history:
|
||||
for history_name in _recipe_history_files(recipes_dir, filename):
|
||||
@@ -219,7 +226,12 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
|
||||
if len(data) > MAX_ENTRY_BYTES:
|
||||
raise ValueError("Historical recipe is too large")
|
||||
history_recipe = _parse_json(data, "historical recipe")
|
||||
history_recipes.append((history_name, _sanitize_recipe_for_export(history_recipe, include_snapshots, include_identity)))
|
||||
history_recipes.append((history_name, _sanitize_recipe_for_export(
|
||||
history_recipe,
|
||||
include_snapshots,
|
||||
include_identity,
|
||||
include_model_notes,
|
||||
)))
|
||||
asset_ids = _referenced_asset_ids(recipe, include_snapshots)
|
||||
for _, history_recipe in history_recipes:
|
||||
asset_ids.update(_referenced_asset_ids(history_recipe, include_snapshots))
|
||||
@@ -230,7 +242,7 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
|
||||
_add_zip_entry(archive, entries, "recipe.json", recipe_bytes, "application/json")
|
||||
|
||||
if asset_ids:
|
||||
assets_dir = recipe_store._recipe_assets_dir(recipes_dir, filename)
|
||||
assets_dir = recipe_images._recipe_assets_dir(recipes_dir, filename)
|
||||
for asset_id in sorted(asset_ids):
|
||||
asset_path = resolve_within(assets_dir, asset_id)
|
||||
if not os.path.isfile(asset_path):
|
||||
@@ -254,6 +266,7 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
|
||||
"include_snapshots": include_snapshots,
|
||||
"include_history": include_history,
|
||||
"include_identity": include_identity,
|
||||
"include_model_notes": include_model_notes,
|
||||
},
|
||||
"entries": entries,
|
||||
}
|
||||
@@ -284,14 +297,14 @@ def _inspect_package(raw):
|
||||
manifest = _parse_json(_read_zip_entry(archive, "manifest.json"), "manifest")
|
||||
_validate_manifest(manifest, archive, names)
|
||||
recipe = _parse_json(_read_zip_entry(archive, "recipe.json"), "recipe")
|
||||
recipe_store._normalise_recipe(recipe)
|
||||
recipe_schema._normalise_recipe(recipe)
|
||||
history_names = [name for name in names if name.startswith("history/")]
|
||||
if len(history_names) > MAX_HISTORY_ENTRIES:
|
||||
raise ValueError("Too many history entries")
|
||||
history_recipes = []
|
||||
for name in history_names:
|
||||
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
|
||||
recipe_store._normalise_recipe(history)
|
||||
recipe_schema._normalise_recipe(history)
|
||||
history_recipes.append(history)
|
||||
_validate_recipe_assets(archive, [recipe, *history_recipes], names)
|
||||
return {
|
||||
@@ -381,14 +394,14 @@ def _commit_import(record, payload):
|
||||
else:
|
||||
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names)
|
||||
|
||||
normalized = recipe_store._normalise_recipe(package_recipe)
|
||||
normalized = recipe_schema._normalise_recipe(package_recipe)
|
||||
normalized["presentation"]["imported"] = True
|
||||
filename = target_filename or f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
|
||||
normalized = recipe_store._enrich_recipe(normalized)
|
||||
normalized = recipe_schema._enrich_recipe(normalized)
|
||||
|
||||
staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir)
|
||||
final_path = resolve_within(recipes_dir, filename)
|
||||
final_assets = recipe_store._recipe_assets_dir(recipes_dir, filename)
|
||||
final_assets = recipe_images._recipe_assets_dir(recipes_dir, filename)
|
||||
final_history = recipe_store._history_dir(recipes_dir, filename)
|
||||
backup_recipe = None
|
||||
backup_assets = None
|
||||
@@ -448,6 +461,12 @@ def _commit_import(record, payload):
|
||||
|
||||
|
||||
async def api_export_recipe_package(request):
|
||||
if not RECIPE_PACKAGE_EXPORT_ENABLED:
|
||||
return web.json_response({
|
||||
"status": "error",
|
||||
"code": "recipe_export_disabled",
|
||||
"message": "Recipe package export is temporarily unavailable",
|
||||
}, status=503)
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
@@ -470,6 +489,11 @@ async def api_export_recipe_package(request):
|
||||
|
||||
|
||||
async def api_import_recipe_package_inspect(request):
|
||||
if not RECIPE_PACKAGE_IMPORT_ENABLED:
|
||||
return web.json_response({
|
||||
"status": "error", "code": "recipe_import_disabled",
|
||||
"message": "Recipe package import is temporarily unavailable",
|
||||
}, status=503)
|
||||
try:
|
||||
raw = await request.content.read(MAX_UPLOAD_BYTES + 1)
|
||||
report = await asyncio.to_thread(_inspect_package, raw)
|
||||
@@ -489,6 +513,11 @@ async def api_import_recipe_package_inspect(request):
|
||||
|
||||
|
||||
async def api_import_recipe_package_commit(request):
|
||||
if not RECIPE_PACKAGE_IMPORT_ENABLED:
|
||||
return web.json_response({
|
||||
"status": "error", "code": "recipe_import_disabled",
|
||||
"message": "Recipe package import is temporarily unavailable",
|
||||
}, status=503)
|
||||
try:
|
||||
payload = await request.json()
|
||||
token = payload.get("token")
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Workflow Recipe record normalization, model references, and enrichment."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
import folder_paths
|
||||
|
||||
from .metadata import get_metadata
|
||||
from .recipe_constants import *
|
||||
from .path_utils import _model_roots, _resolve_exact_model_reference
|
||||
from .recipe_images import _attach_preview_snapshots
|
||||
from .utils import require_filename, resolve_within
|
||||
from .workflow_schema import (
|
||||
_node_title, _node_type, _parameter_signature, _validate_workflow,
|
||||
_widget_values, _workflow_fingerprint,
|
||||
)
|
||||
|
||||
def _model_reference_specs(node):
|
||||
"""Known loader adapters only; arbitrary third-party widgets stay parameters."""
|
||||
node_type = _node_type(node)
|
||||
lowered = node_type.lower()
|
||||
specs = []
|
||||
|
||||
if re.search(r"checkpointloader(simple)?$", lowered):
|
||||
specs.append((0, "checkpoint", "checkpoint"))
|
||||
elif lowered.endswith("unetloader"):
|
||||
specs.append((0, "unet", "unet"))
|
||||
elif re.search(r"loraloader", lowered):
|
||||
specs.append((0, "lora", "lora"))
|
||||
elif lowered.endswith("vaeloader"):
|
||||
specs.append((0, "vae", "vae"))
|
||||
elif lowered.endswith("clipvisionloader"):
|
||||
specs.append((0, "clip_vision", "clip_vision"))
|
||||
elif lowered.endswith("controlnetloader"):
|
||||
specs.append((0, "controlnet", "controlnet"))
|
||||
elif re.search(r"(?:^|[^a-z])(?:dual|triple)?cliploader$", lowered):
|
||||
specs.append((0, "text_encoder", "clip"))
|
||||
if "dualclip" in lowered or "tripleclip" in lowered:
|
||||
specs.append((1, "text_encoder", "clip"))
|
||||
if "tripleclip" in lowered:
|
||||
specs.append((2, "text_encoder", "clip"))
|
||||
return specs
|
||||
|
||||
|
||||
def _identity_for_reference(saved_value):
|
||||
resolved = _resolve_exact_model_reference(saved_value)
|
||||
if not resolved:
|
||||
return {"status": "unavailable"}, None
|
||||
|
||||
identity = {"status": "unverified", "provenance": "local cached metadata"}
|
||||
origin = None
|
||||
try:
|
||||
identity["size"] = os.path.getsize(resolved["path"])
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
metadata = get_metadata(resolved["path"])
|
||||
candidate_hash = str(metadata.get("hash") or "").strip()
|
||||
if SHA256_PATTERN.fullmatch(candidate_hash):
|
||||
identity["status"] = "verified"
|
||||
identity["sha256"] = candidate_hash.lower()
|
||||
identity["provenance"] = metadata.get("hash_source") or "sidecar file SHA-256"
|
||||
|
||||
civitai_url = metadata.get("civitai_url")
|
||||
if civitai_url:
|
||||
origin = {
|
||||
"provider": "civitai",
|
||||
"model_name": metadata.get("name") or "",
|
||||
"model_url": civitai_url,
|
||||
"model_id": metadata.get("model_id") or "",
|
||||
"version_id": metadata.get("version_id") or ""
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return identity, origin
|
||||
|
||||
|
||||
def _computed_identity_for_reference(saved_value):
|
||||
"""Compute an exact SHA-256 only after an explicit save-time opt-in."""
|
||||
resolved = _resolve_exact_model_reference(saved_value)
|
||||
if not resolved:
|
||||
return {"status": "unavailable"}
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with open(resolved["path"], "rb") as model_file:
|
||||
for block in iter(lambda: model_file.read(4 * 1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return {
|
||||
"status": "verified",
|
||||
"sha256": digest.hexdigest(),
|
||||
"size": os.path.getsize(resolved["path"]),
|
||||
"provenance": "computed during recipe save",
|
||||
}
|
||||
except OSError:
|
||||
return {"status": "unverified"}
|
||||
|
||||
|
||||
def _workflow_identity_for_reference(workflow, node_id, saved_value):
|
||||
extra = workflow.get("extra") if isinstance(workflow, dict) else None
|
||||
hashes = extra.get("anomalous_hashes") if isinstance(extra, dict) else None
|
||||
if not isinstance(hashes, dict) or not isinstance(saved_value, str):
|
||||
return None
|
||||
normalized = saved_value.replace("\\", "/")
|
||||
windows_path = saved_value.replace("/", "\\")
|
||||
keys = (
|
||||
f"{node_id}_{saved_value}",
|
||||
f"{node_id}_{normalized}",
|
||||
f"{node_id}_{windows_path}",
|
||||
saved_value,
|
||||
normalized,
|
||||
windows_path,
|
||||
)
|
||||
record = next((hashes[key] for key in keys if key in hashes), None)
|
||||
candidate_hash = record if isinstance(record, str) else record.get("hash") if isinstance(record, dict) else None
|
||||
if not isinstance(candidate_hash, str) or not SHA256_PATTERN.fullmatch(candidate_hash.strip()):
|
||||
return None
|
||||
identity = {
|
||||
"status": "verified",
|
||||
"sha256": candidate_hash.strip().lower(),
|
||||
"provenance": "workflow snapshot",
|
||||
}
|
||||
if isinstance(record, dict):
|
||||
try:
|
||||
identity["size"] = int(record.get("size"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return identity
|
||||
|
||||
|
||||
def _build_model_references(recipe, verify_identities=False):
|
||||
workflow = recipe.get("workflow") if isinstance(recipe, dict) else None
|
||||
params = recipe.get("params") if isinstance(recipe, dict) else None
|
||||
base_model = params.get("baseModel") if isinstance(params, dict) else None
|
||||
references = []
|
||||
for node in workflow.get("nodes", []) if isinstance(workflow, dict) else []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
values = _widget_values(node)
|
||||
for widget_index, category, widget_name in _model_reference_specs(node):
|
||||
saved_value = values[widget_index] if widget_index < len(values) else None
|
||||
if not isinstance(saved_value, str) or not saved_value.strip():
|
||||
continue
|
||||
identity_result = _identity_for_reference(saved_value)
|
||||
if isinstance(identity_result, tuple):
|
||||
identity, origin = (identity_result + (None,))[:2]
|
||||
else:
|
||||
# Keep compatibility with older callers/tests that provide the
|
||||
# pre-origin helper contract and return identity only.
|
||||
identity, origin = identity_result, None
|
||||
workflow_identity = _workflow_identity_for_reference(workflow, node.get("id"), saved_value)
|
||||
if workflow_identity:
|
||||
identity = workflow_identity
|
||||
elif (
|
||||
verify_identities
|
||||
and category in VERIFIABLE_RECIPE_MODEL_CATEGORIES
|
||||
and identity.get("status") != "verified"
|
||||
):
|
||||
identity = _computed_identity_for_reference(saved_value)
|
||||
ref_dict = {
|
||||
"node_id": node.get("id"),
|
||||
"node_type": _node_type(node) or "Unknown",
|
||||
"node_title": _node_title(node),
|
||||
"widget_index": widget_index,
|
||||
"widget_name": widget_name,
|
||||
"saved_value": saved_value,
|
||||
"category": category,
|
||||
"base_model": base_model,
|
||||
"identity": identity,
|
||||
}
|
||||
if origin:
|
||||
ref_dict["origin"] = origin
|
||||
references.append(ref_dict)
|
||||
return references
|
||||
|
||||
|
||||
def _model_reference_key(reference):
|
||||
if not isinstance(reference, dict):
|
||||
return None
|
||||
return (
|
||||
reference.get("node_id"),
|
||||
reference.get("widget_index"),
|
||||
reference.get("category"),
|
||||
reference.get("saved_value"),
|
||||
)
|
||||
|
||||
|
||||
def _preserve_model_reference_fields(previous_references, references, preserve_identity=True):
|
||||
"""Carry package/history presentation and identity without trusting names."""
|
||||
previous = {
|
||||
_model_reference_key(reference): reference
|
||||
for reference in previous_references or []
|
||||
if _model_reference_key(reference) is not None
|
||||
and isinstance(reference, dict)
|
||||
}
|
||||
for reference in references:
|
||||
prior = previous.get(_model_reference_key(reference))
|
||||
if prior is None:
|
||||
continue
|
||||
for field in ("identity", "origin", "preview"):
|
||||
if field == "preview" or (field in ("identity", "origin") and preserve_identity):
|
||||
if isinstance(prior.get(field), dict):
|
||||
reference[field] = json.loads(json.dumps(prior[field], ensure_ascii=False))
|
||||
user_note = prior.get("user_note")
|
||||
if isinstance(user_note, str) and user_note.strip():
|
||||
reference["user_note"] = user_note.strip()
|
||||
|
||||
|
||||
def _enrich_recipe(
|
||||
recipe,
|
||||
recipes_dir=None,
|
||||
filename=None,
|
||||
recapture_previews=True,
|
||||
refresh_identities=False,
|
||||
verify_identities=False,
|
||||
):
|
||||
recipe["workflow_fingerprint"] = _workflow_fingerprint(recipe["workflow"])
|
||||
# Keep the exact parameter-match identity alongside the structural
|
||||
# fingerprint. The detail panel can use this value for result discovery
|
||||
# without asking the browser to recreate or upload a legacy notebook.
|
||||
recipe["parameter_signature"] = _parameter_signature(recipe["workflow"])
|
||||
params = dict(recipe.get("params") or {})
|
||||
previous_references = params.get("model_references", [])
|
||||
references = _build_model_references(recipe, verify_identities=verify_identities)
|
||||
_preserve_model_reference_fields(
|
||||
previous_references,
|
||||
references,
|
||||
preserve_identity=not (refresh_identities or verify_identities),
|
||||
)
|
||||
if recipes_dir and filename and recapture_previews:
|
||||
_attach_preview_snapshots(recipe, recipes_dir, filename, references)
|
||||
params["model_references"] = references
|
||||
recipe["params"] = params
|
||||
if recipe.get("workflow_scope") not in {"partial", "complete"}:
|
||||
recipe["workflow_scope"] = "complete"
|
||||
recipe["schema_version"] = 7
|
||||
encoded = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Recipe is too large")
|
||||
return recipe
|
||||
|
||||
|
||||
def _normalise_source_image(value):
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict) or value.get("type") != "output":
|
||||
raise ValueError("Invalid source image")
|
||||
filename = require_filename(value.get("filename", ""))
|
||||
subfolder = value.get("subfolder", "")
|
||||
if not isinstance(subfolder, str) or len(subfolder) > MAX_SOURCE_SUBFOLDER_LENGTH:
|
||||
raise ValueError("Invalid source image")
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
resolve_within(output_dir, subfolder)
|
||||
return {
|
||||
"filename": filename,
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
}
|
||||
|
||||
|
||||
def _normalise_recipe(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
|
||||
name = payload.get("name", "")
|
||||
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_NAME_LENGTH:
|
||||
raise ValueError("Invalid recipe name")
|
||||
|
||||
raw_tags = payload.get("tags", [])
|
||||
if not isinstance(raw_tags, list):
|
||||
raise ValueError("Invalid recipe tags")
|
||||
tags = []
|
||||
for tag in raw_tags:
|
||||
if not isinstance(tag, str):
|
||||
raise ValueError("Invalid recipe tag")
|
||||
tag = tag.strip()
|
||||
if not tag or len(tag) > MAX_TAG_LENGTH:
|
||||
continue
|
||||
if tag not in tags:
|
||||
tags.append(tag)
|
||||
if len(tags) >= MAX_TAGS:
|
||||
break
|
||||
|
||||
notes = payload.get("notes", "")
|
||||
if not isinstance(notes, str) or len(notes) > MAX_NOTES_LENGTH:
|
||||
raise ValueError("Invalid recipe notes")
|
||||
|
||||
params = payload.get("params", {})
|
||||
workflow = payload.get("workflow")
|
||||
if not isinstance(params, dict) or not isinstance(workflow, dict):
|
||||
raise ValueError("Invalid recipe workflow")
|
||||
model_references = params.get("model_references")
|
||||
if model_references is not None:
|
||||
if not isinstance(model_references, list):
|
||||
raise ValueError("Invalid recipe model references")
|
||||
for reference in model_references:
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError("Invalid recipe model reference")
|
||||
user_note = reference.get("user_note")
|
||||
if user_note is None:
|
||||
continue
|
||||
if not isinstance(user_note, str) or len(user_note) > MAX_MODEL_NOTE_LENGTH:
|
||||
raise ValueError("Invalid recipe model note")
|
||||
if user_note.strip():
|
||||
reference["user_note"] = user_note.strip()
|
||||
else:
|
||||
reference.pop("user_note", None)
|
||||
_validate_workflow(workflow)
|
||||
workflow_scope = payload.get("workflow_scope", "complete")
|
||||
if workflow_scope not in {"partial", "complete"}:
|
||||
raise ValueError("Invalid recipe workflow scope")
|
||||
|
||||
thumbnail = payload.get("thumbnail")
|
||||
if thumbnail is not None:
|
||||
if (
|
||||
not isinstance(thumbnail, str)
|
||||
or len(thumbnail) > MAX_THUMBNAIL_LENGTH
|
||||
or not thumbnail.lower().startswith(SAFE_THUMBNAIL_PREFIXES)
|
||||
):
|
||||
thumbnail = None
|
||||
|
||||
presentation = payload.get("presentation", {})
|
||||
if presentation is None:
|
||||
presentation = {}
|
||||
if not isinstance(presentation, dict):
|
||||
raise ValueError("Invalid recipe presentation")
|
||||
save_model_preview_snapshots = presentation.get("save_model_preview_snapshots", True)
|
||||
if not isinstance(save_model_preview_snapshots, bool):
|
||||
raise ValueError("Invalid recipe presentation")
|
||||
cover_asset_id = presentation.get("cover_asset_id")
|
||||
if cover_asset_id is not None:
|
||||
cover_asset_id = require_filename(cover_asset_id)
|
||||
if not cover_asset_id.startswith("cover-") or not cover_asset_id.endswith(".webp"):
|
||||
raise ValueError("Invalid recipe presentation")
|
||||
|
||||
recipe = {
|
||||
"schema_version": 1,
|
||||
"name": name,
|
||||
"tags": tags,
|
||||
"notes": notes.strip(),
|
||||
"params": params,
|
||||
"workflow": workflow,
|
||||
"workflow_scope": workflow_scope,
|
||||
"thumbnail": thumbnail,
|
||||
"source_image": _normalise_source_image(payload.get("source_image")),
|
||||
"presentation": {
|
||||
"save_model_preview_snapshots": save_model_preview_snapshots,
|
||||
**({"cover_asset_id": cover_asset_id} if cover_asset_id else {}),
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}
|
||||
encoded = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Recipe is too large")
|
||||
return recipe
|
||||
|
||||
|
||||
def _updated_recipe(payload, existing):
|
||||
recipe = _normalise_recipe(payload)
|
||||
existing_presentation = existing.get("presentation") if isinstance(existing.get("presentation"), dict) else {}
|
||||
existing_cover = existing_presentation.get("cover_asset_id")
|
||||
source_unchanged = recipe.get("source_image") == existing.get("source_image")
|
||||
if existing_cover and source_unchanged and not recipe["presentation"].get("cover_asset_id"):
|
||||
recipe["presentation"]["cover_asset_id"] = existing_cover
|
||||
created_timestamp = existing.get("created_timestamp", existing.get("timestamp"))
|
||||
if isinstance(created_timestamp, int):
|
||||
recipe["created_timestamp"] = created_timestamp
|
||||
recipe["updated_timestamp"] = recipe["timestamp"]
|
||||
return recipe
|
||||
@@ -0,0 +1,141 @@
|
||||
"""User-owned Workflow Recipe records and bounded history persistence."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import folder_paths
|
||||
|
||||
from .recipe_constants import MAX_HISTORY_VERSIONS
|
||||
from .recipe_images import _recipe_assets_dir
|
||||
from .utils import require_filename, resolve_within
|
||||
|
||||
def get_recipes_dir():
|
||||
"""Return the user-owned recipe directory, creating it if needed."""
|
||||
user_dir = (
|
||||
folder_paths.get_user_directory()
|
||||
if hasattr(folder_paths, "get_user_directory")
|
||||
else os.path.join(folder_paths.base_path, "user", "default")
|
||||
)
|
||||
recipes_dir = os.path.join(user_dir, "workflows", "anomalous_recipes")
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
return os.path.realpath(recipes_dir)
|
||||
|
||||
|
||||
def _read_recipe(path):
|
||||
with open(path, "r", encoding="utf-8") as recipe_file:
|
||||
return json.load(recipe_file)
|
||||
|
||||
|
||||
def _list_recipes(recipes_dir):
|
||||
recipes = []
|
||||
try:
|
||||
with os.scandir(recipes_dir) as entries:
|
||||
for entry in entries:
|
||||
if not entry.is_file() or not entry.name.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
filename = require_filename(entry.name)
|
||||
data = _read_recipe(resolve_within(recipes_dir, filename))
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
# The graph can be much larger than all card data combined.
|
||||
summary = {key: value for key, value in data.items() if key != "workflow"}
|
||||
recipes.append({"filename": filename, "data": summary})
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
except OSError:
|
||||
return []
|
||||
recipes.sort(key=lambda item: item["data"].get("timestamp", 0), reverse=True)
|
||||
return recipes
|
||||
|
||||
|
||||
def _write_recipe(recipes_dir, filename, recipe):
|
||||
path = resolve_within(recipes_dir, filename)
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".recipe-", suffix=".tmp", dir=recipes_dir)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as recipe_file:
|
||||
json.dump(recipe, recipe_file, ensure_ascii=False, separators=(",", ":"))
|
||||
recipe_file.flush()
|
||||
os.fsync(recipe_file.fileno())
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _history_dir(recipes_dir, filename, create=False):
|
||||
"""Return the contained, user-data-only history directory for one recipe."""
|
||||
stem = os.path.splitext(require_filename(filename))[0]
|
||||
history_dir = resolve_within(recipes_dir, ".history", stem)
|
||||
if create:
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
return history_dir
|
||||
|
||||
|
||||
def _archive_recipe(recipes_dir, filename, recipe):
|
||||
"""Atomically retain a bounded pre-update snapshot before replacing a recipe."""
|
||||
history_dir = _history_dir(recipes_dir, filename, create=True)
|
||||
version_name = f"version_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.json"
|
||||
_write_recipe(history_dir, version_name, recipe)
|
||||
|
||||
entries = []
|
||||
with os.scandir(history_dir) as scan:
|
||||
for entry in scan:
|
||||
if entry.is_file() and entry.name.endswith(".json"):
|
||||
entries.append(entry)
|
||||
entries.sort(key=lambda entry: entry.stat().st_mtime_ns, reverse=True)
|
||||
for entry in entries[MAX_HISTORY_VERSIONS:]:
|
||||
try:
|
||||
os.remove(resolve_within(history_dir, entry.name))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _list_recipe_history(recipes_dir, filename):
|
||||
history_dir = _history_dir(recipes_dir, filename)
|
||||
versions = []
|
||||
try:
|
||||
with os.scandir(history_dir) as entries:
|
||||
for entry in entries:
|
||||
if not entry.is_file() or not entry.name.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
version = require_filename(entry.name)
|
||||
data = _read_recipe(resolve_within(history_dir, version))
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
versions.append({
|
||||
"version": version,
|
||||
"timestamp": data.get("timestamp", 0),
|
||||
"name": data.get("name", ""),
|
||||
"workflow_fingerprint": data.get("workflow_fingerprint"),
|
||||
"model_reference_count": len(
|
||||
(data.get("params") or {}).get("model_references", [])
|
||||
if isinstance(data.get("params"), dict)
|
||||
else []
|
||||
),
|
||||
})
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
except OSError:
|
||||
return []
|
||||
versions.sort(key=lambda item: item["timestamp"], reverse=True)
|
||||
return versions
|
||||
|
||||
|
||||
def _delete_recipe_with_history(recipes_dir, filename):
|
||||
"""Delete an explicitly selected recipe and its contained local history."""
|
||||
os.remove(resolve_within(recipes_dir, filename))
|
||||
history_dir = _history_dir(recipes_dir, filename)
|
||||
if os.path.isdir(history_dir):
|
||||
shutil.rmtree(history_dir)
|
||||
assets_dir = _recipe_assets_dir(recipes_dir, filename)
|
||||
if os.path.isdir(assets_dir):
|
||||
shutil.rmtree(assets_dir)
|
||||
@@ -1,14 +1,216 @@
|
||||
from .metadata import get_metadata
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
from .utils import get_active_folder_types, get_active_scan_paths, resolve_folder_subdir
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import time
|
||||
import uuid
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
from .folder_types import get_active_folder_types, get_active_scan_paths
|
||||
from .path_utils import resolve_folder_subdir
|
||||
try:
|
||||
from ..model_policies import is_physical_rename_protected
|
||||
except ImportError:
|
||||
from model_policies import is_physical_rename_protected
|
||||
|
||||
|
||||
SCAN_SESSION_ID = uuid.uuid4().hex
|
||||
SCAN_MARKER_LOCK = threading.Lock()
|
||||
ACTIVE_SCAN_MARKERS = set()
|
||||
SCAN_RUNTIME_STATE = {}
|
||||
|
||||
|
||||
def _read_json_file(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
value = json.load(f)
|
||||
return value if isinstance(value, dict) else None
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _remove_file(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _pid_is_running(pid):
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if pid <= 0:
|
||||
return False
|
||||
if pid == os.getpid():
|
||||
return True
|
||||
if os.name == 'nt':
|
||||
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
open_process = kernel32.OpenProcess
|
||||
open_process.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
||||
open_process.restype = wintypes.HANDLE
|
||||
get_exit_code = kernel32.GetExitCodeProcess
|
||||
get_exit_code.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
||||
get_exit_code.restype = wintypes.BOOL
|
||||
close_handle = kernel32.CloseHandle
|
||||
close_handle.argtypes = (wintypes.HANDLE,)
|
||||
close_handle.restype = wintypes.BOOL
|
||||
process = open_process(0x1000, False, pid)
|
||||
if not process:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
return bool(get_exit_code(process, ctypes.byref(exit_code))) and exit_code.value == 259
|
||||
finally:
|
||||
close_handle(process)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _write_marker(marker_file, marker):
|
||||
temp_file = f"{marker_file}.{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(marker, f)
|
||||
os.replace(temp_file, marker_file)
|
||||
|
||||
|
||||
def _marker_owner_is_running(marker):
|
||||
if not marker:
|
||||
return False
|
||||
if marker.get("session_id") == SCAN_SESSION_ID:
|
||||
return marker.get("marker_file") in ACTIVE_SCAN_MARKERS
|
||||
worker_pid = marker.get("worker_pid")
|
||||
if worker_pid:
|
||||
return _pid_is_running(worker_pid)
|
||||
return _pid_is_running(marker.get("owner_pid"))
|
||||
|
||||
|
||||
def _cleanup_scan_artifacts(marker_file, artifact_files=()):
|
||||
_remove_file(marker_file)
|
||||
for path in artifact_files:
|
||||
_remove_file(path)
|
||||
|
||||
|
||||
def _claim_scan_marker(marker_file, kind, artifact_files=()):
|
||||
recovered = False
|
||||
with SCAN_MARKER_LOCK:
|
||||
if marker_file in ACTIVE_SCAN_MARKERS:
|
||||
return False, False
|
||||
if os.path.exists(marker_file):
|
||||
marker = _read_json_file(marker_file)
|
||||
if _marker_owner_is_running(marker):
|
||||
return False, False
|
||||
_cleanup_scan_artifacts(marker_file, artifact_files)
|
||||
recovered = True
|
||||
|
||||
marker = {
|
||||
"version": 2,
|
||||
"job_id": uuid.uuid4().hex,
|
||||
"kind": kind,
|
||||
"session_id": SCAN_SESSION_ID,
|
||||
"owner_pid": os.getpid(),
|
||||
"worker_pid": 0,
|
||||
"started_at": time.time(),
|
||||
"marker_file": marker_file,
|
||||
}
|
||||
try:
|
||||
with open(marker_file, 'x', encoding='utf-8') as f:
|
||||
json.dump(marker, f)
|
||||
except FileExistsError:
|
||||
return False, recovered
|
||||
ACTIVE_SCAN_MARKERS.add(marker_file)
|
||||
SCAN_RUNTIME_STATE[marker_file] = {
|
||||
"phase": "preparing",
|
||||
"error": "",
|
||||
"recovered": recovered,
|
||||
}
|
||||
return True, recovered
|
||||
|
||||
|
||||
def _update_scan_marker(marker_file, **values):
|
||||
with SCAN_MARKER_LOCK:
|
||||
if marker_file not in ACTIVE_SCAN_MARKERS:
|
||||
return
|
||||
marker = _read_json_file(marker_file) or {}
|
||||
marker.update(values)
|
||||
marker["marker_file"] = marker_file
|
||||
_write_marker(marker_file, marker)
|
||||
|
||||
|
||||
def _update_scan_state(marker_file, **values):
|
||||
with SCAN_MARKER_LOCK:
|
||||
if marker_file in ACTIVE_SCAN_MARKERS:
|
||||
SCAN_RUNTIME_STATE.setdefault(marker_file, {}).update(values)
|
||||
|
||||
|
||||
def _release_scan_marker(marker_file, artifact_files=()):
|
||||
with SCAN_MARKER_LOCK:
|
||||
ACTIVE_SCAN_MARKERS.discard(marker_file)
|
||||
SCAN_RUNTIME_STATE.pop(marker_file, None)
|
||||
_cleanup_scan_artifacts(marker_file, artifact_files)
|
||||
|
||||
|
||||
def _scan_marker_status(marker_file, artifact_files=()):
|
||||
with SCAN_MARKER_LOCK:
|
||||
if marker_file in ACTIVE_SCAN_MARKERS:
|
||||
return True, False, _read_json_file(marker_file) or {}
|
||||
if not os.path.exists(marker_file):
|
||||
return False, False, {}
|
||||
marker = _read_json_file(marker_file)
|
||||
if _marker_owner_is_running(marker):
|
||||
return True, False, marker or {}
|
||||
_cleanup_scan_artifacts(marker_file, artifact_files)
|
||||
return False, True, marker or {}
|
||||
|
||||
|
||||
def _scan_status_payload(marker_file, progress_file, artifact_files=()):
|
||||
scanning, interrupted, marker = _scan_marker_status(marker_file, artifact_files)
|
||||
data = {
|
||||
"scanning": scanning,
|
||||
"interrupted": interrupted,
|
||||
"job_id": marker.get("job_id", ""),
|
||||
"phase": "idle",
|
||||
"total": 0,
|
||||
"current": 0,
|
||||
"filename": "",
|
||||
}
|
||||
if scanning:
|
||||
with SCAN_MARKER_LOCK:
|
||||
data.update(SCAN_RUNTIME_STATE.get(marker_file, {}))
|
||||
progress = _read_json_file(progress_file)
|
||||
if progress:
|
||||
data.update(progress)
|
||||
return data
|
||||
|
||||
|
||||
def _protected_type_for_path(folder_path):
|
||||
"""Resolve protected registered aliases for a global physical-folder scan."""
|
||||
target = os.path.realpath(folder_path)
|
||||
for folder_type in folder_paths.folder_names_and_paths.keys():
|
||||
if not is_physical_rename_protected(folder_type=folder_type):
|
||||
continue
|
||||
try:
|
||||
if any(os.path.realpath(path) == target for path in folder_paths.get_folder_paths(folder_type)):
|
||||
return folder_type
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
async def api_scan_status(request):
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
@@ -28,12 +230,13 @@ async def api_scan_status(request):
|
||||
except ValueError:
|
||||
return web.json_response({"scanning": False})
|
||||
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
result_file = os.path.join(target_dir, '.scan_result.json')
|
||||
scanning = os.path.exists(marker_file)
|
||||
|
||||
data = {"scanning": scanning}
|
||||
if not scanning and os.path.exists(result_file):
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
progress_file = os.path.join(target_dir, '.scan_progress.json')
|
||||
targets_file = os.path.join(target_dir, '.scan_targets.json')
|
||||
result_file = os.path.join(target_dir, '.scan_result.json')
|
||||
data = _scan_status_payload(marker_file, progress_file, (progress_file, targets_file))
|
||||
|
||||
if not data["scanning"] and os.path.exists(result_file):
|
||||
try:
|
||||
with open(result_file, 'r', encoding='utf-8') as f:
|
||||
data["result"] = __import__('json').load(f)
|
||||
@@ -71,13 +274,19 @@ async def api_scan_folder(request):
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
|
||||
offline_only = data.get("offline_only", False)
|
||||
skip_rename = data.get("skip_rename", False)
|
||||
virtual_rename = data.get("virtual_rename", False)
|
||||
physical_rename = data.get("physical_rename", False)
|
||||
physical_rename_requested = data.get("physical_rename", False)
|
||||
physical_rename = physical_rename_requested and not is_physical_rename_protected(
|
||||
folder_type=folder_type,
|
||||
folder_path=target_dir,
|
||||
)
|
||||
force_overwrite = data.get("force_overwrite", False)
|
||||
|
||||
target_files_list = data.get("target_files", [])
|
||||
@@ -85,22 +294,26 @@ async def api_scan_folder(request):
|
||||
target_files_str = request.query.get('target_files', '')
|
||||
target_files_list = [f.strip() for f in target_files_str.split(',')] if target_files_str else []
|
||||
|
||||
claimed = False
|
||||
try:
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
try:
|
||||
with open(marker_file, 'x') as f:
|
||||
f.write('1')
|
||||
except FileExistsError:
|
||||
progress_file = os.path.join(target_dir, '.scan_progress.json')
|
||||
targets_file = os.path.join(target_dir, '.scan_targets.json')
|
||||
claimed, recovered = _claim_scan_marker(
|
||||
marker_file,
|
||||
"folder",
|
||||
(progress_file, targets_file),
|
||||
)
|
||||
if not claimed:
|
||||
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
|
||||
|
||||
if target_files_list:
|
||||
targets_file = os.path.join(target_dir, '.scan_targets.json')
|
||||
with open(targets_file, 'w', encoding='utf-8') as f:
|
||||
__import__('json').dump(target_files_list, f)
|
||||
|
||||
def run_bg():
|
||||
try:
|
||||
cmd = [sys.executable, scraper_path, target_dir]
|
||||
cmd = [sys.executable, scraper_path, target_dir, "--folder-type", folder_type]
|
||||
if offline_only:
|
||||
cmd.append("--offline-only")
|
||||
if skip_rename:
|
||||
@@ -112,15 +325,15 @@ async def api_scan_folder(request):
|
||||
if force_overwrite:
|
||||
cmd.append("--force-overwrite")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=plugin_dir
|
||||
)
|
||||
if result.returncode != 0:
|
||||
cmd.extend(["--progress-file", progress_file])
|
||||
process = subprocess.Popen(cmd, cwd=plugin_dir)
|
||||
_update_scan_marker(marker_file, worker_pid=process.pid)
|
||||
return_code = process.wait()
|
||||
if return_code != 0:
|
||||
result_file = os.path.join(target_dir, '.scan_result.json')
|
||||
with open(result_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({"success": 0, "fail": 1, "error": f"Scanner exited with code {result.returncode}"}, f)
|
||||
finally:
|
||||
json.dump({"success": 0, "fail": 1, "error": f"Scanner exited with code {return_code}"}, f)
|
||||
finally:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
try: folder_paths.filename_list_cache.clear()
|
||||
except: pass
|
||||
@@ -128,74 +341,104 @@ async def api_scan_folder(request):
|
||||
try: folder_paths.cache_helper.clear()
|
||||
except: pass
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except: pass
|
||||
_release_scan_marker(marker_file, (progress_file, targets_file))
|
||||
|
||||
threading.Thread(target=run_bg, daemon=True).start()
|
||||
|
||||
return web.json_response({"status": "ok", "message": "Scan started in background. Check console for details."})
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"message": "Scan started in background. Check console for details.",
|
||||
"recovered": recovered,
|
||||
})
|
||||
except Exception as e:
|
||||
if 'marker_file' in locals() and os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except OSError: pass
|
||||
if claimed:
|
||||
_release_scan_marker(marker_file, (progress_file, targets_file))
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_scan_all(request):
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
scraper_path = os.path.join(plugin_dir, "scraper.py")
|
||||
scraper_path = os.path.join(plugin_dir, "scraper.py")
|
||||
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
|
||||
progress_file = os.path.join(plugin_dir, '.global_scan_progress.json')
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
|
||||
offline_only = data.get("offline_only", False)
|
||||
use_local_metadata = data.get("use_local_metadata", True)
|
||||
skip_rename = data.get("skip_rename", True)
|
||||
virtual_rename = data.get("virtual_rename", False)
|
||||
physical_rename = data.get("physical_rename", False)
|
||||
physical_rename = data.get("physical_rename", False)
|
||||
force_overwrite = data.get("force_overwrite", False)
|
||||
skip_media = data.get("skip_media", False)
|
||||
|
||||
claimed = False
|
||||
try:
|
||||
try:
|
||||
with open(marker_file, 'x') as f:
|
||||
f.write('1')
|
||||
except FileExistsError:
|
||||
claimed, recovered = _claim_scan_marker(marker_file, "global", (progress_file,))
|
||||
if not claimed:
|
||||
return web.json_response({"status": "error", "message": "Global scan already in progress"}, status=409)
|
||||
|
||||
def run_global_bg():
|
||||
try:
|
||||
paths_to_scan = get_active_scan_paths()
|
||||
for base_dir in paths_to_scan:
|
||||
if not os.path.exists(base_dir): continue
|
||||
try:
|
||||
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
|
||||
cmd = [sys.executable, scraper_path, base_dir]
|
||||
|
||||
def run_global_bg():
|
||||
try:
|
||||
paths_to_scan = get_active_scan_paths()
|
||||
paths_to_scan = [path for path in paths_to_scan if os.path.exists(path)]
|
||||
_update_scan_state(
|
||||
marker_file,
|
||||
phase="preparing",
|
||||
folder_total=len(paths_to_scan),
|
||||
folder_current=0,
|
||||
folder="",
|
||||
)
|
||||
for folder_index, base_dir in enumerate(paths_to_scan, 1):
|
||||
if not os.path.exists(base_dir): continue
|
||||
try:
|
||||
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
|
||||
_remove_file(progress_file)
|
||||
_update_scan_state(
|
||||
marker_file,
|
||||
phase="enumerating",
|
||||
folder_total=len(paths_to_scan),
|
||||
folder_current=folder_index,
|
||||
folder=os.path.basename(os.path.normpath(base_dir)) or base_dir,
|
||||
error="",
|
||||
)
|
||||
cmd = [sys.executable, scraper_path, base_dir]
|
||||
protected_type = _protected_type_for_path(base_dir)
|
||||
if protected_type:
|
||||
cmd.extend(["--folder-type", protected_type])
|
||||
if offline_only:
|
||||
cmd.append("--offline-only")
|
||||
if skip_rename:
|
||||
cmd.append("--skip-rename")
|
||||
if virtual_rename:
|
||||
cmd.append("--virtual-rename")
|
||||
if physical_rename:
|
||||
cmd.append("--physical-rename")
|
||||
if physical_rename and not is_physical_rename_protected(
|
||||
folder_type=protected_type,
|
||||
folder_path=base_dir,
|
||||
):
|
||||
cmd.append("--physical-rename")
|
||||
if force_overwrite:
|
||||
cmd.append("--force-overwrite")
|
||||
if skip_media:
|
||||
cmd.append("--skip-media")
|
||||
if not use_local_metadata:
|
||||
cmd.append("--skip-local-metadata")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=plugin_dir
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"[Anomalous Browser] Scanner exited with code {result.returncode}: {base_dir}")
|
||||
except Exception as e:
|
||||
print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}")
|
||||
if not use_local_metadata:
|
||||
cmd.append("--skip-local-metadata")
|
||||
cmd.extend(["--progress-file", progress_file])
|
||||
process = subprocess.Popen(cmd, cwd=plugin_dir)
|
||||
_update_scan_marker(marker_file, worker_pid=process.pid)
|
||||
return_code = process.wait()
|
||||
_update_scan_marker(marker_file, worker_pid=0)
|
||||
if return_code != 0:
|
||||
message = f"Scanner exited with code {return_code}: {base_dir}"
|
||||
_update_scan_state(marker_file, error=message)
|
||||
print(f"[Anomalous Browser] {message}")
|
||||
except Exception as e:
|
||||
_update_scan_state(marker_file, error=str(e))
|
||||
print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}")
|
||||
finally:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
try: folder_paths.filename_list_cache.clear()
|
||||
@@ -204,22 +447,20 @@ async def api_scan_all(request):
|
||||
try: folder_paths.cache_helper.clear()
|
||||
except: pass
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except: pass
|
||||
_release_scan_marker(marker_file, (progress_file,))
|
||||
|
||||
threading.Thread(target=run_global_bg, daemon=True).start()
|
||||
return web.json_response({"status": "ok", "message": "Global scan started"})
|
||||
return web.json_response({"status": "ok", "message": "Global scan started", "recovered": recovered})
|
||||
except Exception as e:
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except OSError: pass
|
||||
if claimed:
|
||||
_release_scan_marker(marker_file, (progress_file,))
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_global_scan_status(request):
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
|
||||
return web.json_response({"scanning": os.path.exists(marker_file)})
|
||||
async def api_global_scan_status(request):
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
|
||||
progress_file = os.path.join(plugin_dir, '.global_scan_progress.json')
|
||||
return web.json_response(_scan_status_payload(marker_file, progress_file, (progress_file,)))
|
||||
async def api_clean_civitai_info(request):
|
||||
try:
|
||||
paths_to_clean = get_active_scan_paths()
|
||||
@@ -262,7 +503,7 @@ async def api_scan_missing_models(request):
|
||||
sys.path.insert(0, plugin_dir)
|
||||
|
||||
try:
|
||||
from scraper import extract_safetensors_hash, calculate_sha256, fetch_civitai_info, infer_base_model_from_header
|
||||
from scraper import calculate_sha256, fetch_civitai_info, infer_base_model_from_header, computed_file_identity
|
||||
except ImportError:
|
||||
return web.json_response({"status": "error", "message": "Failed to load scraper module"})
|
||||
|
||||
@@ -293,29 +534,8 @@ async def api_scan_missing_models(request):
|
||||
if not file.endswith('.safetensors'):
|
||||
continue
|
||||
file_path = os.path.join(root, file)
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
info_path = base_path + ".info"
|
||||
civitai_info_path = base_path + ".civitai.info"
|
||||
|
||||
# Check if a valid info file exists
|
||||
has_valid_info = False
|
||||
actual_info = info_path if os.path.exists(info_path) else (civitai_info_path if os.path.exists(civitai_info_path) else None)
|
||||
|
||||
if force_overwrite:
|
||||
actual_info = None
|
||||
|
||||
if actual_info:
|
||||
try:
|
||||
with open(actual_info, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for fi in data.get("files", []):
|
||||
if isinstance(fi, dict) and "hashes" in fi and "SHA256" in fi["hashes"]:
|
||||
if fi["hashes"]["SHA256"]:
|
||||
has_valid_info = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
has_valid_info = not force_overwrite and bool(get_metadata(file_path).get("hash"))
|
||||
|
||||
if not has_valid_info:
|
||||
files_to_scan.append(file_path)
|
||||
|
||||
@@ -327,31 +547,15 @@ async def api_scan_missing_models(request):
|
||||
GLOBAL_SCAN_STATE["filename"] = filename
|
||||
|
||||
try:
|
||||
civitai_data = None
|
||||
file_hash = None
|
||||
|
||||
# Fallback 1: Try header hash on Civitai
|
||||
header_hash = extract_safetensors_hash(file_path)
|
||||
if header_hash:
|
||||
civitai_data = fetch_civitai_info(header_hash)
|
||||
if civitai_data:
|
||||
file_hash = header_hash
|
||||
|
||||
# Fallback 2: If header hash fails, compute full SHA256
|
||||
if not civitai_data:
|
||||
full_hash = calculate_sha256(file_path)
|
||||
civitai_data = fetch_civitai_info(full_hash)
|
||||
file_hash = full_hash
|
||||
|
||||
file_hash = calculate_sha256(file_path)
|
||||
civitai_data = fetch_civitai_info(file_hash)
|
||||
|
||||
# Fallback 3: Local Offline Inference
|
||||
if not civitai_data:
|
||||
inferred_base = infer_base_model_from_header(file_path)
|
||||
if inferred_base == 'Unknown':
|
||||
inferred_base = ""
|
||||
|
||||
if not file_hash:
|
||||
file_hash = header_hash or calculate_sha256(file_path)
|
||||
|
||||
civitai_data = {
|
||||
"id": -1,
|
||||
"modelId": -1,
|
||||
@@ -365,15 +569,8 @@ async def api_scan_missing_models(request):
|
||||
"files": [{"hashes": {"SHA256": file_hash}}]
|
||||
}
|
||||
|
||||
# Ensure SHA256 is explicitly injected into the info data
|
||||
if civitai_data:
|
||||
if not civitai_data.get("files"):
|
||||
civitai_data["files"] = [{"hashes": {"SHA256": file_hash}}]
|
||||
elif isinstance(civitai_data["files"][0], dict):
|
||||
if "hashes" not in civitai_data["files"][0]:
|
||||
civitai_data["files"][0]["hashes"] = {}
|
||||
civitai_data["files"][0]["hashes"]["SHA256"] = file_hash
|
||||
|
||||
civitai_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
|
||||
|
||||
# Save info file
|
||||
info_path = os.path.splitext(file_path)[0] + ".info"
|
||||
with open(info_path, 'w', encoding='utf-8') as f:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Bounded translation-provider HTTP route."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
def _translate_with_deepl(text, tl, deepl_key):
|
||||
deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" }
|
||||
d_tl = deepl_map.get(tl, "EN")
|
||||
url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate"
|
||||
payload = urllib.parse.urlencode({
|
||||
"auth_key": deepl_key,
|
||||
"text": text,
|
||||
"target_lang": d_tl
|
||||
}).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=payload)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
return result["translations"][0]["text"]
|
||||
|
||||
|
||||
def _translate_with_google(text, tl):
|
||||
clients = ["dict-chrome-ex", "gtx"]
|
||||
last_err = None
|
||||
target_lang = "zh-CN" if tl in ("zh", "zh-CN") else tl
|
||||
for client in clients:
|
||||
try:
|
||||
url = f"https://translate.googleapis.com/translate_a/single?client={client}&sl=auto&tl={target_lang}&dt=t&q={urllib.parse.quote(text)}"
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
if result and isinstance(result, list) and len(result) > 0 and isinstance(result[0], list):
|
||||
translated_text = "".join([part[0] for part in result[0] if part and len(part) > 0 and part[0]])
|
||||
if translated_text:
|
||||
return translated_text
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
continue
|
||||
if last_err:
|
||||
raise last_err
|
||||
raise RuntimeError("Google translate returned empty result")
|
||||
|
||||
|
||||
def _translate_with_mymemory(text, tl):
|
||||
has_cn = any('\u4e00' <= char <= '\u9fa5' for char in text)
|
||||
sl = 'zh-CN' if has_cn else 'en'
|
||||
pair_tl = 'en' if has_cn else ('zh-CN' if tl in ('zh', 'zh-CN') else tl)
|
||||
url = f"https://api.mymemory.translated.net/get?q={urllib.parse.quote(text)}&langpair={sl}|{pair_tl}"
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
res = data.get("responseData", {}).get("translatedText")
|
||||
if res and res != text:
|
||||
return res
|
||||
for m in data.get("matches", []):
|
||||
t_match = m.get("translation")
|
||||
if t_match and t_match != text:
|
||||
return t_match
|
||||
if res:
|
||||
return res
|
||||
raise RuntimeError("MyMemory returned no translation")
|
||||
|
||||
|
||||
async def api_translate(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)
|
||||
text = str(data.get("text", "")).strip()
|
||||
try:
|
||||
tl = str(data.get("target_lang", "zh-CN")).strip()
|
||||
if not text:
|
||||
return web.json_response({"translated": "", "status": "success"})
|
||||
|
||||
# 1. Try DeepL if configured
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
deepl_key = ""
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
deepl_key = cfg.get("DEEPL_API_KEY", "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if deepl_key:
|
||||
try:
|
||||
translated = await asyncio.to_thread(_translate_with_deepl, text, tl, deepl_key)
|
||||
if translated:
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "deepl"})
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] DeepL translate failed: {e}")
|
||||
|
||||
# 2. Try Google Translate (client=dict-chrome-ex)
|
||||
try:
|
||||
translated = await asyncio.to_thread(_translate_with_google, text, tl)
|
||||
if translated:
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "google"})
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] Google translate failed: {e}")
|
||||
|
||||
# 3. Fallback to MyMemory
|
||||
try:
|
||||
translated = await asyncio.to_thread(_translate_with_mymemory, text, tl)
|
||||
if translated:
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "mymemory"})
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] MyMemory translate failed: {e}")
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] All translation providers failed: {e}")
|
||||
return web.json_response({"translated": text, "error": str(e), "status": "error"})
|
||||
@@ -1,610 +1,31 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
import hashlib
|
||||
import tempfile
|
||||
import time
|
||||
"""Compatibility exports for focused backend utility modules."""
|
||||
|
||||
import os
|
||||
|
||||
from . import gallery_routes as _gallery
|
||||
from .folder_types import *
|
||||
from .media_routes import *
|
||||
from .path_utils import *
|
||||
from .translation_routes import *
|
||||
|
||||
|
||||
CARD_THUMBNAIL_EDGE = 512
|
||||
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
|
||||
_thumbnail_cleanup_lock = threading.Lock()
|
||||
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
|
||||
_thumbnail_last_cleanup = 0.0
|
||||
_thumbnail_created_since_cleanup = 0
|
||||
_gallery_snapshot = _gallery._gallery_snapshot
|
||||
_gallery_collect_impl = _gallery._collect_gallery_images
|
||||
_gallery_images_impl = _gallery._gallery_images
|
||||
|
||||
|
||||
def resolve_within(base_dir, *parts):
|
||||
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
|
||||
base_real = os.path.realpath(base_dir)
|
||||
candidate = os.path.realpath(os.path.join(base_real, *parts))
|
||||
try:
|
||||
if os.path.commonpath([base_real, candidate]) != base_real:
|
||||
raise ValueError("Path escapes the configured directory")
|
||||
except ValueError:
|
||||
raise ValueError("Path escapes the configured directory")
|
||||
return candidate
|
||||
def _collect_gallery_images(output_dir):
|
||||
return _gallery_collect_impl(output_dir)
|
||||
|
||||
|
||||
def get_folder_root(folder_type, path_idx=0):
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
|
||||
raise ValueError("Invalid folder path")
|
||||
return os.path.realpath(paths[path_idx])
|
||||
def _gallery_images(output_dir, refresh=False):
|
||||
_gallery._collect_gallery_images = _collect_gallery_images
|
||||
return _gallery_images_impl(output_dir, refresh)
|
||||
|
||||
|
||||
def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
|
||||
base_dir = get_folder_root(folder_type, path_idx)
|
||||
relative = "" if subfolder in (None, "", "/") else str(subfolder).strip("/\\")
|
||||
return base_dir, resolve_within(base_dir, relative)
|
||||
def _invalidate_gallery_snapshot():
|
||||
return _gallery._invalidate_gallery_snapshot()
|
||||
|
||||
|
||||
def require_filename(filename):
|
||||
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
|
||||
raise ValueError("Invalid filename")
|
||||
return filename
|
||||
|
||||
|
||||
def _thumbnail_cache_directory():
|
||||
try:
|
||||
base_dir = folder_paths.get_temp_directory()
|
||||
except Exception:
|
||||
base_dir = None
|
||||
if not isinstance(base_dir, (str, os.PathLike)):
|
||||
base_dir = tempfile.gettempdir()
|
||||
cache_dir = os.path.join(base_dir, "anomalous_model_browser", "card_thumbnails")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
|
||||
def _prune_thumbnail_cache(cache_dir, created=False):
|
||||
"""Occasionally cap the derived thumbnail cache without touching source covers."""
|
||||
global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup
|
||||
now = time.monotonic()
|
||||
with _thumbnail_cleanup_lock:
|
||||
if created:
|
||||
_thumbnail_created_since_cleanup += 1
|
||||
now = time.monotonic()
|
||||
if (
|
||||
now - _thumbnail_last_cleanup < 3600
|
||||
and _thumbnail_created_since_cleanup < 64
|
||||
):
|
||||
return
|
||||
_thumbnail_last_cleanup = now
|
||||
_thumbnail_created_since_cleanup = 0
|
||||
entries = []
|
||||
total_size = 0
|
||||
try:
|
||||
with os.scandir(cache_dir) as iterator:
|
||||
for entry in iterator:
|
||||
if not entry.is_file() or not entry.name.endswith('.webp'):
|
||||
continue
|
||||
try:
|
||||
stat = entry.stat()
|
||||
except OSError:
|
||||
continue
|
||||
total_size += stat.st_size
|
||||
entries.append((stat.st_mtime_ns, stat.st_size, entry.path))
|
||||
except OSError:
|
||||
return
|
||||
if total_size <= CARD_THUMBNAIL_CACHE_LIMIT:
|
||||
return
|
||||
target_size = int(CARD_THUMBNAIL_CACHE_LIMIT * 0.8)
|
||||
for _, size, path in sorted(entries):
|
||||
try:
|
||||
os.remove(path)
|
||||
total_size -= size
|
||||
except OSError:
|
||||
pass
|
||||
if total_size <= target_size:
|
||||
break
|
||||
|
||||
|
||||
def _build_card_thumbnail_impl(source_path):
|
||||
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
source_stat = os.stat(source_path)
|
||||
cache_key = "\0".join((
|
||||
os.path.realpath(source_path),
|
||||
str(source_stat.st_size),
|
||||
str(source_stat.st_mtime_ns),
|
||||
str(getattr(source_stat, 'st_ctime_ns', 0)),
|
||||
str(CARD_THUMBNAIL_EDGE),
|
||||
))
|
||||
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
|
||||
cache_dir = _thumbnail_cache_directory()
|
||||
cached_path = os.path.join(cache_dir, f"{digest}.webp")
|
||||
if os.path.isfile(cached_path):
|
||||
try:
|
||||
os.utime(cached_path, None)
|
||||
except OSError:
|
||||
pass
|
||||
_prune_thumbnail_cache(cache_dir)
|
||||
return cached_path
|
||||
|
||||
with Image.open(source_path) as image:
|
||||
if getattr(image, 'is_animated', False):
|
||||
return source_path
|
||||
image = ImageOps.exif_transpose(image)
|
||||
if max(image.size) <= CARD_THUMBNAIL_EDGE:
|
||||
return source_path
|
||||
resampling = getattr(Image, 'Resampling', Image).LANCZOS
|
||||
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
|
||||
if image.mode not in ('RGB', 'RGBA'):
|
||||
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
|
||||
temp_path = (
|
||||
f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
)
|
||||
try:
|
||||
image.save(temp_path, format='WEBP', quality=84, method=4)
|
||||
os.replace(temp_path, cached_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
_prune_thumbnail_cache(cache_dir, created=True)
|
||||
return cached_path
|
||||
except Exception:
|
||||
return source_path
|
||||
|
||||
|
||||
def _build_card_thumbnail(source_path):
|
||||
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
|
||||
with _thumbnail_generation_slots:
|
||||
return _build_card_thumbnail_impl(source_path)
|
||||
|
||||
async def api_serve_image(request):
|
||||
"""Dedicated image serving endpoint for model preview images."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
subfolder = request.query.get('subfolder', '')
|
||||
filename = request.query.get('filename', '')
|
||||
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
return web.Response(status=400, text='Invalid request')
|
||||
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
return web.Response(status=404, text='Image not found')
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
content_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.avif': 'image/avif',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo'
|
||||
}
|
||||
content_type = content_types.get(ext)
|
||||
if content_type is None:
|
||||
return web.Response(status=415, text='Unsupported media type')
|
||||
|
||||
served_path = file_path
|
||||
if (
|
||||
request.query.get('variant') == 'card'
|
||||
and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS
|
||||
):
|
||||
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
|
||||
if served_path != file_path:
|
||||
content_type = 'image/webp'
|
||||
|
||||
headers = {'Content-Type': content_type}
|
||||
if request.query.get('t'):
|
||||
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||
return web.FileResponse(served_path, headers=headers)
|
||||
|
||||
|
||||
|
||||
async def api_clear_cache(request):
|
||||
try:
|
||||
from .metadata import clear_metadata_cache
|
||||
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
clear_metadata_cache()
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
async def api_translate(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
text = data.get("text", "")
|
||||
tl = data.get("target_lang", "zh-CN")
|
||||
if not text:
|
||||
return web.json_response({"translated": ""})
|
||||
|
||||
# Try DeepL if API key exists
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
deepl_key = ""
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
deepl_key = cfg.get("DEEPL_API_KEY", "").strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
if deepl_key:
|
||||
deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" }
|
||||
d_tl = deepl_map.get(tl, "EN")
|
||||
url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate"
|
||||
payload = urllib.parse.urlencode({
|
||||
"auth_key": deepl_key,
|
||||
"text": text,
|
||||
"target_lang": d_tl
|
||||
}).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=payload)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
return web.json_response({"translated": result["translations"][0]["text"]})
|
||||
else:
|
||||
# Fallback to Google Translate (free, no key)
|
||||
url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl={tl}&dt=t&q={urllib.parse.quote(text)}"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
# Google Translate returns a list of fragments
|
||||
translated_text = "".join([part[0] for part in result[0]])
|
||||
return web.json_response({"translated": translated_text})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Translate Error: {e}")
|
||||
return web.json_response({"translated": text, "error": str(e)})
|
||||
|
||||
|
||||
async def api_get_gallery_images(request):
|
||||
try:
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.exists(output_dir):
|
||||
return web.json_response({"images": [], "total": 0, "page": 1, "pages": 0})
|
||||
|
||||
page = max(1, int(request.query.get('page', 1)))
|
||||
limit = min(200, max(1, int(request.query.get('limit', 50))))
|
||||
|
||||
valid_exts = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
|
||||
def collect_images():
|
||||
images = []
|
||||
for root, dirs, files in os.walk(output_dir):
|
||||
for f in files:
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
if ext in valid_exts:
|
||||
rel_path = os.path.relpath(root, output_dir)
|
||||
subfolder = "" if rel_path == "." else rel_path.replace('\\', '/')
|
||||
full_path = os.path.join(root, f)
|
||||
try:
|
||||
mtime = os.path.getmtime(full_path)
|
||||
except OSError:
|
||||
mtime = 0
|
||||
images.append({
|
||||
"filename": f,
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
"mtime": mtime
|
||||
})
|
||||
images.sort(key=lambda x: x['mtime'], reverse=True)
|
||||
return images
|
||||
|
||||
images = await asyncio.to_thread(collect_images)
|
||||
|
||||
total = len(images)
|
||||
start_idx = (page - 1) * limit
|
||||
end_idx = start_idx + limit
|
||||
paginated_images = images[start_idx:end_idx]
|
||||
|
||||
return web.json_response({
|
||||
"images": paginated_images,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": (total + limit - 1) // limit if limit > 0 else 0
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def api_delete_gallery_image(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename")
|
||||
subfolder = data.get("subfolder", "")
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
target_dir = resolve_within(output_dir, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return web.json_response({"status": "success"})
|
||||
else:
|
||||
return web.json_response({"status": "error", "message": "File not found"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
def read_png_text_fast(path):
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
signature = f.read(8)
|
||||
if signature != b'\x89PNG\r\n\x1a\n':
|
||||
return None
|
||||
while True:
|
||||
length_bytes = f.read(4)
|
||||
if not length_bytes: break
|
||||
length = struct.unpack('>I', length_bytes)[0]
|
||||
chunk_type = f.read(4)
|
||||
if chunk_type == b'tEXt':
|
||||
data = f.read(length)
|
||||
keyword, text = data.split(b'\0', 1)
|
||||
if keyword == b'prompt':
|
||||
return text.decode('utf-8', errors='ignore')
|
||||
else:
|
||||
f.seek(length, 1)
|
||||
f.seek(4, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def api_get_model_images(request):
|
||||
model_name = request.rel_url.query.get('model_name', '')
|
||||
if not model_name:
|
||||
return web.json_response({'images': []})
|
||||
|
||||
base_target = os.path.basename(model_name).lower()
|
||||
|
||||
# We will search the output directory
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.exists(output_dir):
|
||||
return web.json_response({'images': []})
|
||||
|
||||
def find_images():
|
||||
matched_images = []
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for file in files:
|
||||
if not file.lower().endswith('.png'):
|
||||
continue
|
||||
full_path = os.path.join(root, file)
|
||||
prompt_text = read_png_text_fast(full_path)
|
||||
if not prompt_text:
|
||||
continue
|
||||
try:
|
||||
prompt_data = json.loads(prompt_text)
|
||||
matched = any(
|
||||
os.path.basename(v).lower() == base_target
|
||||
for node in prompt_data.values()
|
||||
if isinstance(node, dict) and 'class_type' in node
|
||||
for v in node.get('inputs', {}).values()
|
||||
if isinstance(v, str)
|
||||
)
|
||||
if not matched:
|
||||
continue
|
||||
rel_path = os.path.relpath(root, output_dir).replace('\\', '/')
|
||||
if rel_path == '.':
|
||||
rel_path = ''
|
||||
url = f'/view?filename={urllib.parse.quote(file)}&type=output'
|
||||
if rel_path:
|
||||
url += f'&subfolder={urllib.parse.quote(rel_path)}'
|
||||
matched_images.append({'url': url, 'mtime': os.path.getmtime(full_path)})
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
pass
|
||||
matched_images.sort(key=lambda x: x['mtime'], reverse=True)
|
||||
return matched_images
|
||||
|
||||
matched_images = await asyncio.to_thread(find_images)
|
||||
|
||||
return web.json_response({'images': matched_images})
|
||||
|
||||
def get_folder_view_mode():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
return cfg.get("folder_view_mode", "abstract")
|
||||
except:
|
||||
pass
|
||||
return "abstract"
|
||||
|
||||
def get_all_physical_basenames():
|
||||
basenames = set()
|
||||
for t in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for p in paths:
|
||||
bn = os.path.basename(os.path.normpath(p))
|
||||
if bn: basenames.add(bn)
|
||||
except:
|
||||
pass
|
||||
return list(basenames)
|
||||
|
||||
def get_active_physical_basenames():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
all_bns = get_all_physical_basenames()
|
||||
active = []
|
||||
configured = set()
|
||||
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
pfc = cfg.get("physical_folders_config")
|
||||
if pfc and isinstance(pfc, list):
|
||||
for item in pfc:
|
||||
bn = item.get("type")
|
||||
if bn in all_bns:
|
||||
configured.add(bn)
|
||||
if item.get("visible", True):
|
||||
active.append(bn)
|
||||
|
||||
for bn in all_bns:
|
||||
if bn not in configured:
|
||||
active.append(bn)
|
||||
return active
|
||||
except:
|
||||
pass
|
||||
|
||||
return all_bns
|
||||
|
||||
def get_active_scan_paths():
|
||||
mode = get_folder_view_mode()
|
||||
paths = set()
|
||||
|
||||
if mode == "physical":
|
||||
active_bns = set(get_active_physical_basenames())
|
||||
for t in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
ps = folder_paths.get_folder_paths(t)
|
||||
if not ps: continue
|
||||
for p in ps:
|
||||
if not os.path.exists(p): continue
|
||||
bn = os.path.basename(os.path.normpath(p))
|
||||
if bn in active_bns:
|
||||
paths.add(os.path.realpath(p))
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
active_types = get_active_folder_types()
|
||||
for t in active_types:
|
||||
try:
|
||||
ps = folder_paths.get_folder_paths(t)
|
||||
if not ps: continue
|
||||
for p in ps:
|
||||
if not os.path.exists(p): continue
|
||||
paths.add(os.path.realpath(p))
|
||||
except:
|
||||
pass
|
||||
|
||||
return list(paths)
|
||||
|
||||
def get_active_folder_types():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
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', 'controlnet', 'vae']
|
||||
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
ftc = cfg.get("folder_types_config")
|
||||
if ftc and isinstance(ftc, list):
|
||||
active = []
|
||||
configured_types = set()
|
||||
for item in ftc:
|
||||
t = item.get("type")
|
||||
if t in all_types:
|
||||
configured_types.add(t)
|
||||
if item.get("visible", True):
|
||||
active.append(t)
|
||||
# For any newly registered types not in config, do not show them by default
|
||||
# unless they are in default_types
|
||||
for t in all_types:
|
||||
if t not in configured_types and t in default_types:
|
||||
active.append(t)
|
||||
return active
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback if no config exists
|
||||
active = [t for t in default_types if t in all_types]
|
||||
return active
|
||||
|
||||
async def api_get_all_folder_types(request):
|
||||
mode = get_folder_view_mode()
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
result = []
|
||||
|
||||
if mode == "physical":
|
||||
all_bns = get_all_physical_basenames()
|
||||
configured = set()
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
pfc = cfg.get("physical_folders_config")
|
||||
if pfc and isinstance(pfc, list):
|
||||
for item in pfc:
|
||||
bn = item.get("type")
|
||||
if bn in all_bns:
|
||||
configured.add(bn)
|
||||
result.append({
|
||||
"type": bn,
|
||||
"visible": item.get("visible", True)
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
default_physical_types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
|
||||
for bn in all_bns:
|
||||
if bn not in configured:
|
||||
result.append({
|
||||
"type": bn,
|
||||
"visible": bn in default_physical_types
|
||||
})
|
||||
else:
|
||||
# Abstract mode
|
||||
all_types = list(folder_paths.folder_names_and_paths.keys())
|
||||
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
|
||||
configured_types = set()
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
ftc = cfg.get("folder_types_config")
|
||||
if ftc and isinstance(ftc, list):
|
||||
for item in ftc:
|
||||
t = item.get("type")
|
||||
if t in all_types:
|
||||
configured_types.add(t)
|
||||
result.append({
|
||||
"type": t,
|
||||
"visible": item.get("visible", True)
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
for t in all_types:
|
||||
if t not in configured_types:
|
||||
result.append({
|
||||
"type": t,
|
||||
"visible": t in default_types
|
||||
})
|
||||
|
||||
return web.json_response({"folder_types": result, "folder_view_mode": mode})
|
||||
api_get_gallery_images = _gallery.api_get_gallery_images
|
||||
api_delete_gallery_image = _gallery.api_delete_gallery_image
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Plugin version display, published-release listing and version switching.
|
||||
|
||||
The plugin is normally installed as a git clone (ComfyUI Manager), so the
|
||||
installed version is read from git and switching checks out a published
|
||||
release tag. Nothing runs automatically: every network call starts from a
|
||||
button in the UI. Switching refuses when tracked files have local changes, and
|
||||
only tags that are published releases (or at least exist on the remote) can be
|
||||
checked out.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
STATE_FILE = os.path.join(PLUGIN_DIR, ".anomalous_version.local.json")
|
||||
# A version whose tree contains this file has the version panel; older ones do not.
|
||||
SWITCHER_MARKER = "api/version_manager.py"
|
||||
TAG_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$")
|
||||
GITHUB_REMOTE = re.compile(r"github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$", re.IGNORECASE)
|
||||
RELEASES_CACHE_SECONDS = 300
|
||||
NETWORK_TIMEOUT = 10
|
||||
FETCH_TIMEOUT = 180
|
||||
|
||||
_lock = threading.Lock()
|
||||
_releases_cache = {"expires": 0, "data": None}
|
||||
|
||||
|
||||
class VersionError(Exception):
|
||||
"""A user-facing failure with a stable code for the UI."""
|
||||
|
||||
def __init__(self, code, message="", **details):
|
||||
super().__init__(message or code)
|
||||
self.code = code
|
||||
self.details = details
|
||||
|
||||
|
||||
# ---------- git helpers ----------
|
||||
|
||||
def _git_executable():
|
||||
found = shutil.which("git")
|
||||
if not found:
|
||||
raise VersionError("git_missing", "git executable not found")
|
||||
return found
|
||||
|
||||
|
||||
def _git(*args, timeout=30, check=True):
|
||||
kwargs = {}
|
||||
if os.name == "nt":
|
||||
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_git_executable(), "-C", PLUGIN_DIR, *args],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=timeout, env=env, **kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise VersionError("git_timeout", f"git {' '.join(args[:2])} timed out")
|
||||
if check and result.returncode != 0:
|
||||
raise VersionError("git_failed", (result.stderr or result.stdout).strip()[-500:])
|
||||
return result
|
||||
|
||||
|
||||
def _git_out(*args, **kwargs):
|
||||
result = _git(*args, check=False, **kwargs)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def version_key(tag):
|
||||
"""Sortable key for tags such as v1.4.2, v1.57-beta, v1.58.0-rc1 (stable after pre-release)."""
|
||||
match = re.match(r"^v?(\d+(?:\.\d+)*)(.*)$", str(tag or ""))
|
||||
if not match:
|
||||
return ((), 0, str(tag))
|
||||
numbers = tuple(int(part) for part in match.group(1).split("."))
|
||||
suffix = match.group(2).lstrip("-_.")
|
||||
return (numbers, 1 if not suffix else 0, suffix)
|
||||
|
||||
|
||||
def current_state():
|
||||
"""Local only (no network): what is installed and whether it can be switched."""
|
||||
if not os.path.isdir(os.path.join(PLUGIN_DIR, ".git")) and _git_out("rev-parse", "--is-inside-work-tree") != "true":
|
||||
return {"is_git": False}
|
||||
commit = _git_out("rev-parse", "HEAD")
|
||||
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD") or None
|
||||
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD") or None
|
||||
described = _git_out("describe", "--tags", "--always")
|
||||
base_tag = _git_out("describe", "--tags", "--abbrev=0") or None
|
||||
# Porcelain lines are "XY path"; keep the leading status column (no strip before slicing).
|
||||
status = _git("status", "--porcelain", "--untracked-files=no", check=False).stdout
|
||||
changed = [line[3:] for line in status.splitlines() if line.strip()]
|
||||
return {
|
||||
"is_git": True,
|
||||
"commit": commit[:12],
|
||||
"branch": branch,
|
||||
"tag": exact_tag,
|
||||
"base_tag": base_tag,
|
||||
"label": exact_tag or described or commit[:12],
|
||||
"dirty": bool(changed),
|
||||
"changed_files": changed[:20],
|
||||
"remote": _git_out("remote", "get-url", "origin") or None,
|
||||
"previous": _read_state().get("previous"),
|
||||
}
|
||||
|
||||
|
||||
# ---------- releases ----------
|
||||
|
||||
def _github_repo(remote_url):
|
||||
match = GITHUB_REMOTE.search(remote_url or "")
|
||||
return (match.group(1), match.group(2)) if match else None
|
||||
|
||||
|
||||
def _fetch_github_releases(owner, repo):
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=50"
|
||||
request = urllib.request.Request(url, headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "Anomalous-Model-Browser",
|
||||
})
|
||||
with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
releases = []
|
||||
for item in payload if isinstance(payload, list) else []:
|
||||
if item.get("draft") or not item.get("tag_name"):
|
||||
continue
|
||||
releases.append({
|
||||
"tag": item["tag_name"],
|
||||
"name": item.get("name") or item["tag_name"],
|
||||
"published_at": item.get("published_at"),
|
||||
"prerelease": bool(item.get("prerelease")),
|
||||
"notes": (item.get("body") or "")[:8000],
|
||||
"url": item.get("html_url"),
|
||||
})
|
||||
return releases
|
||||
|
||||
|
||||
def _remote_tags():
|
||||
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3)
|
||||
tags = [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line]
|
||||
return [{"tag": tag, "name": tag, "published_at": None, "prerelease": False, "notes": "", "url": None} for tag in tags]
|
||||
|
||||
|
||||
def list_releases(force=False):
|
||||
"""Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback."""
|
||||
state = current_state()
|
||||
if not state.get("is_git"):
|
||||
raise VersionError("not_git", "The plugin is not a git checkout")
|
||||
now = time.monotonic()
|
||||
if not force and _releases_cache["data"] and now < _releases_cache["expires"]:
|
||||
releases, source = _releases_cache["data"]
|
||||
else:
|
||||
releases, source = None, None
|
||||
repo = _github_repo(state.get("remote"))
|
||||
if repo:
|
||||
try:
|
||||
releases, source = _fetch_github_releases(*repo), "github"
|
||||
except Exception:
|
||||
releases = None
|
||||
if releases is None:
|
||||
releases, source = _remote_tags(), "git"
|
||||
if not releases:
|
||||
raise VersionError("offline", "Could not reach GitHub or the git remote")
|
||||
releases.sort(key=lambda item: (item["published_at"] or "", version_key(item["tag"])), reverse=True)
|
||||
if source == "git":
|
||||
releases.sort(key=lambda item: version_key(item["tag"]), reverse=True)
|
||||
_releases_cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
|
||||
|
||||
current_key = version_key(state.get("tag") or state.get("base_tag"))
|
||||
stable = [item for item in releases if not item["prerelease"]]
|
||||
latest = stable[0]["tag"] if stable else None
|
||||
result = []
|
||||
for item in releases:
|
||||
key = version_key(item["tag"])
|
||||
entry = dict(item)
|
||||
entry["current"] = item["tag"] == state.get("tag")
|
||||
entry["latest"] = item["tag"] == latest
|
||||
entry["direction"] = "same" if entry["current"] else ("newer" if key > current_key else "older")
|
||||
result.append(entry)
|
||||
return {"state": state, "releases": result, "latest": latest, "source": source}
|
||||
|
||||
|
||||
# ---------- switching ----------
|
||||
|
||||
def _read_state():
|
||||
try:
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _write_state(data):
|
||||
temporary = STATE_FILE + ".tmp"
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, ensure_ascii=False, indent=2)
|
||||
os.replace(temporary, STATE_FILE)
|
||||
|
||||
|
||||
def _require_switchable():
|
||||
state = current_state()
|
||||
if not state.get("is_git"):
|
||||
raise VersionError("not_git", "The plugin is not a git checkout")
|
||||
if state["dirty"]:
|
||||
raise VersionError("dirty", "Tracked files have local changes", files=state["changed_files"])
|
||||
return state
|
||||
|
||||
|
||||
def _known_tag(tag):
|
||||
if not TAG_NAME.match(str(tag or "")):
|
||||
raise VersionError("bad_tag", "Invalid version name")
|
||||
tags = {item["tag"] for item in list_releases()["releases"]}
|
||||
if tag not in tags:
|
||||
raise VersionError("unknown_tag", f"{tag} is not a published version")
|
||||
return tag
|
||||
|
||||
|
||||
def _fetch_tag(tag):
|
||||
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT)
|
||||
|
||||
|
||||
def preview_switch(tag):
|
||||
"""Fetch the target and report what switching would mean, without changing anything."""
|
||||
state = _require_switchable()
|
||||
_known_tag(tag)
|
||||
_fetch_tag(tag)
|
||||
has_switcher = _git("cat-file", "-e", f"refs/tags/{tag}:{SWITCHER_MARKER}", check=False).returncode == 0
|
||||
target_key = version_key(tag)
|
||||
current_key = version_key(state.get("tag") or state.get("base_tag"))
|
||||
return {
|
||||
"tag": tag,
|
||||
"direction": "same" if tag == state.get("tag") else ("newer" if target_key > current_key else "older"),
|
||||
"has_switcher": has_switcher,
|
||||
"from": state["label"],
|
||||
"from_branch": state["branch"],
|
||||
}
|
||||
|
||||
|
||||
def switch_to(tag):
|
||||
with _lock:
|
||||
state = _require_switchable()
|
||||
_known_tag(tag)
|
||||
_fetch_tag(tag)
|
||||
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}")
|
||||
_write_state({"previous": previous, "switched_at": time.time()})
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
|
||||
|
||||
def _default_branch():
|
||||
ref = _git_out("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
|
||||
if ref.startswith("origin/"):
|
||||
return ref.split("/", 1)[1]
|
||||
for name in ("main", "master"):
|
||||
if _git("ls-remote", "--exit-code", "--heads", "origin", name, check=False, timeout=NETWORK_TIMEOUT * 3).returncode == 0:
|
||||
return name
|
||||
raise VersionError("no_default_branch", "Could not determine the default branch")
|
||||
|
||||
|
||||
def return_to_latest():
|
||||
"""Back to the default branch at its newest commit; the same path ComfyUI Manager's update takes."""
|
||||
with _lock:
|
||||
state = _require_switchable()
|
||||
branch = _default_branch()
|
||||
_git("fetch", "--no-tags", "origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}", timeout=FETCH_TIMEOUT)
|
||||
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
|
||||
if _git("rev-parse", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0:
|
||||
_git("checkout", "--quiet", branch)
|
||||
else:
|
||||
_git("checkout", "--quiet", "-b", branch, f"origin/{branch}")
|
||||
merge = _git("merge", "--ff-only", "--quiet", f"origin/{branch}", check=False)
|
||||
if merge.returncode != 0:
|
||||
raise VersionError("diverged", f"Local {branch} has commits that are not on the remote")
|
||||
_write_state({"previous": previous, "switched_at": time.time()})
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
|
||||
|
||||
def undo_last_switch():
|
||||
with _lock:
|
||||
previous = _read_state().get("previous")
|
||||
if not previous or not previous.get("commit"):
|
||||
raise VersionError("no_previous", "There is no switch to undo")
|
||||
state = _require_switchable()
|
||||
here = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
|
||||
if previous.get("branch"):
|
||||
# Going back to a branch means its current tip, even if it moved since (e.g. a Manager update).
|
||||
_git("checkout", "--quiet", previous["branch"])
|
||||
else:
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"])
|
||||
_write_state({"previous": here, "switched_at": time.time()})
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
|
||||
|
||||
# ---------- routes ----------
|
||||
|
||||
def _error_response(error):
|
||||
status = 409 if error.code in {"dirty", "diverged"} else 400
|
||||
if error.code in {"offline", "git_timeout", "git_failed"}:
|
||||
status = 502
|
||||
return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status)
|
||||
|
||||
|
||||
async def _run(handler, *args):
|
||||
try:
|
||||
return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))})
|
||||
except VersionError as error:
|
||||
return _error_response(error)
|
||||
|
||||
|
||||
async def _json_body(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
async def api_get_version(request):
|
||||
"""GET /anomalous/version - Installed version (local, no network)."""
|
||||
try:
|
||||
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
|
||||
except VersionError as error:
|
||||
return _error_response(error)
|
||||
|
||||
|
||||
async def api_get_releases(request):
|
||||
"""GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked)."""
|
||||
return await _run(list_releases, request.query.get("refresh") == "1")
|
||||
|
||||
|
||||
async def api_preview_switch(request):
|
||||
"""POST /anomalous/version/preview {tag}"""
|
||||
return await _run(preview_switch, str((await _json_body(request)).get("tag", "")))
|
||||
|
||||
|
||||
async def api_switch_version(request):
|
||||
"""POST /anomalous/version/switch {tag}"""
|
||||
return await _run(switch_to, str((await _json_body(request)).get("tag", "")))
|
||||
|
||||
|
||||
async def api_return_to_latest(request):
|
||||
"""POST /anomalous/version/latest"""
|
||||
return await _run(return_to_latest)
|
||||
|
||||
|
||||
async def api_undo_switch(request):
|
||||
"""POST /anomalous/version/undo"""
|
||||
return await _run(undo_last_switch)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get('/anomalous/version', api_get_version)
|
||||
app.router.add_get('/anomalous/version/releases', api_get_releases)
|
||||
app.router.add_post('/anomalous/version/preview', api_preview_switch)
|
||||
app.router.add_post('/anomalous/version/switch', api_switch_version)
|
||||
app.router.add_post('/anomalous/version/latest', api_return_to_latest)
|
||||
app.router.add_post('/anomalous/version/undo', api_undo_switch)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Pure Workflow Recipe graph validation and fingerprinting."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
||||
from .recipe_constants import *
|
||||
|
||||
def _normalise_workflow_key(value):
|
||||
return re.sub(r"[^a-z0-9]+", "_", str(value).strip().lower()).strip("_")
|
||||
|
||||
|
||||
def _volatile_widget_indexes(node):
|
||||
"""Known serialized LiteGraph widget positions for native sampler seeds."""
|
||||
node_type = _node_type(node).lower()
|
||||
if node_type == "ksampler":
|
||||
return (0,)
|
||||
if node_type == "ksampleradvanced":
|
||||
return (1,)
|
||||
return ()
|
||||
|
||||
|
||||
def _clean_volatile_params(value):
|
||||
"""Produce a stable graph view while retaining all generation-defining data."""
|
||||
if isinstance(value, list):
|
||||
return [_clean_volatile_params(item) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
|
||||
cleaned = {}
|
||||
for key, nested_value in value.items():
|
||||
if _normalise_workflow_key(key) in VOLATILE_WORKFLOW_KEYS:
|
||||
continue
|
||||
cleaned[str(key)] = _clean_volatile_params(nested_value)
|
||||
|
||||
widgets = cleaned.get("widgets_values")
|
||||
if isinstance(widgets, list):
|
||||
for index in _volatile_widget_indexes(value):
|
||||
if 0 <= index < len(widgets):
|
||||
widgets[index] = "__anomalous_volatile_seed__"
|
||||
return cleaned
|
||||
|
||||
|
||||
def _workflow_fingerprint(workflow):
|
||||
"""Hash the workflow structure after removing known run-volatile values."""
|
||||
canonical = json.dumps(
|
||||
_clean_volatile_params(workflow),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return {
|
||||
"algorithm": "sha256-structural-v1",
|
||||
"value": hashlib.sha256(canonical).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def _parameter_signature(workflow):
|
||||
"""Hash node types and parameter values for UI workflows or API prompts."""
|
||||
if not isinstance(workflow, dict):
|
||||
return {"algorithm": "sha256-params-v1", "value": ""}
|
||||
|
||||
nodes = workflow.get("nodes")
|
||||
is_ui_workflow = isinstance(nodes, list)
|
||||
if not is_ui_workflow:
|
||||
nodes = list(workflow.values())
|
||||
|
||||
cleaned_nodes = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
if is_ui_workflow:
|
||||
parameters = {"widgets": _clean_volatile_params(node.get("widgets_values") or [])}
|
||||
widgets = parameters["widgets"]
|
||||
if isinstance(widgets, list):
|
||||
for index in _volatile_widget_indexes(node):
|
||||
if 0 <= index < len(widgets):
|
||||
widgets[index] = "__anomalous_volatile_seed__"
|
||||
else:
|
||||
parameters = {"inputs": _clean_volatile_params(node.get("inputs") or {})}
|
||||
|
||||
cleaned_nodes.append({
|
||||
"type": _node_type(node),
|
||||
**parameters,
|
||||
})
|
||||
|
||||
cleaned_nodes.sort(key=lambda x: (x["type"], json.dumps(x, sort_keys=True, ensure_ascii=False)))
|
||||
|
||||
canonical = json.dumps(
|
||||
cleaned_nodes,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
return {
|
||||
"algorithm": "sha256-params-v1",
|
||||
"value": hashlib.sha256(canonical).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def _workflow_node_types(workflow):
|
||||
"""Extract only node class names from either UI workflow or API prompt data."""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
nodes = workflow.get("nodes")
|
||||
if isinstance(nodes, list):
|
||||
values = nodes
|
||||
else:
|
||||
values = workflow.values()
|
||||
result = []
|
||||
for node in values:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_type = _node_type(node) or str(node.get("class_type") or "").strip()
|
||||
if node_type:
|
||||
result.append(node_type)
|
||||
return sorted(result, key=lambda value: value.casefold())
|
||||
|
||||
|
||||
def _workflow_node_signature(workflow):
|
||||
"""Stable node-composition signature used for tolerant result discovery."""
|
||||
canonical = json.dumps(
|
||||
_workflow_node_types(workflow),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return {
|
||||
"algorithm": "sha256-node-types-v1",
|
||||
"value": hashlib.sha256(canonical).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def _node_type(node):
|
||||
return str(node.get("type") or node.get("class_type") or "").strip()
|
||||
|
||||
|
||||
def _node_title(node):
|
||||
meta = node.get("_meta") if isinstance(node.get("_meta"), dict) else {}
|
||||
return str(meta.get("title") or node.get("title") or _node_type(node) or "Unknown node").strip()
|
||||
|
||||
|
||||
def _widget_values(node):
|
||||
values = node.get("widgets_values")
|
||||
return values if isinstance(values, list) else []
|
||||
|
||||
|
||||
def _workflow_node_key(value):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
||||
raise ValueError("Invalid workflow node id")
|
||||
value = str(value).strip()
|
||||
if not value:
|
||||
raise ValueError("Invalid workflow node id")
|
||||
return value
|
||||
|
||||
|
||||
def _workflow_link_records(workflow):
|
||||
links = workflow.get("links", [])
|
||||
if links is None:
|
||||
return []
|
||||
if isinstance(links, list):
|
||||
return links
|
||||
if isinstance(links, dict):
|
||||
return list(links.values())
|
||||
raise ValueError("Invalid workflow links")
|
||||
|
||||
|
||||
def _validate_workflow(workflow):
|
||||
"""Reject malformed graph topology before it reaches user recipe storage."""
|
||||
if not isinstance(workflow, dict):
|
||||
raise ValueError("Invalid recipe workflow")
|
||||
nodes = workflow.get("nodes")
|
||||
if not isinstance(nodes, list) or len(nodes) > MAX_WORKFLOW_NODES:
|
||||
raise ValueError("Invalid workflow nodes")
|
||||
|
||||
node_ids = set()
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
raise ValueError("Invalid workflow node")
|
||||
node_key = _workflow_node_key(node.get("id"))
|
||||
if node_key in node_ids:
|
||||
raise ValueError("Duplicate workflow node id")
|
||||
node_ids.add(node_key)
|
||||
values = node.get("widgets_values")
|
||||
if values is not None and (not isinstance(values, list) or len(values) > MAX_WIDGET_VALUES_PER_NODE):
|
||||
raise ValueError("Invalid workflow widget values")
|
||||
|
||||
groups = workflow.get("groups", [])
|
||||
if groups is not None and (not isinstance(groups, list) or len(groups) > MAX_WORKFLOW_GROUPS):
|
||||
raise ValueError("Invalid workflow groups")
|
||||
|
||||
links = _workflow_link_records(workflow)
|
||||
if len(links) > MAX_WORKFLOW_LINKS:
|
||||
raise ValueError("Too many workflow links")
|
||||
for link in links:
|
||||
if isinstance(link, list) and len(link) >= 5:
|
||||
origin_id, target_id = link[1], link[3]
|
||||
elif isinstance(link, dict):
|
||||
origin_id, target_id = link.get("origin_id"), link.get("target_id")
|
||||
else:
|
||||
raise ValueError("Invalid workflow link")
|
||||
if _workflow_node_key(origin_id) not in node_ids or _workflow_node_key(target_id) not in node_ids:
|
||||
raise ValueError("Dangling workflow link")
|
||||
|
||||
|
||||
def _recipe_receipt(recipe, filename):
|
||||
"""Return a small, user-visible confirmation for the accepted graph."""
|
||||
workflow = recipe.get("workflow") if isinstance(recipe, dict) else {}
|
||||
params = recipe.get("params") if isinstance(recipe, dict) else {}
|
||||
return {
|
||||
"filename": filename,
|
||||
"node_count": len(workflow.get("nodes", [])) if isinstance(workflow, dict) else 0,
|
||||
"link_count": len(_workflow_link_records(workflow)) if isinstance(workflow, dict) else 0,
|
||||
"group_count": len(workflow.get("groups", [])) if isinstance(workflow.get("groups", []), list) else 0,
|
||||
"parameter_node_count": len(params.get("nodes", [])) if isinstance(params, dict) and isinstance(params.get("nodes", []), list) else 0,
|
||||
"pinned_count": len(params.get("pinned", [])) if isinstance(params, dict) and isinstance(params.get("pinned", []), list) else 0,
|
||||
"workflow_fingerprint": recipe.get("workflow_fingerprint") if isinstance(recipe, dict) else None,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Architecture Topic Guide
|
||||
|
||||
Start with the repository-root [`ARCHITECTURE.md`](../../ARCHITECTURE.md), then
|
||||
open only the topic needed for the current task.
|
||||
|
||||
- [`backend.md`](backend.md): Python routes, filesystem safety, persistence,
|
||||
scan state, model sidecars, and backend performance.
|
||||
- [`frontend.md`](frontend.md): extension bootstrap, UI ownership, localization,
|
||||
media lifecycle, panel state, and graph mutations.
|
||||
- [`recipes.md`](recipes.md): Workflow Recipes, packages, galleries, Parameter
|
||||
Notebooks, prompt roles, and recipe-to-model transitions.
|
||||
- [`material-library.md`](material-library.md): generated-image snapshots,
|
||||
material assets, node-block reuse, and Model Doctor handoff.
|
||||
- [`recipe-model-lifecycle.md`](recipe-model-lifecycle.md): authoritative model
|
||||
fields, Hash synchronization, local matching, partial append, and note privacy.
|
||||
- [`model-resolution.md`](model-resolution.md): model provenance, Model Doctor,
|
||||
hash injection, deep scanning, and recovery policy.
|
||||
- [`../decisions/README.md`](../decisions/README.md): concise records of product
|
||||
and architectural decisions that still explain current behavior.
|
||||
|
||||
These documents describe current contracts rather than implementation history.
|
||||
For exact changes, use Git history. For user-visible releases, use
|
||||
`CHANGELOG.md`. For recurring implementation traps, use
|
||||
`.agents/logs/ai_lessons.md`.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Backend Architecture
|
||||
|
||||
Read this document for changes to `api/`, `scraper.py`, model files and sidecars,
|
||||
recipe persistence, or filesystem-facing routes.
|
||||
|
||||
## Runtime and route boundary
|
||||
|
||||
`__init__.py` exposes the frontend directory and imports the modular route
|
||||
package. `api/__init__.py` registers `aiohttp` routes, all prefixed with
|
||||
`/anomalous/`. Changes to Python modules require a full ComfyUI restart; a
|
||||
frontend reload alone does not replace registered handlers or module state.
|
||||
Route registration uses explicit module references. `tests/test_route_manifest.py`
|
||||
locks the HTTP method/path pairs so internal ownership changes cannot silently
|
||||
drop, duplicate, or rename an endpoint.
|
||||
|
||||
The backend owns filesystem authority. A browser-supplied path, filename,
|
||||
category, output reference, recipe asset, or archive member is untrusted until
|
||||
validated. Use the shared helpers in `api/path_utils.py` (also re-exported by the
|
||||
small `api/utils.py` compatibility facade):
|
||||
|
||||
- `resolve_folder_subdir()` for configured model-folder boundaries;
|
||||
- `resolve_within()` for containment below an owned root;
|
||||
- `require_filename()` for single-file identifiers.
|
||||
|
||||
Never rely only on `..` rejection. Absolute Windows paths, UNC paths, alternate
|
||||
separators, device paths, and symlinks can bypass naïve string checks. Media
|
||||
routes also enforce an explicit extension allowlist.
|
||||
|
||||
Configured model scope belongs to `api.folder_types`. Category mode uses
|
||||
`get_active_folder_types()`; folder-manager-facing library enumeration uses
|
||||
`get_active_model_roots()` so physical mode can retain the exact registered
|
||||
`type`, `path_idx`, and real root for every enabled directory. Real roots are
|
||||
deduplicated without merging same-named files from different roots. Do not
|
||||
hardcode a walk across `checkpoints`, `loras`, or another category. A directory
|
||||
disabled in `config.json` must cause no walk or metadata I/O.
|
||||
|
||||
`/anomalous/all_scan_models` follows that current folder-manager mode and keeps
|
||||
its paginated response plus `limit=0` full-list behavior. Its local source-link
|
||||
inventory recognizes GGUF and PTH case-insensitively in addition to the existing
|
||||
list formats. This route-level inventory does not expand global model-format,
|
||||
scanner, metadata-parser, recovery, or cloud-identification support.
|
||||
|
||||
## Storage ownership
|
||||
|
||||
Runtime UI settings and newly stored API keys live in `api/config.json`.
|
||||
`scraper.py` reads it first and may fall back to the legacy root `config.json`.
|
||||
Secrets are never persisted to browser `localStorage`.
|
||||
|
||||
Workflow Recipes live below the active ComfyUI user directory in
|
||||
`workflows/anomalous_recipes`; Parameter Notebooks live in
|
||||
`workflows/anomalous_parameters`. They are user data, not repository assets.
|
||||
Writes validate their bounded schema and use atomic replacement.
|
||||
|
||||
Backend modules follow route/domain/storage ownership. Model discovery,
|
||||
identity recovery, metadata mutation, and cover media live in
|
||||
`model_catalog.py`, `model_resolution.py`, `model_metadata.py`, and
|
||||
`model_media.py`. Recipe graph rules, recipe shaping, image processing, and
|
||||
persistence/history live in `workflow_schema.py`, `recipe_schema.py`,
|
||||
`recipe_images.py`, and `recipe_store.py`; `recipes.py` is the HTTP facade.
|
||||
Material shaping, private assets, and the single persistence lock/summary cache
|
||||
live in `material_schema.py`, `material_assets.py`, and `material_store.py`;
|
||||
`materials.py` owns request/response mapping and compatibility entry points.
|
||||
|
||||
The former mixed utility routes are separated: `media_routes.py` owns card
|
||||
thumbnails and model/output media lookups, `gallery_routes.py` owns the bounded
|
||||
output snapshot and deletion (searched through `image_search.py`, which reads
|
||||
PNG text chunks only and caches per-image records by mtime), `translation_routes.py`
|
||||
owns provider fallback, and `folder_types.py` owns configured visibility and scan scope.
|
||||
|
||||
`version_manager.py` runs git against the plugin checkout only (no shell, no
|
||||
prompts, `CREATE_NO_WINDOW` on Windows) and accepts only tags that appear in the
|
||||
published release list. It refuses switches while tracked files are modified,
|
||||
never rewrites a local branch that has diverged from the remote, and reports
|
||||
stable error codes (`dirty`, `diverged`, `offline`, `unknown_tag`, ...) that the UI
|
||||
localizes. Switching changes code only; a ComfyUI restart applies it.
|
||||
|
||||
Offline inference sidecars use non-positive Civitai IDs as sentinels. Metadata
|
||||
normalization must not expose those values as release-page URLs or resolved
|
||||
model/version identities; only positive IDs may form a Civitai source link.
|
||||
|
||||
Prompt Notes use `workflows/anomalous_notebooks`. First access copies legacy
|
||||
`api/notebooks` records without deleting originals or overwriting current notes.
|
||||
Conflicts receive a deterministic recovered filename. A completion marker makes
|
||||
the copy retryable after write failure and prevents deleted notes reappearing.
|
||||
Invalid legacy records remain untouched and are listed in the migration marker.
|
||||
Notebook I/O runs in a worker under its persistence lock; the UI queues snapshots
|
||||
and reports failed saves instead of showing success. `api.path_utils.atomic_write_json`
|
||||
owns bounded, flushed temporary writes followed by atomic file replacement.
|
||||
|
||||
The recipe card endpoint returns lightweight metadata. Full graphs and history
|
||||
are fetched only for detail, edit, compare, restore, export, or another operation
|
||||
that needs them. Successful save, update, and restore responses include a compact
|
||||
integrity receipt containing graph counts and the persisted workflow fingerprint.
|
||||
|
||||
Package import is inspect-then-commit. `recipe_packages.py` accepts only bounded
|
||||
ZIP packages with a manifest, recipe JSON, declared contained WebP assets, and
|
||||
optional bounded history. It rejects traversal, symlinks, undeclared members,
|
||||
archive bombs, and checksum failures; stages all content before the final rename;
|
||||
and restores the previous recipe set if replacement commit fails. Packages never
|
||||
install code or dependencies.
|
||||
|
||||
## Event-loop and scan-state rules
|
||||
|
||||
Potentially large disk work does not run on the `aiohttp` event loop. Recursive
|
||||
walks, bulk metadata parsing, full-file hashing, and similar operations use
|
||||
`asyncio.to_thread()` or the established worker process/thread path. Reading a
|
||||
small bounded safetensors header is acceptable; reading a whole multi-gigabyte
|
||||
model to discover metadata is not.
|
||||
|
||||
Background work claims state before launching. Folder scans use
|
||||
`.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep
|
||||
missing-model scans use `GLOBAL_SCAN_STATE` and the corresponding status route.
|
||||
Marker files are versioned JSON records containing backend session, owner PID,
|
||||
worker PID, and job ID. Status checks validate ownership and process liveness;
|
||||
file existence alone is not proof that a scan is active.
|
||||
|
||||
A marker owned by a dead process, or a legacy plain-text marker, represents an
|
||||
interrupted job. Its marker, progress file, and pending selection may be removed
|
||||
so a restart does not permanently lock scanning. A still-running orphan worker
|
||||
remains locked until that recorded process exits.
|
||||
|
||||
`scraper.py --progress-file` atomically publishes enumeration state, selected
|
||||
file count, current index, and filename. Status responses merge worker progress
|
||||
with parent-owned folder progress. The frontend can reconstruct this state by
|
||||
polling after its UI has been reopened.
|
||||
|
||||
## Metadata and cache behavior
|
||||
|
||||
The output gallery keeps one ordered directory snapshot for at most ten seconds
|
||||
and 50,000 images. Page requests reuse it; explicit refresh and image deletion
|
||||
invalidate it immediately. Larger inventories are returned normally but not cached.
|
||||
|
||||
Metadata and embedded safetensors-header hashes may be cached only in a bounded
|
||||
cache. The key includes the model's real path and the physical `size`, `mtime_ns`,
|
||||
and `ctime_ns` signatures of the model and relevant sidecars. Return independent
|
||||
copies; never expose cached mutable dictionaries or lists.
|
||||
|
||||
Folder listing inventories a directory once with `os.scandir()` and preserves
|
||||
preview priority: `.preview.*` before bare same-stem media. Avoid an `exists()`
|
||||
sequence for every candidate when the directory inventory already has the
|
||||
answer.
|
||||
|
||||
Preview URLs use the selected preview file's stable nanosecond modification time
|
||||
as the cache version. Do not append `Date.now()` or random request tokens during
|
||||
ordinary listing. A changed file must change its URL; an unchanged file should
|
||||
remain browser-cacheable.
|
||||
|
||||
Preview lookup tries a contained exact relative path first and recursively walks
|
||||
the library only for unresolved basename fallbacks. This locates presentation
|
||||
for a model value already supplied by ComfyUI; it is not Model Doctor discovery.
|
||||
|
||||
Balanced grid thumbnails are derived, longest-edge 512 px WebP files in
|
||||
ComfyUI's temporary area. Their cache is keyed by source real path and physical
|
||||
signature and capped at 256 MiB with oldest-accessed eviction. The original mode
|
||||
serves source covers, and detail views always use originals. Derived media never
|
||||
modifies or sits beside a user's cover; unsupported, animated, or failed inputs
|
||||
fall back to the original.
|
||||
|
||||
## Model sidecar and cover lifecycle
|
||||
|
||||
Sidecar handling is non-negotiable because it can destroy user metadata or
|
||||
media.
|
||||
|
||||
- `.civitai_bak.*` is a persistent restore source created from a real Civitai
|
||||
download. Setting a custom cover changes only `.preview.*`.
|
||||
- Cover reset first restores `.civitai_bak.*` to `.preview.*`. Otherwise it may
|
||||
remove `.preview.*` only when a bare original cover can take over. If the
|
||||
preview is the only image, preserve it and return a visible warning. Reset
|
||||
never silently downloads from the network.
|
||||
- Physical rename moves every recognized sidecar to the new model stem. Model
|
||||
deletion may clean sidecars only after the main model is successfully deleted.
|
||||
- Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
|
||||
`clip_vision`—are never physical-rename targets. UI and all backend entry
|
||||
points enforce the same denial.
|
||||
- Main model extensions (`.safetensors`, `.ckpt`, `.pt`, `.bin`) are never
|
||||
sidecar suffixes. Cleanup must not delete a same-stem model with another
|
||||
extension. If such a sibling remains, ambiguous stem-keyed sidecars remain.
|
||||
- Rename/delete/reset uses centralized immutable suffix tuples and a constant
|
||||
number of exact-path checks. Do not walk, glob, index, hash, or call the
|
||||
network for these operations.
|
||||
|
||||
Keep product wording precise: deletion cleans sidecars; rename migrates them.
|
||||
|
||||
## Backend implementation rules
|
||||
|
||||
- Prefer standard-library and existing ComfyUI facilities; missing optional
|
||||
dependencies must fail as a localized capability, not break plugin import.
|
||||
- Use `os.path`, `pathlib`, `os.sep`, or normalization helpers for paths. Avoid
|
||||
hand-written backslash replacement.
|
||||
- Update an existing `.civitai.info` dictionary rather than overwriting it with
|
||||
a one-field object.
|
||||
- Large input collections use a JSON `POST` body. Do not place them in a query
|
||||
string or pass them as command-line arguments on Windows.
|
||||
- The private local `tests/` directory is ignored and is not imported by runtime
|
||||
code or shipped in the installable plugin.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Frontend Architecture
|
||||
|
||||
Read this document for changes under `web/`, browser entry modes, panel state,
|
||||
localization, media, or explicit canvas mutations.
|
||||
|
||||
## Bootstrap and module ownership
|
||||
|
||||
The verified AMB0/AMB1 workflow share-code Import / Export Center is available
|
||||
from Toolbox through `window.AMB_WorkflowShare.showUnifiedModal()`. Both directions
|
||||
are enabled by explicit product decision. It is independent of the paused Recipe
|
||||
package import/export and closed Material Library file import. Image/workflow hash
|
||||
injection, host saving, and ordinary image downloads remain unchanged.
|
||||
|
||||
ComfyUI loads JavaScript in the extension `WEB_DIRECTORY` as ES modules.
|
||||
`web/main.js` registers `Anomalous.ModelBrowser` and coordinates host hooks.
|
||||
`browser.js` defines the browser class and binds extracted feature methods;
|
||||
`browser_entry.js` owns the single browser instance and all entry presentation;
|
||||
`interface_settings.js` owns locale and theme preferences. A syntax error or duplicate
|
||||
top-level declaration in any imported module can prevent registration and make
|
||||
the entire entry disappear. For affected modules, validate syntax and module linking
|
||||
via Node's experimental VM modules (`vm.SourceTextModule`) and verify
|
||||
the real ComfyUI runtime creates the configured entry. Floating trigger styling in
|
||||
the ordered stylesheet bundle rooted at `web/styles.css` uses dynamic `1em` SVG
|
||||
scaling and flex centering to guarantee
|
||||
consistent visual presentation across all configured trigger sizes.
|
||||
|
||||
Major UI panels live in `web/modules/ui_*.js`. Shared browser state remains on
|
||||
the `AnomalousBrowser` instance. Pure parsing, normalization, comparison, and
|
||||
transaction helpers remain in focused modules rather than acquiring DOM state.
|
||||
|
||||
`ui_sidebar.js` assembles the browser shell and folder navigation.
|
||||
`ui_settings_hub.js` owns settings and model-card preferences, while
|
||||
`ui_toolbox.js` owns the catalog, fixed shortcut actions, and tool dispatch.
|
||||
The Material Library shortcut remains a native ComfyUI command/keybinding; a
|
||||
deferred window-key fallback invokes the same command path only when the host
|
||||
did not bring the library forward, so handled shortcuts are not executed twice.
|
||||
`ui_browser_navigation.js` owns shared panel hiding, recoverable detail cleanup,
|
||||
and workspace return. `ui_scan_wizard.js` owns scan configuration and scan-launch polling,
|
||||
`ui_folder_manager.js` owns folder visibility/order and presentation-mode
|
||||
changes, and `ui_help.js` owns the help dialog. Public entry functions remain
|
||||
browser-instance methods so existing actions share current browser state.
|
||||
|
||||
Update-guide content and UI lifecycle are separate modules. The header and Help
|
||||
open it only on explicit user action; browser close disposes it. Read
|
||||
[update-guide.md](update-guide.md) before changing guide IDs, steps, persistence,
|
||||
or sidebar icon/label transitions. Do not embed guide logic in the main/sidebar
|
||||
bootstrap or reuse its content version as a feature flag.
|
||||
|
||||
The main surfaces are:
|
||||
|
||||
- Sidebar shell: navigation and persistent scan controls; focused child modules
|
||||
own the scan wizard, folder manager, and help dialog.
|
||||
- Grid and model detail: model browsing plus coordinated detail display in
|
||||
`ui_detail.js`, metadata editing in `ui_model_editor.js`, and advanced selection
|
||||
in `ui_model_selector.js`.
|
||||
- Gallery: generated outputs and the full-screen Image Detail Studio Workbench.
|
||||
`ui_gallery_detail.js` owns its singleton lifecycle and cache, `ui_image_stage.js`
|
||||
owns header/zoom/filmstrip interaction, and `ui_image_inspector.js` owns metadata tabs.
|
||||
- Workspace: Prompt Notes, Workflow Recipes, and the Material Library. Prompt Note
|
||||
catalog/persistence, editing, and canvas creation are separated across
|
||||
`ui_notebooks.js`, `ui_notebook_editor.js`, and `notebook_canvas.js`. Material
|
||||
discovery/pagination, cards, detail, and node application are separated across
|
||||
`ui_materials.js`, `ui_material_cards.js`, `ui_material_detail.js`, and
|
||||
`ui_material_application.js`.
|
||||
- Node Assistant/Model Doctor: selected-node actions, diagnostics, parameter
|
||||
presets, and missing-model recovery. `ui_doctor.js` coordinates diagnosis and
|
||||
global scans, `ui_node_assistant.js` owns assistant history, `ui_node_model_picker.js`
|
||||
owns the native-widget model replacer, and `ui_node_presets.js` owns parameter
|
||||
preset previews and application.
|
||||
|
||||
`ui_dom.js` owns generic `text` and `jsonResponse` helpers.
|
||||
`material_inspector.js` shares image metadata helpers and node-parameter rendering
|
||||
between the material and image-inspector modules. The workbench does not import
|
||||
the library UI, keeping this dependency chain acyclic. Library discovery uses
|
||||
server-side filters and pages with cancellable requests. Detail entry fetches
|
||||
metadata and scoped node blocks; the complete workflow loads only on the explicit
|
||||
open action. See `material-library.md` for the API and persistence contract.
|
||||
|
||||
## Entry modes and host integration
|
||||
|
||||
One shared `openBrowser()` command backs the floating trigger, ComfyUI action-bar
|
||||
button, and `Extensions -> Anomalous Model Browser` command. Entry presentations
|
||||
are mutually exclusive and reuse the same browser instance. The Extensions
|
||||
command remains available as a recovery path in every mode.
|
||||
|
||||
Native commands own the default `Ctrl + Shift + M` browser binding and the
|
||||
`Ctrl + Shift + L` Material Library binding. Shortcut customization delegates
|
||||
to ComfyUI's command/keybinding panel and recorder; the plugin does not install
|
||||
a parallel global keyboard listener or maintain a second shortcut preference.
|
||||
|
||||
Language changes replace the registered Interface setting descriptors with
|
||||
freshly translated copies. ComfyUI's reactive settings tree therefore updates
|
||||
the open category heading, labels, tooltips, custom controls, and combo options
|
||||
immediately without requiring a page refresh.
|
||||
|
||||
Hidden reusable dialogs must not advertise an active modal state. In particular,
|
||||
`aria-modal` is `true` only while the dialog is visible, so ComfyUI's global
|
||||
modal guard does not suppress unrelated host shortcuts.
|
||||
|
||||
## Panel and lifecycle state
|
||||
|
||||
Main content surfaces are exclusive. Opening one must hide or clean the surface
|
||||
it replaces, while preserving only the state required for an explicit return
|
||||
path. Search from the sidebar exits model detail and shows the grid. Workspace
|
||||
records the previously visible main-browser panel and restores it on close,
|
||||
falling back to the grid when the prior state is unavailable.
|
||||
|
||||
Recipe-to-model navigation is a special recoverable transition described in
|
||||
`recipes.md`. It keeps the outer browser open and must not call the browser-wide
|
||||
`close()` lifecycle.
|
||||
|
||||
Grid folder navigation removes old content rather than accumulating hidden
|
||||
grids. Before removing DOM that contains video or audio, pause the media and
|
||||
release sources where applicable. Closing the browser aborts active listing,
|
||||
disconnects observers, pauses media, and releases grid video/audio sources.
|
||||
Lightweight cards may remain warm for 90 seconds, after which card DOM and model
|
||||
payloads are released. A new listing cancels the previous request and render
|
||||
generation.
|
||||
|
||||
The full ordered model result remains available, but cards are created in
|
||||
bounded animation-frame chunks. Images use lazy loading and async decoding.
|
||||
Grid video sources are attached only near the viewport and then follow the
|
||||
configured `always` or `hover` behavior with muted, loop, and plays-inline
|
||||
attributes.
|
||||
|
||||
When identifying video media from a served URL, inspect the full URL with a
|
||||
query-aware extension test such as `/\.(mp4|webm)(?:$|\?|&|#)/i`. Do not use
|
||||
`new URL(url).pathname`, because the backend may put the actual filename in a
|
||||
query parameter.
|
||||
|
||||
## Model source component links
|
||||
|
||||
The source hub uses `inferModelFolderTypes` for workflow component labels and
|
||||
category-scoped, exact-path local metadata reads. Context requests are batched
|
||||
to respect the endpoint's 16-item limit. Missing source links and unavailable
|
||||
native model choices are independent states; dynamic choices are not treated
|
||||
as evidence of absence. Native extensionless component choices and PTH/GGUF
|
||||
references remain visible even when the backend cannot locate an individual file.
|
||||
|
||||
`model_source_data.js` owns library-result shaping and the source hub's pure
|
||||
main/component grouping and filtering. CLIP, text encoder, CLIP Vision, VAE, and
|
||||
preview-VAE entries live in a session-local, default-collapsed disclosure; the
|
||||
source-status pills and search affect both main models and components. Collapsed
|
||||
component rows are not constructed. Main-model completion statistics exclude
|
||||
optional component links, while the disclosure's missing-local count describes
|
||||
only the currently filtered component matches.
|
||||
Legacy Civitai placeholder links with non-positive model IDs are normalized to
|
||||
an unfilled source before grouping, so offline inference records remain visible
|
||||
under the source-needed filter.
|
||||
|
||||
Workflow save and Note generation always receive the complete workflow model
|
||||
set; summary copy receives only the visible rows. Such links may be saved to the
|
||||
workflow, but component sidecar writes require a resolved local target. Opening,
|
||||
filtering, and expanding the hub do not initiate cloud scans, hash calculation,
|
||||
or sidecar writes, and do not change graph/model identity recovery rules.
|
||||
|
||||
## Localization and DOM safety
|
||||
|
||||
`web/modules/locales.js` is the canonical catalog for user-visible runtime
|
||||
strings. Resolve strings at render time through the shared translator. Chinese
|
||||
and English dictionaries keep identical key sets, have no duplicate keys, and
|
||||
cover all statically referenced translation keys.
|
||||
|
||||
Dynamic filenames, paths, model names, node types, counts, and user-authored
|
||||
content remain parameters rather than dictionary entries. Insert untrusted text
|
||||
with `textContent` or escaping. Only fields intentionally supporting trusted
|
||||
formatting may pass through `setSafeRichHtml()` in `safe_dom.js`; never assign
|
||||
metadata directly to `innerHTML`.
|
||||
|
||||
Persistent localized nodes that survive a language change carry an i18n key and
|
||||
are refreshed in place or rebuilt from current state. Missing translation data
|
||||
must not prevent a panel or action from rendering.
|
||||
|
||||
Scope plugin DOM IDs and CSS classes with `anomalous-`. Before introducing a
|
||||
dynamic ID, search `styles.css` for collisions. Follow the established z-index
|
||||
tiers and ensure a child modal sits above the parent that would otherwise consume
|
||||
its clicks.
|
||||
|
||||
## Graph and widget mutation rules
|
||||
|
||||
Explicit graph mutations are transactional. Validate the entire intended change
|
||||
before mutation, wrap it in `graph.beforeChange()` / `graph.afterChange()`, and
|
||||
restore prior nodes, links, and widget values if any operation or callback fails.
|
||||
A successful change marks graph and canvas dirty and emits the host change event
|
||||
expected by dependent surfaces.
|
||||
|
||||
Partial Recipe Append clones serialized nodes, remaps IDs and links, supports
|
||||
groups, and rolls back everything it created on failure. It never calls
|
||||
`loadGraphData` on the live graph and rejects unsupported subgraph definitions
|
||||
until they have a complete remapping path. Complete recipes use
|
||||
`loadGraphData` intentionally so ComfyUI owns new-workflow canvas creation.
|
||||
|
||||
`graph_splice.js` handles deliberate MODEL/CLIP insertion. It analyzes declared
|
||||
port types rather than slot indexes or display names. Ambiguous downstream
|
||||
fan-out is rejected until a user can choose a branch. Picker candidates come
|
||||
from the target widget's native combo values; metadata may filter or decorate
|
||||
that set but may not introduce foreign category values.
|
||||
|
||||
Parameter application resolves a serialized match back to a live ComfyUI node
|
||||
before accessing widgets or callbacks. It validates all target slots first,
|
||||
skips volatile values, and calls the host's widget hooks with the current
|
||||
four-argument contract. Serialized workflow records are never treated as live
|
||||
widget objects.
|
||||
|
||||
## Optional capabilities
|
||||
|
||||
`web/hash_resolver.js` may hook a compatible graph serializer to carry model
|
||||
provenance. If the host API is unavailable, it disables only that integration
|
||||
with a useful warning. It must not prevent `main.js` from registering or remove
|
||||
the visible browser entry.
|
||||
|
||||
Network-backed enrichment is explicit and recoverable. An unavailable Civitai
|
||||
or translation service may produce a local error state; it must not block local
|
||||
browsing, editing, or already stored data.
|
||||
|
||||
## Prompt Studio ownership
|
||||
|
||||
The studio supports copying prompt text and saving combinations to the local
|
||||
Material Library. Standalone prompt-plan JSON export has been removed and the
|
||||
Material Library file-import entry is closed.
|
||||
|
||||
Source cards automatically refresh on studio open, browser focus/visibility, and
|
||||
every 30 seconds while the page is visible (scheduled after the previous request).
|
||||
Saving a studio combination also refreshes immediately. `sourceKind: 'material'`
|
||||
cards are reconciled by filename and role from a complete paginated snapshot;
|
||||
renames/content edits replace them and deleted sources disappear. Failed or
|
||||
cancelled reads retain existing cards. Equal text from different sources retains
|
||||
each source identity. Built-in and unsaved local cards remain independent.
|
||||
Library cards show their origin and have no delete action in the studio. Mixer
|
||||
blocks are editable copies; refreshing sources must not modify existing drafts.
|
||||
The deck owns its refresh timer, listeners and AbortController and releases all
|
||||
of them when its view closes. New-card role is supplied by the workbench callback.
|
||||
|
||||
The studio has one standalone drawer; the former embedded side/full composer
|
||||
and material import drawer are removed. Existing browser integration continues
|
||||
to call `openPromptStudio(owner)`, and external prompt dispatch uses
|
||||
`appendPromptToStudio(owner, text, isPositive, title)`.
|
||||
|
||||
| Module | Owned state and responsibilities |
|
||||
| --- | --- |
|
||||
| `ui_prompt_composer.js` | Active drawer, docking width/side, trigger visibility and plan-loading request |
|
||||
| `ui_prompt_workbench.js` | Owner-backed draft, active role, block editing, ordering, write/copy/save |
|
||||
| `ui_prompt_source_deck.js` | Source cards, filters, new-card form, preview popover and current sync request |
|
||||
| `ui_prompt_inspector.js` | Full-text inspection window, role tab and its translation lifetime |
|
||||
| `prompt_studio_data.js` | Starter cards, display categories, draft/block initialization and synthesis |
|
||||
| `prompt_composition.js` | Pure plan conversion, sorting and composition; no DOM ownership |
|
||||
| `prompt_material_source.js` | All-page prompt discovery, detail loading and reconciliation by source identity |
|
||||
| `ui_lifecycle.js` | View-scoped listeners, AbortSignal, cleanup callbacks and resizing |
|
||||
|
||||
Child views receive their container and narrow callbacks. The workbench keeps
|
||||
`owner.promptPlanDraft` for reopening and exposes only `updateAll` / `addBlock`
|
||||
through `owner.sidePromptComposerControl`. The source deck keeps its state local
|
||||
and returns extraction/refresh/sync actions instead of assigning new owner fields.
|
||||
|
||||
Draft `plan.parts` is the authoritative editable representation. Full-text
|
||||
inspector edits merge the enabled blocks in the edited role into one block,
|
||||
preserving disabled blocks and the opposite role. The inspector explains this
|
||||
behavior before editing. Copy, save and reopen derive text from the same parts.
|
||||
|
||||
Every close route (button, Escape, backdrop, replacement and parent close) must
|
||||
dispose the same view scope. Parent close also closes its inspector. An Escape
|
||||
handled by an inspector must not close its parent in the same event dispatch.
|
||||
Closing during resize releases move/up listeners and restores body cursor and
|
||||
selection styles. UI reads are cancelled on close; a save already sent to the
|
||||
server may still complete, but must not reopen or repaint a disposed view.
|
||||
|
||||
Initial and automatic source synchronization share a paginated loader. List
|
||||
summaries are identifiers, not prompt bodies: each unique filename is resolved
|
||||
through `material_prompt_data.js`. Reconciliation uses source filename and role,
|
||||
so equal text from different materials retains each origin. A newer sync cancels the old
|
||||
one and only the current result updates the deck.
|
||||
|
||||
The translator captures graph, node, widget and widget value before an async
|
||||
write. It revalidates that destination and the input after translation; close,
|
||||
selection changes and intervening widget edits invalidate the pending write.
|
||||
Explicit English translation applies to all input languages, including kana
|
||||
and Korean. Transport errors and rejected bridge responses remain local errors;
|
||||
provider-specific validation belongs to the backend translation route.
|
||||
|
||||
## Visual styling and theme architecture
|
||||
|
||||
`styles.css` is the single external entry and ordered import manifest for current
|
||||
visual values. Its `web/styles/00-*.css` through `10-*.css` children preserve the
|
||||
original cascade order; every child import carries the same cache version so an
|
||||
entry-cache hit cannot leave stale child rules. `tests/css_bundle_order.mjs`
|
||||
guards the unique ordered list and byte-for-byte reconstructed bundle. Shared `--amb-*`
|
||||
tokens express surfaces, text, borders and control shapes; theme overrides must
|
||||
be scoped to `.theme-abyssal-scarlet` rather than changing unrelated surfaces.
|
||||
|
||||
Studio drawer rules keep the source deck at the screen edge and the assembly
|
||||
track next to the canvas. Common geometry is shared between dock directions;
|
||||
direction-specific rules set column order and separators. The removed embedded
|
||||
composer's `#anomalous-container.anomalous-docked` overrides must not return.
|
||||
Before adding an override or `!important`, locate and edit the owning rule.
|
||||
Older component and theme overrides elsewhere in the ordered bundle still need a
|
||||
separate, visually verified consolidation.
|
||||
|
||||
## Verification
|
||||
|
||||
Use `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` for actual
|
||||
module open/close, nested Escape, resize cleanup, block insertion and delayed
|
||||
node writes. `tests/prompt_material_source.mjs` covers pagination, deduplication
|
||||
and cancellation; `tests/translation_service_contracts.mjs` covers the HTTP
|
||||
bridge. The UI fixture simulates DOM and ComfyUI APIs and does not replace
|
||||
checking the real host, layout, focus, drag gestures and theme rendering.
|
||||
@@ -0,0 +1,333 @@
|
||||
# Material Library
|
||||
|
||||
The Material Library is a user-curated reuse layer. It does not replace
|
||||
Workflow Recipes, Prompt Notes, Node Assistant, or Model Doctor. The initial
|
||||
material kind is an image workflow snapshot captured from a generated PNG that
|
||||
contains a complete ComfyUI UI workflow.
|
||||
|
||||
## Persistence and API ownership
|
||||
|
||||
`api/material_schema.py` owns record shaping, prompt-role rules and bounded
|
||||
normalization; `api/material_assets.py` owns source inspection and private image
|
||||
copies; `api/material_store.py` is the sole owner of persistence locking, the
|
||||
summary cache, query/update/delete, and recipe-source resolution. `api/materials.py`
|
||||
owns HTTP request/response mapping and narrow compatibility entry points. Records live under
|
||||
`user/<profile>/workflows/anomalous_materials`; source PNG copies and bounded
|
||||
WebP previews live in a private `.assets/<material-stem>/` directory.
|
||||
|
||||
The backend accepts output-image descriptors only after applying the shared
|
||||
filename and containment checks. It reads bounded embedded metadata, requires a
|
||||
valid UI workflow, copies at most 64 MiB of source image data, validates the
|
||||
graph with the Recipe workflow validator, and writes JSON atomically. List
|
||||
responses omit the full workflow. The explicit image-inspection route returns
|
||||
the validated workflow plus summary node blocks only after the user opens one
|
||||
image detail. This lets the workbench display exact `widgets_values`, bounded
|
||||
node `properties`, mode, and volatile widget indexes without allocating a
|
||||
second full-PNG buffer in the browser. Direct PNG parsing remains a temporary
|
||||
compatibility fallback for an older running backend. Exact metadata is kept in
|
||||
a small 16-entry in-memory LRU, and duplicate workflow/widget fields are
|
||||
discarded before caching. Local-model preview resolution is deferred until the
|
||||
Models tab is opened and then cached with that image's metadata. Asset reads
|
||||
require both a valid material record and a contained private asset path.
|
||||
|
||||
Saving stages the image, preview, and JSON together below a private temporary
|
||||
directory. Assets are promoted before the record becomes visible. A failed final
|
||||
record commit removes only the newly promoted assets; staging is cleaned on exit.
|
||||
Existing records and their images are never overwritten by this path.
|
||||
|
||||
Material discovery caches up to 4,096 summaries, keyed by the contained record's
|
||||
real path, size, mtime, and ctime. It never caches full workflows. A directory
|
||||
inventory detects additions/deletions and re-parses only changed records. Node-type
|
||||
lookup filters summaries before opening matching workflows; asset authorization
|
||||
uses the same validated summary cache. Callers receive independent summary copies.
|
||||
|
||||
The library requests 48 summaries per page. `materials` accepts `q` (name, tags,
|
||||
or node type), `tag`, `kind`, `category`, exact `node_type`, `page`, and `limit` (at most 100), and returns
|
||||
`total`, `page`, `pages`, and the library's available `tags`. Older callers without
|
||||
page/limit retain their complete summary response. Name/tag edits use
|
||||
`update_material`, preserve the source workflow, and atomically replace the record.
|
||||
Tags are trimmed, deduplicated without case sensitivity, and limited to 20 tags
|
||||
of 60 characters each; older records without tags remain valid.
|
||||
|
||||
`category` groups sources before pagination: `workflow` contains full snapshots;
|
||||
`prompts` contains note/text/plan kinds and selections consisting only of prompt
|
||||
nodes; `params` contains other node/recipe selections. `all` is the default.
|
||||
Exact `kind` remains compatible. `material_prompt_data.js` reads authoritative
|
||||
detail fields for the studio: notes use `note.promptEn`, plans compose saved
|
||||
parts, and workflow selections use top-level `prompt_groups`. The import drawer
|
||||
pages summaries and fetches prompt text only on inspection; search covers names,
|
||||
tags and node types. Side/full editors have separate view references and share
|
||||
the active draft; closing or resetting their importer cancels pending requests.
|
||||
|
||||
Save, edit, and delete serialize their writes within the server process. Before
|
||||
publishing a new record, save compares the source PNG SHA-256, material kind, and
|
||||
selected node IDs against existing summaries. A duplicate returns HTTP 409 with
|
||||
`status: duplicate` and the existing name/filename, without leaving new assets.
|
||||
An explicit retry with `allow_duplicate: true` creates a separate copy. This is
|
||||
exact-source duplicate detection, not visual similarity or model identity.
|
||||
|
||||
The route family is:
|
||||
|
||||
- `POST /anomalous/inspect_image_material`
|
||||
- `POST /anomalous/save_image_material`
|
||||
- `POST /anomalous/save_parameter_material`
|
||||
- `POST /anomalous/save_prompt_note_material`
|
||||
- `POST /anomalous/save_prompt_plan`
|
||||
- `GET /anomalous/materials`
|
||||
- `GET /anomalous/material_full`
|
||||
- `GET /anomalous/material_asset`
|
||||
- `GET /anomalous/materials/by_node_type`
|
||||
- `POST /anomalous/update_material`
|
||||
- `POST /anomalous/delete_material`
|
||||
|
||||
## Frontend ownership
|
||||
|
||||
`ui_materials.js` owns Workspace discovery, filters, pagination and CRUD
|
||||
coordination; `ui_material_cards.js` owns summary cards; `ui_material_detail.js`
|
||||
owns names/tags and full-workflow detail/handoff; `ui_material_application.js`
|
||||
owns selected-node tracking and explicit application. `ui_gallery_detail.js`
|
||||
owns the single image-workbench lifecycle/cache, while `ui_image_stage.js` owns
|
||||
media/zoom/filmstrip interaction and `ui_image_inspector.js` owns image/node
|
||||
inspection and saving. These views use `material_inspector.js` for shared metadata helpers and exact node
|
||||
parameter rendering; the workbench does not import the library UI.
|
||||
`ui_gallery.js` and
|
||||
`ui_recipe_detail.js` only supply non-invasive gallery entry points. Main
|
||||
Gallery image clicks open the focused pan/zoom viewer, while the dedicated
|
||||
parameter action opens the Image Detail Studio. Drag, delete, and cover-selection
|
||||
behavior remains independent of those two entry points.
|
||||
`ui_node_presets.js` presents Node Assistant presets; it shares node application
|
||||
with the library through `ui_material_application.js` and `node_material_actions.js`.
|
||||
|
||||
`ui_recipe_detail.js` can publish the active Recipe parameters or the selected
|
||||
Parameter Notebook as `recipe_parameter_selection`. The primary panel saves all
|
||||
reusable widget-bearing nodes; the raw-node inspector supports direct single-node
|
||||
save and explicit multi-selection. Prompt cards expose the same direct save.
|
||||
The backend reloads the named Recipe/Parameter Notebook from its contained user
|
||||
directory instead of trusting a browser-supplied workflow.
|
||||
|
||||
Material cards remain compact, summary-only discovery items. “View Details”
|
||||
switches the library itself to a master-detail inspector: a contained reference
|
||||
image stays on the left, while scope, model references, and reusable node blocks
|
||||
are grouped on the right; exact widget values remain nested under each node.
|
||||
Only the opened material fetches `material_full?include_workflow=0`: metadata and
|
||||
scoped node blocks, with no complete source workflow. Only prompt cards initially
|
||||
expand; other node cards build their parameter DOM on first expansion and reuse
|
||||
it on later toggles. Expand/collapse-all follows the actual card state. Selectable
|
||||
cards in the image workbench remain closed initially.
|
||||
Returning to the list aborts
|
||||
an unfinished request and releases the detail payload/DOM so browsing never
|
||||
accumulates full workflows in browser memory.
|
||||
|
||||
Opening a complete snapshot explicitly requests `include_workflow=1`, which
|
||||
returns the original workflow and an empty `node_blocks` array to avoid duplicate
|
||||
widget payloads. The original seed and hash evidence remain intact. The server
|
||||
reads, shapes, and serializes these responses in a worker thread. Selected-node
|
||||
records filter before copying widget values; they never return a full workflow
|
||||
and reject explicit workflow requests with HTTP 403. For legacy callers, omitting
|
||||
the flag retains both workflow and node blocks for complete, openable snapshots.
|
||||
Only `0`, `1`, or an omitted flag are accepted.
|
||||
|
||||
Search is debounced and each list request cancels its predecessor. Changing a
|
||||
filter resets the page; returning from detail preserves the current filters.
|
||||
Card activation supports Enter/Space as well as mouse clicks. A name/tag edit
|
||||
refreshes summary cards and filter choices. Shared confirmation dialogs sit above
|
||||
the image workbench, whose keyboard shortcuts yield while a dialog is open.
|
||||
|
||||
A full material workflow is exact and retains its seed. Node-sized reuse is a
|
||||
preset operation and therefore uses the existing transactional parameter
|
||||
application path, which skips known volatile seed widgets. The lookup endpoint
|
||||
filters blocks by exact `node.type`; when more than one source node matches, the
|
||||
user chooses the block explicitly.
|
||||
|
||||
Every node card in the image workbench can be saved directly as an
|
||||
`image_node_selection`; checking several cards exposes one colocated save action
|
||||
above the node list. The initially hidden footer remains dedicated to the full image and
|
||||
workflow snapshot rather than mixing both concepts in a scope selector. The
|
||||
original workflow remains in a selected-node record as source provenance, but
|
||||
list/count/lookup APIs expose only the selected blocks. Such a material
|
||||
deliberately lacks `open_workflow`; the library and Node Assistant apply its
|
||||
parameters to one node without opening the hidden source workflow.
|
||||
|
||||
The image workbench also exposes saving on its primary surfaces. The top action
|
||||
reveals the full-snapshot name/tag form. One generation-settings action captures
|
||||
the sampler and size nodes together; each prompt node can be saved without visiting
|
||||
the all-nodes tab. A metric action is intentionally node-sized: it does not claim
|
||||
to persist one isolated widget value. The former plain-text “share text” action
|
||||
is not part of this workbench.
|
||||
|
||||
Prompt roles are inferred from workflow topology first, with node-title hints as
|
||||
a fallback. Image details display the inferred role beside every prompt node and
|
||||
allow an explicit positive, negative, shared, unknown, or ignored override.
|
||||
Overrides travel into the saved material but never write back to a source Recipe.
|
||||
Material details expose the same selector; `update_material` atomically persists
|
||||
or clears these overrides, and choosing Automatic restores the topology result.
|
||||
|
||||
## Model identity handoff
|
||||
|
||||
Material storage preserves the workflow's existing `extra.anomalous_hashes`.
|
||||
When one node block is applied, records scoped to the source node ID are copied
|
||||
to the target node ID. This is evidence transport, not identity resolution.
|
||||
Model Doctor remains the authority that decides whether a missing model may be
|
||||
repaired. A material name, preview, saved path, or size is never promoted to
|
||||
cryptographic identity.
|
||||
|
||||
## Compatibility and future material kinds
|
||||
|
||||
Records declare `schema_version`, `kind`, and `capabilities`. New sources such
|
||||
as Recipe selections and Prompt Note blocks should add explicit kinds or
|
||||
source metadata without weakening the image snapshot contract. Existing direct
|
||||
Recipe and Prompt Note use paths remain available; the library is optional
|
||||
curation rather than a mandatory intermediary.
|
||||
|
||||
The currently saved kinds are `image_workflow_snapshot`, `image_node_selection`,
|
||||
and `recipe_parameter_selection`. Recipe parameter materials intentionally have
|
||||
no image asset and only advertise `apply_node_parameters`; they cannot replace
|
||||
the canvas with the source Recipe. A prompt badge on a CLIPTextEncode selection
|
||||
still describes a workflow prompt node, not a Prompt Note import. Prompt Notes use the explicit kinds described below. The `reference_image` capability currently
|
||||
means a preserved image that can be viewed; copying it into ComfyUI input or
|
||||
configuring LoadImage is also future work.
|
||||
|
||||
## Prompt Note capture and return
|
||||
|
||||
`POST save_prompt_note_material` accepts `notebook_filename` as a provenance
|
||||
label, `name`, optional `tags`, `scope` (`note` or `prompt`), and an immutable
|
||||
client snapshot in `note`. It does not use that filename to read a file. The
|
||||
snapshot includes the latest text before the editor's autosave timer fires.
|
||||
The server bounds it to the notebook's 2 MiB limit and retains only the supported
|
||||
prompt, translation, language, base-model, main-model and LoRA fields. Model
|
||||
objects remain saved selections, never identity evidence. Prompt-only capture
|
||||
excludes all model fields. `promptZh` is a translation, not a negative prompt.
|
||||
|
||||
- `prompt_note_bundle`: entire note, translations and companion model selections.
|
||||
- `prompt_text`: prompt text and translations without companion models.
|
||||
|
||||
Both kinds advertise `copy_prompt` and `restore_prompt_note`, contain no image
|
||||
assets or synthetic workflow, and never appear in node-type lookup. Explicit
|
||||
full-workflow requests return 403. Atomic creation and duplicate confirmation
|
||||
reuse the image-free material writer; deduplication compares content signature
|
||||
and kind. Editing a note later cannot alter its captured material.
|
||||
|
||||
The library copies prompt text or loads the snapshot as a newly named, uniquely
|
||||
identified Prompt Note. It flushes any pending note before switching, writes the
|
||||
new record successfully before navigation, and preserves both original note and
|
||||
material. Canvas use remains the existing explicit Prompt Note action.
|
||||
|
||||
`material_feedback.js` supplies a shared save receipt with View Material.
|
||||
`web/main.js` binds `openSavedMaterial` from `ui_materials.js`; it owns navigation
|
||||
and workspace return state, keeping workbench imports acyclic. Image details,
|
||||
recipes and Prompt Notes use that same handoff. Node-sized receipts lead to a
|
||||
detail that explains reuse through the library or Node Assistant.
|
||||
|
||||
Primary surfaces use progressive disclosure: image save form, prompt role
|
||||
selectors, technical node metadata, companion models, recipe export/source
|
||||
editing, and recipe multi-selection are revealed on request. Parameter-page
|
||||
metrics appear once with exact-value copy controls. Display wording is Parameter
|
||||
Sets (参数方案); existing notebook routes and storage identifiers stay stable.
|
||||
|
||||
|
||||
## Entry points and shared application
|
||||
|
||||
The lower-left control opens a standalone Material Library container, with no
|
||||
workspace tabs and no initial Prompt Note fetch. The upper-right control opens
|
||||
Workflow Recipes; that workspace retains its Recipes and Prompt Notes tabs.
|
||||
Both containers share the browser's panel area but have separate headers and
|
||||
lifecycle state; switching restores the appropriate container. Material Library
|
||||
file import and its transfer-center entry are closed. It has no independent
|
||||
image-material bundle exporter. The verified workflow share-code Import / Export
|
||||
Center lives in Toolbox. Recipe package import/export remains paused, while prompt-plan
|
||||
JSON export has been removed. Local capture/save actions remain available.
|
||||
|
||||
Opening the library with exactly one live selected node enables apply mode.
|
||||
Summary requests filter by exact `node_type`; activating a card fetches only that
|
||||
material's scoped detail. No selection means normal detail browsing. An explicit
|
||||
toggle returns to browsing all materials; prompt-only kinds also use browse mode.
|
||||
Multiple source blocks of the same type require choosing one block. The full
|
||||
workflow quick-open button is omitted from apply-mode cards.
|
||||
|
||||
`node_material_actions.js` is the shared, UI-independent mutation owner. It checks
|
||||
live graph/node identity, indexes, value types and native combo choices before
|
||||
editing. It skips seed widgets, preserves node identity/links/position, calls the
|
||||
live widget callback and four-argument node hook, and marks the graph dirty.
|
||||
Values, serialized widget values and target-scoped hash evidence change in one
|
||||
before/after transaction; hook failures restore their snapshots. Model paths must
|
||||
already be available in the native combo. This path does not invoke global model
|
||||
repair after application. Hash transport remains evidence, never verification.
|
||||
|
||||
`material_full?include_workflow=0` includes `workflow_hashes` restricted to returned
|
||||
blocks, without exposing the hidden workflow. Both library and assistant show the
|
||||
same application receipt. Undo restores values and scoped hashes only while the
|
||||
same live node, values, serialized values and target hashes still match the applied
|
||||
state. Later edits are protected. Unsupported third-party widget side effects
|
||||
still require real-host compatibility testing.
|
||||
|
||||
Selection hooks chain the host's callbacks and batch updates in a microtask;
|
||||
there is no polling. List browsing retains 48-item pagination and cancellation.
|
||||
Language changes rebuild visible library/composer text while keeping the current
|
||||
draft. Node labels reuse ComfyUI's registered localized titles with raw type fallback.
|
||||
|
||||
## Prompt combinations
|
||||
|
||||
`prompt_plan` is an image-free material with `compose_prompt` capability and no
|
||||
synthetic workflow. `POST save_prompt_plan` accepts a name, tags, and `plan`:
|
||||
`parts` is an ordered list of up to 100 records with `name` (up to 120 characters),
|
||||
`category` (`general` or `specific`), `enabled`, `positive`, and `negative`.
|
||||
Top-level `positive` and `negative` hold prompt text. The plan is bounded to
|
||||
2 MiB; unknown fields are discarded. Atomic writes and explicit duplicate-copy
|
||||
confirmation reuse the material persistence lock and canonical content signature.
|
||||
|
||||
The current panel has simple positive/negative text fields and a beginning/end
|
||||
drop-position selector. Fragment categories, ordering and enable controls are
|
||||
deferred. Opening an older plan joins its enabled fragments and final content
|
||||
into those two fields; a new save has empty `parts`, leaving the original record
|
||||
intact. Text insertion preserves weights, commas, duplicates and original nonblank
|
||||
string whitespace. A Prompt Note's `promptZh` translation is never inferred as
|
||||
negative text. Captured notes and classified workflow prompt groups can populate
|
||||
the fields; unknown workflow roles are not silently assigned a role.
|
||||
|
||||
Saving creates an independent new snapshot. Studio source cards automatically
|
||||
follow library changes; assembled draft blocks remain independent copies without
|
||||
model binding. A page-session draft survives library navigation and is replaced
|
||||
by an existing saved plan only after confirmation; it must be saved before page
|
||||
reload. Stale detail requests cannot replace the current draft. Standalone JSON
|
||||
export has been removed, and the file-import entry is closed. The shared save
|
||||
endpoint still supports local combination saves. The panel also exposes role and target-widget selection,
|
||||
beginning/end buttons for a selected node, copy controls and guarded undo.
|
||||
|
||||
## Canvas drag and drop
|
||||
|
||||
`material_drag.js` binds native drag handles to all material cards and to each
|
||||
prompt field's drag button (`bindPolymorphicMaterialCardDrag`). Only an active,
|
||||
same-page drag is trusted; transfer data is a marker, not an external mutation
|
||||
command. During drag, the browser window becomes transparent and stops intercepting
|
||||
pointer events. End, drop, Escape and loss of focus restore it and remove temporary
|
||||
event listeners. There is no persistent drag polling or detail request during hover.
|
||||
|
||||
Hit testing evaluates live node target acceptance before canvas blank surface drop.
|
||||
Because nodes sit on the canvas element, testing canvas surface first would hijack
|
||||
node drops; evaluating `accepts(node, data)` first guarantees node targeting takes
|
||||
precedence. Live node lookup uses ComfyUI canvas coordinate conversion (including pan/zoom),
|
||||
canvas bounds, and graph node hit-testing. DOM-widget surfaces are accepted. The
|
||||
graph/canvas identities are captured at drag start and checked again at drop. The actual
|
||||
drop node is the target, regardless of the previously selected node. Receipts name
|
||||
that target and provide the shared guarded undo.
|
||||
|
||||
When dropped onto a compatible node:
|
||||
- Parameter-bearing materials fetch scoped node blocks only after drop and replace
|
||||
compatible widget values through the transactional parameter path, preserving seed,
|
||||
node position, and links.
|
||||
- Prompt materials inject prompt text into target nodes via semantic prompt widget
|
||||
sniffing (`customtext`, `multiline`, `text_g`, `text_l`, `prompt`, `positive`,
|
||||
`negative`, and localized labels), relaxing strict node-type matching for third-party
|
||||
text nodes. Text is cleanly inserted without synthetic prefixes ("负向:" / "正向:").
|
||||
|
||||
When dropped onto blank canvas:
|
||||
- Workflow materials (`image_workflow_snapshot`) trigger full workflow loading
|
||||
via `openMaterialWorkflow`.
|
||||
- Prompt materials (prompt notes, prompt plans, prompt node selections) auto-instantiate
|
||||
native `CLIPTextEncode` nodes at canvas drop coordinates, populating the pure prompt
|
||||
text, setting bilingual titles (`CLIP Text Encode (Negative/Positive)` /
|
||||
`CLIP 文本编码器 (负向/正向)`), and applying standard LiteGraph dark theme colors
|
||||
(`#532323` dark red for negative, `#235327` dark green for positive).
|
||||
|
||||
Empty/unsupported targets and canceled drags do not mutate the graph. Neither drop path
|
||||
queues generation.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Model Identity and Scanning
|
||||
|
||||
Read this document for Model Doctor, workflow provenance, missing-model recovery,
|
||||
hash caches, Civitai metadata resolution, or deep scanning.
|
||||
|
||||
## Identity boundary
|
||||
|
||||
Model Doctor recovers the same physical model referenced by provenance embedded
|
||||
in a workflow or image. Local renames and path differences are the problem it
|
||||
solves, so names cannot also be its proof.
|
||||
|
||||
Allowed automatic evidence is:
|
||||
|
||||
1. plugin-carried cryptographic hash;
|
||||
2. exact physical byte size only as a disambiguator for that hash;
|
||||
3. the model category required by the target widget.
|
||||
|
||||
Without a hash, one unique in-category size match is only a candidate. Model
|
||||
Doctor may show it during an explicit manual check, but it cannot redirect the
|
||||
node until the user confirms that candidate. Confirmation applies to the current
|
||||
node only; it does not create a persistent binding. If both hash and size are
|
||||
present and point to conflicting physical files (both match distinct local files),
|
||||
resolution reports an identity conflict and is rejected. When a requested model hash
|
||||
simply does not exist in any local file, it returns `{"found": False}` cleanly without
|
||||
falsely flagging a conflict.
|
||||
|
||||
Paths, filenames, source filenames, display/custom names, previews, workflow
|
||||
fingerprints, and fuzzy/visual similarity are never candidate evidence. They may
|
||||
be used only after identity is established to return a local dropdown value,
|
||||
locate presentation media, and verify the value against ComfyUI's native choices.
|
||||
|
||||
Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
|
||||
`clip_vision`—are hash-only automatic-recovery categories. Byte size alone
|
||||
cannot automatically repair them. When size provenance is available it may be
|
||||
shown as the same explicit manual candidate, but a supplied hash mismatch never
|
||||
falls back to a filename or size-only guess.
|
||||
This is a Model Doctor confidence boundary, not a scanner-support boundary. The
|
||||
scan wizard may traverse any active registered model folder and can still
|
||||
calculate a local hash when Civitai has no matching record. Sparse or ambiguous
|
||||
remote metadata is a reason to require cryptographic identity, not to exclude
|
||||
the category from scanning.
|
||||
|
||||
An existing native combo value remains loadable even if a foundation component's
|
||||
current local hash differs from stored provenance. Model Doctor shows a
|
||||
non-blocking identity-change warning instead of declaring the node missing or
|
||||
replacing it. If the value is absent, redirection requires one exact in-category
|
||||
hash match. Missing or ambiguous evidence remains unresolved for manual action.
|
||||
|
||||
Slash normalization of a value that already has an exact native-option
|
||||
equivalent is representation normalization, not discovery. The resolver must
|
||||
never append a foreign or cross-category value to `widget.options.values` merely
|
||||
to claim success.
|
||||
|
||||
## Frontend provenance cache and injection
|
||||
|
||||
`window.anomalous_hash_cache` maps model widget values to exact SHA-256 and size
|
||||
records. It is fetched by the optional resolver from `/anomalous/all_hashes`.
|
||||
Relative-path and basename aliases exist only when unambiguous; if two files
|
||||
share an alias but differ in identity, omit that alias. Lookups prefer the full
|
||||
widget value before basename fallback.
|
||||
|
||||
After a scan writes new metadata, `window.anomalous_reload_hashes()` refreshes
|
||||
the frontend cache.
|
||||
|
||||
When provenance injection is enabled, the compatible active graph constructor's
|
||||
`serialize` path writes
|
||||
`extraObj.anomalous_hashes[node_id_filename] = {hash, size}` for known model
|
||||
widgets. The cache covers ordinary models and foundation categories. A
|
||||
foundation component without a recorded hash receives no size-only provenance.
|
||||
A missing compatible graph API disables only injection and recovery integration,
|
||||
not the main browser.
|
||||
|
||||
Exact-path checks are limited to preflight confirmation that an already resolved
|
||||
local value exists. Backend discovery constrains candidates to the inferred
|
||||
category and intersects saved hash/size evidence. One unique in-category
|
||||
size-only match is returned as a confirmation-required candidate; it is never
|
||||
an automatic result. Multiple equal-sized candidates remain unresolved. A real
|
||||
hash/size conflict is rejected.
|
||||
|
||||
## Metadata association
|
||||
|
||||
A Civitai `.info` record can describe multiple physical files, such as a model,
|
||||
text encoder, and VAE. `get_metadata()` selects the matching `files[]` entry by
|
||||
exact physical byte size. An unmatched entry may be used only when it is the sole
|
||||
hash candidate. Never take the first SHA-256 or select among entries by filename.
|
||||
|
||||
Metadata refresh is enrichment, not identity mutation. Offline generated
|
||||
metadata includes the discovered hash, but consumers still associate the record
|
||||
with the current physical file through the established size/name rules; array
|
||||
position is not identity.
|
||||
|
||||
## Deep scanning
|
||||
|
||||
Deep Hash Scan runs outside the aiohttp event loop and identifies a model through
|
||||
the established fallback sequence:
|
||||
|
||||
1. use existing valid file SHA-256 metadata when the scan does not request refresh;
|
||||
2. otherwise calculate the complete file SHA-256, never a ModelSpec header digest;
|
||||
3. if the remote service has no record, infer a bounded local base-model family
|
||||
from tensor/header fingerprints and write offline metadata.
|
||||
|
||||
`model_identity.py` owns digest validation and the `anomalous_file_identity`
|
||||
sidecar record: algorithm, file scope, digest, physical size/mtime, and computed
|
||||
source. ModelSpec hashes may describe tensor content and are never full-file
|
||||
identity ([specification](https://github.com/Stability-AI/ModelSpec)). Old locally
|
||||
inferred sidecars without this record keep their display data but need an explicit
|
||||
scan or successful on-demand SHA-256 check before supplying identity again.
|
||||
Successful on-demand verification preserves existing notes and remote metadata.
|
||||
Imported workflow provenance stays intact; it must match valid local file evidence.
|
||||
|
||||
Remote metadata requests are part of an explicit user-initiated scan. Local
|
||||
browsing and offline inference remain usable when the service is unavailable.
|
||||
|
||||
Physical rename conflicts are non-destructive. If the generated target filename
|
||||
already exists, hash both complete files. Deletion is allowed only when their
|
||||
full SHA-256 values match. Distinct files with the same generated display name
|
||||
are both preserved.
|
||||
|
||||
## Resolution execution
|
||||
|
||||
The frontend finds unresolved workflow nodes and sends provenance hash, byte
|
||||
size, and inferred model category to the batch endpoint, falling back to the
|
||||
single-item endpoint if batching fails. Requests are grouped by the exact model
|
||||
category tuple so the backend scans each group once, but every result retains
|
||||
the same hash, size, category, conflict, and ambiguity rules.
|
||||
|
||||
If a size-selected candidate lacks cached hash metadata, the backend may hash it
|
||||
on demand. Only an exact hash match establishes identity; successful discovery
|
||||
may write offline metadata for later reuse.
|
||||
|
||||
After a hash match—or explicit confirmation of a size-only candidate—the
|
||||
frontend refreshes ComfyUI's native combo definitions and accepts the returned
|
||||
path only if it is present in the target widget's choices. Then it updates the
|
||||
dropdown and clears the missing-model presentation. Background checks may
|
||||
surface size candidates but never prompt for or apply them.
|
||||
|
||||
Provenance-rich workflows skip the redundant full filename-to-hash cache refresh.
|
||||
Legacy workflows without injected provenance may refresh it for compatibility.
|
||||
This is an I/O optimization only and does not change identity evidence.
|
||||
|
||||
Civitai lookup by hash may resolve a model/version page and route mature content
|
||||
to the configured domain. Hash-detail UI may compare and copy workflow and local
|
||||
hash/size values. Neither presentation feature changes recovery rules.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Workflow Recipe Model Lifecycle
|
||||
|
||||
Read this document before changing Workflow Recipe model cards, local matching,
|
||||
partial-canvas append, package export, or Model Doctor integration. UI code may
|
||||
change freely, but the data ownership and transition rules below are contracts.
|
||||
|
||||
## Three representations with different jobs
|
||||
|
||||
A saved recipe keeps model information in three related representations:
|
||||
|
||||
1. `workflow.nodes[].widgets_values[]` is authoritative for the value ComfyUI
|
||||
will load into a model widget.
|
||||
2. `params.model_references[]` is the structured recipe record used by the UI,
|
||||
package system, history, and matching actions.
|
||||
3. `workflow.extra.anomalous_hashes` is Model Doctor provenance keyed to the
|
||||
serialized node ID and widget value.
|
||||
|
||||
Changing only one representation creates a broken recipe. A path change must
|
||||
update the workflow widget, its model reference, and its node-scoped Hash key in
|
||||
the same recipe update. A partial append must remap the Hash key alongside the
|
||||
node ID. Presentation fields must never become identity evidence.
|
||||
|
||||
## Schema v7 model reference
|
||||
|
||||
`params.model_references[]` contains these persisted fields:
|
||||
|
||||
- `node_id`, `node_type`, `node_title`: serialized graph location and display
|
||||
context;
|
||||
- `widget_index`, `widget_name`: authoritative widget slot;
|
||||
- `saved_value`: exact dropdown/path value stored in the workflow;
|
||||
- `category`, `base_model`: bounded matching and presentation context;
|
||||
- `identity`: `status`, optional SHA-256, optional byte size, and provenance;
|
||||
- `origin`: editable official-source metadata, independent of identity;
|
||||
- `preview`: optional recipe-owned bounded snapshot descriptor;
|
||||
- `user_note`: optional recipe-scoped personal note, at most 1000 characters.
|
||||
|
||||
`user_note` is not global model metadata, is not used for matching, and does not
|
||||
change Model Doctor confidence. Enrichment preserves it by the complete model
|
||||
reference key. Package export explicitly asks whether to include it; excluding
|
||||
it removes the field only from the exported current/history records.
|
||||
|
||||
Runtime-only fields such as `localModel`, `localMatch`,
|
||||
`currentAvailability`, and `currentPreviewUrl` must not be persisted as schema.
|
||||
|
||||
## Hash record shape and keys
|
||||
|
||||
The canonical record is compatible with:
|
||||
|
||||
```json
|
||||
{
|
||||
"hash": "lowercase SHA-256 or an empty string",
|
||||
"size": 123456
|
||||
}
|
||||
```
|
||||
|
||||
Node-scoped keys use `<node_id>_<saved_widget_value>`. Slash-normalized aliases
|
||||
may coexist for Windows compatibility. Readers check node-scoped keys first;
|
||||
global path aliases are legacy fallback only and must not be relied on when
|
||||
mutating a recipe.
|
||||
|
||||
Graph serialization prefers current verified local-cache data. When a widget
|
||||
value is still missing—or no current cache record exists—it must preserve the
|
||||
node-scoped provenance already carried by the graph. Clearing it would make a
|
||||
successfully appended partial recipe lose its Model Doctor identity on the next
|
||||
save.
|
||||
|
||||
An empty Hash with a known size represents unverified provenance. It may produce
|
||||
a manual same-size candidate, but never an automatic repair. A Hash copied from
|
||||
the original recipe must not be attached to a size-only candidate.
|
||||
|
||||
## Lifecycle transitions
|
||||
|
||||
### Save and update
|
||||
|
||||
The frontend captures the graph once. The backend validates it, derives model
|
||||
references, preserves compatible identity/origin/preview/user-note fields, and
|
||||
writes schema v7 atomically. Optional save-time verification may calculate a
|
||||
full SHA-256. Normal enrichment does not silently replace imported identity.
|
||||
|
||||
### Availability and local matching
|
||||
|
||||
Availability and previews describe the current computer only. Matching sends
|
||||
saved Hash/size/category evidence to Model Doctor:
|
||||
|
||||
- exact Hash match: may be offered for explicit recipe application;
|
||||
- unique size-only match: confirmation-required candidate;
|
||||
- Hash/size conflict: rejected;
|
||||
- ambiguity: unresolved.
|
||||
|
||||
Finding a candidate is presentation-only. The recipe is not changed until the
|
||||
user activates its Apply/Confirm action.
|
||||
|
||||
### Apply local match
|
||||
|
||||
One full-recipe update performs all of these changes together:
|
||||
|
||||
1. replace the target `widgets_values[widget_index]` with the local dropdown
|
||||
value;
|
||||
2. replace `model_references[].saved_value` and its identity;
|
||||
3. update `params.baseModel` when it held the same old value;
|
||||
4. remove old node-scoped Hash aliases and write aliases for the new value;
|
||||
5. archive the previous recipe through the normal update endpoint.
|
||||
|
||||
For a Hash match, the stored local identity retains the proven SHA-256. For a
|
||||
manually confirmed size candidate, use a valid Hash from the local candidate's
|
||||
metadata when available; otherwise store size-only unverified provenance. The
|
||||
author's editable `origin` and the recipe-scoped `user_note` remain intact.
|
||||
|
||||
### Apply to canvas
|
||||
|
||||
Complete recipes pass their cloned workflow to ComfyUI, so the contained
|
||||
`extra.anomalous_hashes` opens with the new workflow canvas.
|
||||
|
||||
Partial recipes are a merge transaction. New collision-free node IDs are
|
||||
allocated, links and groups are restored, and node-scoped Hash records are
|
||||
copied under the new IDs without deleting current-canvas records. If any part
|
||||
fails, inserted nodes/groups and the previous Hash map are restored together.
|
||||
|
||||
### Export and import
|
||||
|
||||
Export choices independently control preview snapshots, history, model
|
||||
identity, and personal model notes. Removing identity or notes sanitizes only
|
||||
the package clone. Import validates the contained recipe and assets, then uses
|
||||
the same normalization and enrichment boundaries as local recipes.
|
||||
|
||||
## Implementation map for UI refactors
|
||||
|
||||
- `api/recipes.py`: schema validation, enrichment, field preservation, history;
|
||||
- `api/recipe_packages.py`: export privacy and package boundaries;
|
||||
- `api/models.py`: Model Doctor evidence and candidate policy;
|
||||
- `web/modules/recipe_identity.js`: model-reference adapters for presentation;
|
||||
- `web/modules/recipe_provenance.js`: pure Hash lookup/remap/replace helpers;
|
||||
- `web/modules/recipe_actions.js`: canvas transactions and append rollback;
|
||||
- `web/modules/ui_recipe_detail.js`: user actions that invoke the contracts;
|
||||
- `web/modules/ui_recipes.js`: save/import/export orchestration;
|
||||
- `web/modules/recipe_diff.js`: history-visible semantic changes.
|
||||
|
||||
When restructuring UI, call these data functions rather than recreating Hash
|
||||
keys, matching confidence, or append behavior inside components. Filenames,
|
||||
notes, previews, and official names must never be promoted to identity proof.
|
||||
@@ -0,0 +1,231 @@
|
||||
# Workflow Recipes and Parameter Notebooks
|
||||
|
||||
Read this document for recipe schemas, cards/detail behavior, package handling,
|
||||
result galleries, Parameter Notebooks, prompt roles, and the recipe-powered Node
|
||||
Assistant.
|
||||
|
||||
## Package import/export availability
|
||||
|
||||
Recipe package import and export are temporarily closed pending validation.
|
||||
Topbar/card/detail buttons are disabled with a localized explanation. The registered
|
||||
`POST /anomalous/export_recipe_package` route returns HTTP 503 with code
|
||||
`recipe_export_disabled` before reading request JSON or recipe files.
|
||||
`RECIPE_PACKAGE_EXPORT_ENABLED` in `api/recipe_packages.py` is a release gate,
|
||||
not a user setting. `RECIPE_PACKAGE_IMPORT_ENABLED` likewise gates both
|
||||
`POST /anomalous/import_recipe_package_inspect` and
|
||||
`POST /anomalous/import_recipe_package_commit`: each returns HTTP 503 with code
|
||||
`recipe_import_disabled` before reading uploaded bytes, request JSON, inspection
|
||||
tokens or writing files. Package format helpers remain for compatibility tests;
|
||||
local saves are unaffected.
|
||||
|
||||
Reopening requires an explicit release decision, package round-trip and failure
|
||||
validation, then restoring the frontend export action and enabling the backend
|
||||
gates together. Do not reopen package transfers as a side effect of UX work. Workflow
|
||||
share-code import/export is a separate, verified feature available from Toolbox.
|
||||
|
||||
## Product and data model
|
||||
|
||||
Workspace contains Prompt Notes, Workflow Recipes, and the Material Library.
|
||||
Recipe Parameter Notebooks are presented as Parameter Sets (参数方案). Internal
|
||||
notebook route and property names may remain stable for compatibility even when
|
||||
the user-facing presentation changes.
|
||||
|
||||
A Workflow Recipe stores the authoritative serialized graph plus bounded
|
||||
metadata for browsing and comparison. Summary adapters expose common model,
|
||||
LoRA, prompt, and sampling semantics; generic bounded widget summaries cover
|
||||
other nodes, including third-party nodes. Summaries are presentation only. Edit,
|
||||
apply, structural validation, and detailed values resolve from the contained
|
||||
serialized workflow by node ID and widget index.
|
||||
|
||||
The card API stays lightweight. Full workflow and history payloads are loaded on
|
||||
demand. Every update archives the previous full recipe locally, bounded to 20
|
||||
versions. The structural fingerprint (`sha256-structural-v1`) is an integrity
|
||||
and version-comparison value, not model identity evidence.
|
||||
|
||||
Backend ownership is split by responsibility: `workflow_schema.py` owns graph
|
||||
validation, fingerprints, signatures and integrity receipts; `recipe_schema.py`
|
||||
owns recipe/model-reference normalization; `recipe_images.py` owns source images,
|
||||
covers and output-gallery inspection; `recipe_store.py` owns directory, record and
|
||||
history I/O. `recipes.py` maps HTTP requests and errors and retains narrow
|
||||
compatibility exports only. `recipe_packages.py`, `parameters.py`, and materials
|
||||
import those owners directly rather than reaching through the route facade.
|
||||
|
||||
The current persisted recipe schema is v7. Earlier schema steps introduced the
|
||||
structural fingerprint, explicit model-reference identity records, and optional
|
||||
recipe-owned preview descriptors; v5 separates model identity from editable
|
||||
official-origin fields. V6 records whether the saved graph is a partial or
|
||||
complete recipe. V7 adds bounded recipe-scoped model notes and makes Hash
|
||||
synchronization across local matching and partial append an explicit invariant.
|
||||
Normal save/update paths preserve compatible imported
|
||||
records rather than rebuilding identity from the current machine. An explicit
|
||||
save-time verification choice may replace missing identity with a freshly
|
||||
computed SHA-256 for supported model categories.
|
||||
|
||||
Recipe model references separate saved identity from current-machine
|
||||
availability and official origin metadata. Origin refresh is an explicit
|
||||
enrichment mode. A refresh-only request clones the stored recipe before
|
||||
enrichment so it cannot be rejected by complete-recipe validation or discard the
|
||||
workflow. Normal updates preserve imported identity/origin records when the
|
||||
current machine cannot resolve them.
|
||||
|
||||
## Save, edit, and presentation
|
||||
|
||||
Save captures the live graph exactly once before opening the dialog; that fixed
|
||||
snapshot is the graph sent to the backend. The dialog records recipe identity,
|
||||
notes, tags, and cover/source image. It does not ask users to select presentation
|
||||
pins or a per-recipe snapshot policy. Existing `params.pinned` values survive an
|
||||
edit, while new recipes use an empty list.
|
||||
|
||||
Before the dialog opens, an advisory check reads only the captured workflow hash
|
||||
records and cached local metadata. Recognized model references without verified
|
||||
identity appear as an optional action. The checkbox is off by default; enabling
|
||||
it computes the exact full-file SHA-256 in a worker thread during persistence.
|
||||
Foundation components such as VAE and text encoders are included because Model
|
||||
Doctor requires their hash for automatic recovery and treats size-only evidence
|
||||
as a manual candidate at most.
|
||||
Inspection failure never blocks the workflow snapshot from being saved.
|
||||
|
||||
The detail view contains Overview, Parameters, Gallery, and Versions as
|
||||
applicable. Compact cards may ellipsize bounded values while preserving their
|
||||
full copy value. Detail rows provide visible expand/collapse and copy controls
|
||||
for long values and prompts; they do not silently truncate authoritative data.
|
||||
|
||||
`ui_recipe_detail.js` coordinates the detail session, active tab, and model composition.
|
||||
`ui_recipe_overview.js` owns the overview; `ui_recipe_parameters.js` owns prompt roles,
|
||||
parameter editing, raw nodes, and preset saving; and `ui_recipe_model_matching.js`
|
||||
owns preview resolution plus explicit local matching. Inline persistence is centralized
|
||||
in `ui_recipe_metadata.js`, with pure ordering/value helpers in
|
||||
`ui_recipe_parameter_utils.js`. The catalog shell lives in `ui_recipe_catalog.js`,
|
||||
cards and card actions in `ui_recipe_cards.js`, save/edit dialogs in
|
||||
`ui_recipe_dialogs.js`, and shared cover helpers in `ui_recipe_media.js`.
|
||||
`ui_recipe_versions.js` owns version comparison and restore, while
|
||||
`ui_recipe_gallery.js` owns result rendering and opens `ui_gallery_detail.js`
|
||||
directly for image inspection. Shared detail DOM/copy primitives live in
|
||||
`ui_recipe_detail_dom.js`; the subviews return refresh/finish decisions through
|
||||
callbacks instead of redrawing one another.
|
||||
|
||||
Model names in compact recipe presentation use a basename or official model
|
||||
name, never a full filesystem path. Saved paths and hashes belong behind advanced
|
||||
information. A preview or exact path can locate current-machine presentation
|
||||
only after the reference is already understood; it cannot establish identity.
|
||||
|
||||
Import matching is a separate explicit recovery action. Unresolved references
|
||||
are sent to the hash/size/category resolver. A discovered candidate remains
|
||||
presentation-only until the user chooses Apply match; that action updates the
|
||||
authoritative workflow widget, model reference, and node-scoped Hash index
|
||||
through the full-recipe update path and archives the previous recipe. The
|
||||
author's saved filename or path is never match evidence. Model-reference
|
||||
`user_note` is recipe-scoped presentation metadata and never match evidence.
|
||||
|
||||
Recipe-owned model preview snapshots are bounded, content-addressed WebP files
|
||||
below `.assets/<recipe-stem>/`. They are at most 320 px or 96 KiB each, limited
|
||||
to 12 images and 1.25 MiB per save/update. Static local preview files are
|
||||
accepted; original videos are never packaged, though a bound local video may
|
||||
contribute a bounded first-frame thumbnail. Deleting a recipe removes only its
|
||||
contained assets after the recipe file is deleted. History may share an asset
|
||||
ID.
|
||||
|
||||
## Canvas actions
|
||||
|
||||
Recipe cards and details expose a scope-aware canvas action. A live graph with
|
||||
an active output node and no unconnected required inputs is saved as a complete
|
||||
recipe and opens as a new workflow canvas. A graph without an output node or
|
||||
with required connection boundaries is saved as a partial recipe and appends to
|
||||
the current canvas. Append clones saved nodes, assigns collision-free IDs,
|
||||
remaps links, places/selects the inserted content, treats groups as first-class
|
||||
items, remaps node-scoped model Hash records, and rolls back nodes/groups/Hash
|
||||
records together on failure. Legacy recipes without
|
||||
scope metadata are complete recipes because earlier releases only documented
|
||||
and captured complete workflows.
|
||||
|
||||
Structural editing is separate from composition. It may load a recipe into a new
|
||||
canvas after explicit confirmation and saves back through the full-recipe update
|
||||
path. It is not presented as an ambiguous “Open to canvas” recipe action.
|
||||
|
||||
All list/detail actions await the shared transaction before reporting success or
|
||||
restoring controls. A failure must leave the button usable and the host graph in
|
||||
its prior state.
|
||||
|
||||
## Result galleries
|
||||
|
||||
Recipe result discovery uses `sha256-node-types-v1`: the sorted node class
|
||||
composition and count. It intentionally ignores seeds, prompts, model values,
|
||||
and other parameters, so ordinary generation variations can match. This is
|
||||
separate from the structural integrity fingerprint.
|
||||
|
||||
Opening detail scans at most the newest 200 PNG files below the ComfyUI output
|
||||
directory and reads only bounded embedded `workflow` or API `prompt` metadata.
|
||||
There is no persistent output index or background polling. A chosen result may
|
||||
become the recipe-owned compressed cover; its original output path remains a
|
||||
local convenience reference rather than package content or identity evidence.
|
||||
|
||||
The main output Gallery performs its page-one scan when opened and exposes a
|
||||
manual Refresh action. It does not poll in the background and preserves scroll
|
||||
position across manual refresh.
|
||||
|
||||
## Parameter Notebooks
|
||||
|
||||
Parameter Notebooks are immutable generation-value snapshots owned by a recipe.
|
||||
Saving or updating a recipe creates a new snapshot. The Parameters tab uses a
|
||||
two-pane history browser: the left pane selects a notebook and the right pane
|
||||
prominently identifies the active name and timestamp. Selection requests carry a
|
||||
token so an older gallery response cannot overwrite a newer selection. Parameter
|
||||
result matching uses the separate `sha256-params-v1` signature.
|
||||
|
||||
“New parameter note” clones the selected recipe/snapshot into an editable draft
|
||||
and saves a new immutable snapshot. Rename uses the dedicated parameter endpoint
|
||||
and atomic persistence. Delete accepts only the validated notebook filename,
|
||||
invalidates in-flight requests, reloads the list, and selects the newest remaining
|
||||
note when necessary. The recipe's current baseline is not a deletable stored row.
|
||||
|
||||
Runtime-volatile values such as sampler seeds remain visible by field name but
|
||||
their values are excluded from summaries, copying, editing, matching, and apply.
|
||||
The serialized workflow retains the slots for compatibility.
|
||||
|
||||
“Read current and create” and “Apply to current workflow” share a skeleton
|
||||
preflight. Every saved node must match a local node by type and shape, preferring
|
||||
stable ID/title evidence; extra local nodes are allowed. Read-current refuses to
|
||||
create a draft if the skeleton is incomplete. Apply validates all widget slots
|
||||
before mutation, resolves serialized records to live nodes, invokes ComfyUI
|
||||
callbacks, and rolls values back if a callback fails.
|
||||
|
||||
The Node Assistant queries bounded notebook presets by selected node type. Its
|
||||
cache is invalidated by save/delete and bypassed by an explicit refresh. Applying
|
||||
a preset is a single-node transaction; it does not apply the full workflow.
|
||||
|
||||
## Prompt roles
|
||||
|
||||
Automatic prompt roles are conservative. `recipe_parser.js` traces backward
|
||||
from allowlisted official sampler/Guider conditioning inputs through allowlisted
|
||||
conditioning pass-through nodes to native `CLIPTextEncode` nodes. Unknown
|
||||
third-party nodes are opaque; titles, type-name guesses, prompt content, and a
|
||||
default-positive rule are not semantic evidence.
|
||||
|
||||
Saved role metadata joins to assistant records by string-safe node ID. Legacy
|
||||
recipes may recover a role only from an exact, unambiguous full prompt value in
|
||||
the saved positive/negative arrays. The same value in both arrays remains
|
||||
unknown.
|
||||
|
||||
Manual overrides live in `params.promptRoleOverrides`, keyed by string-safe node
|
||||
ID and guarded by saved node type. Values are `positive`, `negative`, `both`,
|
||||
`ignored`, or `unknown`. An override wins over automatic/legacy metadata; removing
|
||||
it restores the saved automatic result. Updates retain it only while ID and type
|
||||
still match.
|
||||
|
||||
## Recipe-to-model navigation
|
||||
|
||||
Opening a resolved local model from Recipe Overview keeps the outer browser open
|
||||
and temporarily replaces all main content surfaces with model detail. A compact
|
||||
return token retains the recipe payload, active tab, scroll position, and prior
|
||||
Workspace return state.
|
||||
|
||||
Model-detail Back, or reopening Workspace and selecting Workflow Recipes,
|
||||
consumes that token and reconstructs the exact recipe detail. Navigating directly
|
||||
to another main panel or closing Workspace abandons the transition and clears
|
||||
stale media/DOM/return state. Opening Workspace during the transition must not
|
||||
overwrite the earlier outer-panel return state.
|
||||
|
||||
Workflow Recipes and recipe-powered Assistant parameter presets remain beta
|
||||
surfaces until an explicit stability decision changes that status. Localized
|
||||
notices identify their data directories and distinguish single-node preset apply
|
||||
from full-skeleton Parameter Notebook apply.
|
||||
@@ -0,0 +1,56 @@
|
||||
# 更新引导维护契约
|
||||
|
||||
更新引导只解释明显变化,不承担功能开关、数据迁移或执行用户操作。
|
||||
修改前阅读根目录 [AGENTS.md](../../AGENTS.md)。
|
||||
|
||||
## 内容与实现分工
|
||||
|
||||
| 文件 | 责任 |
|
||||
| --- | --- |
|
||||
| `web/modules/update_guide_data.js` | 当前引导的稳定 ID、步骤顺序、图标和文案键;也提供配置校验 |
|
||||
| `web/modules/locales.js` | 每个步骤及按钮的中英文文案,均按纯文本展示 |
|
||||
| `web/modules/ui_update_guide.js` | 显示、翻页、关闭、焦点、版本记录;一般内容更新不要修改 |
|
||||
| `web/modules/sidebar_actions.js` | 六个底部按钮的短标签、完整名称和用途提示,不改变点击动作 |
|
||||
| `web/styles.css` | 引导布局和底部图标/文字切换的现有规则 |
|
||||
| `web/main.js` / `ui_sidebar.js` | 父窗口关闭清理,以及上方按钮和帮助页的手动入口 |
|
||||
|
||||
## 后续 AI 更新步骤
|
||||
|
||||
1. 先确认功能已实现及真实入口。不要在引导里承诺未上线、暂停或验证失败的功能。
|
||||
2. 在 `CURRENT_UPDATE_GUIDE.steps` 编写 1–5 个步骤,每项包含唯一 `id`、短 `icon`、`titleKey`、`bodyKey`。
|
||||
3. 在 `locales.js` 补齐这些键的中文和英文。标题说明变化,正文说明入口和用户该怎么用。禁止 HTML、可执行回调、请求及文件写入。
|
||||
4. **只有引导内容属于新一轮功能变化时才换 `id`**,例如 `2026-10-library-navigation`。修改错字、翻译、样式时保持 ID。不使用时间戳、随机数、Git 提交号或普通版本号自动生成 ID;换 ID 也不能自动弹窗。
|
||||
5. 配置校验会拒绝重复步骤、空步骤及缺失双语文案。配置错误只跳过引导,不应阻止插件打开;必须修正错误后再交付。
|
||||
6. 运行下方相关测试,再在浏览器验证打开插件不弹窗、上方入口、跳过后手动重开、帮助页重看、Esc 和长文案。
|
||||
7. 修改现有样式规则,不在文件末尾追加覆盖包。没有明确产品决策,不增加强制阅读、自动跳转、网络请求或额外追踪。
|
||||
|
||||
## 生命周期与记录
|
||||
|
||||
- 面板右上角常驻感叹号按钮(`#anomalous-update-notice-btn`),提供完整的用途提示和 accessible name。只有用户点击该按钮或帮助页入口才打开引导;初始化、打开插件、版本变化都不能触发弹窗。
|
||||
- 使用原生 `dialog` 的顶层显示和焦点约束,关闭时尽可能恢复原焦点。
|
||||
- 完成、跳过、×、Esc、点击空白关闭会记录已读,上方入口仍然保留;关闭父浏览器仅清理,不将未读引导标记为已读。入口不需要全局事件或延时隐藏。
|
||||
- 持久化键为 `anomalous_update_guide_seen:<id>`,值为 `1`。只保存本地已读状态,不发送到服务端。
|
||||
- 存储不可用时,当前页面会话仍记住关闭状态;刷新后仍必须手动点击才能显示。
|
||||
- 同一时刻只有一个引导。重复调用不叠加弹窗;所有关闭路径通过 `closeUpdateGuide` 释放资源。
|
||||
- 上方按钮和帮助页的“查看更新引导”均使用 `force: true`,允许重看已确认版本。
|
||||
- 引导不自动点击对应功能,也不修改画布、模型、素材、导入导出限制。
|
||||
|
||||
## 底部按钮约束
|
||||
|
||||
原按钮保留点击处理器、尺寸和图标。短标签是独立且 `aria-hidden` 的 `span`,
|
||||
不能占用已用于详细提示的 `::before`/`::after`。扫描状态替换 SVG 后重新配置标签。
|
||||
语言切换须更新六个标签、完整 accessible name 和用途提示。
|
||||
悬停与键盘焦点使用相同文字切换;无悬停设备直接显示短文字;减少动态效果偏好禁用位移动画。
|
||||
**防闪烁与稳定性铁律**:底栏按钮必须显式声明 `cursor: pointer !important; overflow: hidden;`,内部 SVG 与 Label 必须设置 `pointer-events: none !important;`。图标切换文本时**严禁使用 `display: none`**(必须使用 `opacity: 0` 保持物理文档流支撑),以避免浏览器 Hit-testing 丢失目标导致高频触发 `mouseleave` -> `mouseenter` 乒乓震荡闪烁。
|
||||
|
||||
## 验证
|
||||
|
||||
在插件根目录运行:
|
||||
|
||||
```powershell
|
||||
node --experimental-vm-modules tests/update_guide.mjs
|
||||
```
|
||||
|
||||
`update_guide.mjs` 加载真实模块,检查双语配置、版本记忆、重看、翻页、关闭清理、
|
||||
父窗口关闭、存储失败及扫描图标替换后的标签。它不能代替实际浏览器的布局与焦点验证。
|
||||
只有同时修改工坊生命周期或导入导出行为时,才追加对应测试;纯引导样式不运行无关测试。
|
||||
@@ -0,0 +1,200 @@
|
||||
# Anomalous Model Browser 全功能实测审计与问题复原报告
|
||||
**Audit & Functional Verification Report**
|
||||
|
||||
- **测试对象**:`Anomalous Model Browser` (异构模型浏览器 ComfyUI 插件)
|
||||
- **运行环境**:ComfyUI Portable (Windows x64) / 本地端口 `http://127.0.0.1:8188`
|
||||
- **当前 Git 分支**:`feature/recipe-model-binding`
|
||||
- **浏览器会话录屏存档**:`anomalous_deep_test_1789816916045.webp`
|
||||
|
||||
---
|
||||
|
||||
## 一、 测试操作过程完整复原 (Step-by-Step Reproduction)
|
||||
|
||||
本次实测通过浏览器子智能体(Browser Subagent)与本地真实运行的 ComfyUI 实例进行端到端(E2E)交互操作,测试全流程操作复原如下:
|
||||
|
||||
### 1. 插件唤起与主界面交互
|
||||
1. **浏览器进入 ComfyUI 画布**:
|
||||
- 页面初始加载默认工作流节点(包括 `Load Checkpoint`、`CLIP Text Encode`、`KSampler`、`Anima VAE` 等)。
|
||||
2. **唤起插件主界面**:
|
||||
- 点击 WebUI 界面悬浮触发球及左侧快捷立方体图标,`#anomalous-modal` 顺畅展开,居中层叠在画布上。
|
||||
- 验证右上角 `×` 关闭按钮与侧边栏 `☰` 折叠按钮,动画过渡自然,能顺畅收起并安全回到主画布。
|
||||
|
||||
### 2. 模型医生(Model Doctor)全局体检与修复测试
|
||||
1. **打开体检中心**:
|
||||
- 点击侧边栏听诊器图标(🩺),切换至 Model Doctor 面板。
|
||||
2. **体检扫描表现**:
|
||||
- 面板显示当前画布的节点模型状态:“健康 2 个 / 缺失 1 个”。
|
||||
- 正确检测到当前画布缺失模型:`Anima\qwen_image_vae.safetensors`。
|
||||
3. **交互操作测试**:
|
||||
- 点击右上角“刷新缓存”按钮,控制台正确触发 `app.refreshComboInNodes()` 和 `/anomalous/all_hashes` 刷新。
|
||||
- 查看缺失模型右侧操作按钮:包含了“深度哈希扫描”、“手动替换”、“查看哈希”以及“Civitai 搜索”链接。
|
||||
|
||||
### 3. 素材库(Material Library)与分类检索测试
|
||||
1. **打开素材库**:
|
||||
- 点击工具箱或侧边栏素材库图标,成功打开 Material Library 视图。
|
||||
2. **分类与搜索切换**:
|
||||
- 点击顶部筛选胶囊:“全部”、“工作流”、“提示词”等分类,卡片即时响应过滤。
|
||||
- 在搜索框输入关键词 `Kirazuri`,检索到对应的完整工作流素材卡片 `Kirazuri (Anima)_v4.0`。
|
||||
3. **卡片拖拽交互测试**:
|
||||
- 测试按住卡片尝试拖拽至画布空白处或节点上,排查拖拽反馈(发现重大逻辑断点,详见下文第二节)。
|
||||
|
||||
### 4. 模型扫描向导(Scan Wizard)与设置中心测试
|
||||
1. **打开扫描向导**:
|
||||
- 点击侧边栏雷达扫描图标,弹出 Scan Wizard 原生 Dialog 弹窗。
|
||||
- 查看各项扫描策略配置:包括完整哈希、Civitai 元数据提取等。
|
||||
- 测试取消弹窗:按 `Escape` 键或点击外部背景,发现原生 Dialog 没有绑定外部点击关闭逻辑,必须向下滚动找到底部的“取消”按钮才能退出遮罩层。
|
||||
2. **全局设置中心(Settings Hub)**:
|
||||
- 打开齿轮图标设置面板,测试在“紧凑模式 (Compact)”与“标准模式 (Standard)”之间来回切换,网格与卡片间距自适应良好。
|
||||
|
||||
---
|
||||
|
||||
## 二、 发现的核心缺陷与不合理之处深度剖析 (Critical Issues & Bugs)
|
||||
|
||||
通过“浏览器实际操作 + 源码 AST/逻辑级走查”,发现以下 **4 个影响核心体验或违反技术方案** 的问题:
|
||||
|
||||
---
|
||||
|
||||
### 🚨 缺陷 1(核心技术方案违背):本地缺失模型被误判为“⛔ 身份冲突”,导致修复链路完全中断
|
||||
|
||||
#### 1. 问题定位
|
||||
- **文件**:`api/model_resolution.py`(第 178-181 行)
|
||||
- **函数**:`_resolve_from_candidates`
|
||||
|
||||
#### 2. 代码问题根源
|
||||
在比对带有来源信息(Provenance,同时包含 `hash` 与 `size`)的模型时:
|
||||
```python
|
||||
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
|
||||
if hash_matches:
|
||||
return {"found": False, "identity_conflict": True}
|
||||
return {"found": False, "identity_conflict": True} # <--- 无论 hash_matches 是否为空,均返回 identity_conflict: True!
|
||||
```
|
||||
- 当用户导入一个网上下载的带有哈希的工作流,而本地**压根没有这个模型**时,`candidates` 或 `size_matches` 是空的。
|
||||
- 代码执行到末尾,**无条件返回了 `{"found": False, "identity_conflict": True}`**!
|
||||
|
||||
#### 3. 产生的不合理现象
|
||||
1. **误导用户**:在前端 `ui_doctor.js` 和 `ui_recipe_model_matching.js` 中,该模型会被标上鲜红的 `⛔ 身份冲突 (Identity Conflict)`,给用户一种“本地文件损坏或文件哈希冲突被系统拒绝”的假象。
|
||||
2. **阻断修复方案**:根据 `docs/architecture/model-resolution.md` 规范,只有当本地存在尺寸相同但哈希不同(或哈希相同但尺寸不同)的文件时,才算作真正的 `identity_conflict`。如果本地根本没有该模型,应该返回普通的 `{"found": False}`,以便触发正常的“未找到模型”提示、Civitai 自动检索建议或下载引导。
|
||||
|
||||
#### 4. 修复建议
|
||||
修改 `api/model_resolution.py`,只有在发现真正的冲突(如尺寸符合但哈希不匹配、或哈希符合但尺寸矛盾)时返回 `identity_conflict: True`;无任何匹配时应返回普通未找到:
|
||||
```python
|
||||
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
|
||||
if hash_matches:
|
||||
return {"found": False, "identity_conflict": True}
|
||||
return {"found": False}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🚨 缺陷 2(功能承诺未兑现 & 事件拦截冲突):素材库卡片“拖入画布载入工作流”无效且拖拽逻辑存在优先级漏洞
|
||||
|
||||
#### 1. 问题定位
|
||||
- **文件 A**:`web/modules/ui_material_cards.js`(第 90-96 行)
|
||||
- **文件 B**:`web/modules/material_drag.js`(第 55-84 行)
|
||||
|
||||
#### 2. 代码问题根源
|
||||
1. **提示与实现脱节(虚假功能)**:
|
||||
- 卡片上的 Tooltip 明确写着:`"按住可拖拽至画布节点注入参数,或拖至空白处载入工作流"`(`t('materialCardDragHint')`)。
|
||||
- 但是在 `ui_material_cards.js` 中绑定拖拽时:
|
||||
```javascript
|
||||
if (material.node_types?.length) {
|
||||
bindMaterialDrag(card, owner, {
|
||||
payload: () => ({ ...material, node_types: [...material.node_types], dragHint: t('materialDragParameters') || '拖拽素材参数至目标节点' }),
|
||||
accepts: (node, source) => source.node_types.includes(node.type),
|
||||
drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph),
|
||||
// 致命缺失:根本没有传入 dropOnCanvas 回调!
|
||||
});
|
||||
}
|
||||
```
|
||||
**根本没有传入 `dropOnCanvas` 回调**!导致用户拖到空白画布释放时什么都没发生。
|
||||
- 此外,如果一个素材是**整图工作流素材**(`material.node_types` 为空),因 `if (material.node_types?.length)` 为假,导致**整张卡片完全没有被绑定拖拽能力**(`draggable = false`),用户根本拖不动!
|
||||
|
||||
2. **`material_drag.js` 中的命中优先级缺陷(Precedence Inversion)**:
|
||||
在 `material_drag.js` 的拖拽结束处理中:
|
||||
```javascript
|
||||
if (dropOnCanvas && overCanvas) {
|
||||
try { await dropOnCanvas(event, data, graph); }
|
||||
return;
|
||||
}
|
||||
if (!node || !accepts?.(node, data)) return;
|
||||
try { await drop(node, data, graph); }
|
||||
```
|
||||
- 只要定义了 `dropOnCanvas`,一旦鼠标落在画布区域(`overCanvas == true`),代码会**无条件先执行 `dropOnCanvas`**(载入整图工作流覆盖画布)!
|
||||
- 即便鼠标此时正精准悬停在某个兼容的节点上,也不会触发节点的参数注入!
|
||||
|
||||
#### 3. 修复建议
|
||||
1. 在 `material_drag.js` 中调整执行优先级:**先判断是否命中了兼容节点(Target Node),若命中了则优先执行节点注入;仅当未命中任何节点且落在画布空白区域时,才触发 `dropOnCanvas` 载入工作流**。
|
||||
2. 在 `ui_material_cards.js` 中,只要素材包含工作流(`material.has_workflow` 或 `material.data?.workflow`),就为其绑定 `dropOnCanvas: async (e, data, graph) => openMaterialWorkflow(owner, material.filename)`,并在 `material.node_types` 为空时也允许拖拽。
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 缺陷 3(交互体验问题):扫描向导(Scan Wizard)缺少遮罩点击关闭 & 纵向溢出
|
||||
|
||||
#### 1. 问题表现
|
||||
- 扫描向导使用原生的 HTML `<dialog>` 挂载并使用 `showModal()`。
|
||||
- 当弹窗内容较多(包含多项模型目录开关与说明)时,若屏幕纵向高度较小,底部“开始扫描”与“取消”按钮会被推到视口外部。
|
||||
- 用户尝试点击周围半透明深色遮罩(Backdrop)时,无法关闭弹窗;由于未在 `dialog` 上绑定 `click` 检测 `event.target === dialog` 的关闭逻辑,用户必须手动向下滚动寻找底部取消按钮。
|
||||
|
||||
#### 2. 优化建议
|
||||
- 在 `ui_scan_wizard.js` 中,为 Dialog 添加点击遮罩关闭逻辑:
|
||||
```javascript
|
||||
dialog.addEventListener('click', (e) => {
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
const isInDialog = (rect.top <= e.clientY && e.clientY <= rect.top + rect.height
|
||||
&& rect.left <= e.clientX && e.clientX <= rect.left + rect.width);
|
||||
if (!isInDialog) dialog.close();
|
||||
});
|
||||
```
|
||||
- 限制 `max-height: 85vh` 并对内部表单容器开启 `overflow-y: auto`,确保底部的操作按钮始终固定可见(Sticky Bottom)。
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 缺陷 4(同步与竞态隐患):模型单次精准扫描后前端下拉列表刷新时机
|
||||
|
||||
#### 1. 问题表现
|
||||
- 在针对单个模型触发精准扫描(`triggerDirectModelScan`)时,后端会计算 SHA-256 并生成 `.info` 文件。
|
||||
- 前端在通知扫描成功后,虽然调用了 `window.anomalous_reload_hashes()`,但在 ComfyUI 原生节点的下拉菜单选项(`widget.options.values`)重新拉取存在微弱的异步延迟,此时若立即在 Model Doctor 中点击“自动修复”,偶尔会触发 `optionsCacheStale`,需等待二次刷新才能消除红框。
|
||||
|
||||
#### 2. 优化建议
|
||||
- 在单模型扫描写入完成后,增加 `await fetch('/anomalous/clear_cache', { method: 'POST' });` 并等待 `await app.refreshComboInNodes()` 完成后再触发 Doctor 的状态比对。
|
||||
|
||||
---
|
||||
|
||||
## 三、 总结与排查结论
|
||||
|
||||
1. **整体功能架构**:
|
||||
插件整体架构非常先进且完整,Model Doctor、Material Library、Recipe 工作流、Scan Wizard 的多面板协调运作良好,UI 设计美观、动效细腻。
|
||||
2. **核心问题收敛**:
|
||||
- **核心 Bug 1**(`api/model_resolution.py` 误报 `identity_conflict`)是必须修复的高危逻辑漏洞,它直接导致工作流模型恢复方案失效;
|
||||
- **核心 Bug 2**(素材库拖拽无响应及画布/节点优先级颠倒)直接影响了用户对于“素材一拖即用”的核心预期。
|
||||
|
||||
---
|
||||
|
||||
## 四、 修复复查与验收记录 (Verification Sign-off)
|
||||
|
||||
- **验收时间**:2026-09-19 20:30
|
||||
- **修复 Commit**:`04b2eeff8f` (*fix(materials, doctor, scan): polymorphic material drag, third-party prompt nodes, model resolution, and scan wizard improvements*)
|
||||
- **复查录屏文件**:`anomalous_fix_verify_1789820782702.webp`
|
||||
|
||||
### 1. 自动化回归测试结果
|
||||
- **Python 后端单元测试**:运行 `unittest discover -s tests -p "test_*.py"`,**99 个测试全数通过 (OK)**。
|
||||
- 特别验证 `test_dynamic_hash_mismatch_is_rejected` 与空候选测试,确认无哈希匹配时不再误报 `identity_conflict`。
|
||||
- **前端契约与模块测试**:运行全部 32 个前端 `.mjs` 测试(涵盖 `studio_contracts.mjs`、`sidebar_feature_modules.mjs`、`material_feature_modules.mjs` 等),**32 项全部通过 (PASSED)**。
|
||||
|
||||
### 2. 浏览器端到端实测验证
|
||||
1. **模型修复与身份冲突 (Model Doctor)**:
|
||||
- 模型比对逻辑恢复正常,本地缺失模型返回 `found: False`,恢复正常下载与引导逻辑;
|
||||
- 唤起模型医生点击 `🔄 刷新缓存` 顺畅运行,控制台 0 报错。
|
||||
2. **素材库多态拖拽 (Polymorphic Material Drag)**:
|
||||
- 全图工作流素材:卡片 Tooltip 提示更新为“按住拖至空白画布载入完整工作流”,拖拽至空白画布成功加载完整图谱;
|
||||
- 提示词素材:支持拖拽至任意文本/提示词节点注入文本,或拖至空白画布自动创建对应 `CLIPTextEncode` 节点;
|
||||
- 节点参数素材:拖拽至目标节点优先注入参数,拖至空白画布新建对应节点;修复了过去由于优先级颠倒导致的画布与节点拦截冲突。
|
||||
3. **扫描向导 (Scan Wizard)**:
|
||||
- 弹窗底部操作按钮已设置固定粘性定位(Sticky Bottom),在任何屏幕高度下均不会被挤出视口;
|
||||
- 点击外部暗色遮罩 Backdrop 立即平滑关闭向导;
|
||||
- 按下键盘 `Escape` 键立即响应并关闭向导。
|
||||
4. **控制台与稳定性**:
|
||||
- 全流程操作无任何未捕获的 JavaScript 异常或 HTTP 异常状态码。
|
||||
|
||||
**验收结论**:本次修复完整、精准,所有指出的严重缺陷及交互体验问题均已彻底解决并闭环。
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 审计与测试报告索引 (Audits & Verification Reports)
|
||||
|
||||
本目录专门用于归档与整理 Anomalous Model Browser 插件在各开发阶段的端到端浏览器实测、质量审计、缺陷复原以及验收记录。
|
||||
|
||||
---
|
||||
|
||||
## 报告目录归档
|
||||
|
||||
| 日期 | 报告文件 | 测试类型 | 涉及模块 | 验收状态 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **2026-09-19** | [AUDIT_AND_BUG_REPORT.md](file:///e:/ComfyUI_windows_portable/ComfyUI/custom_nodes/Anomalous_Model_Browser/docs/audits/AUDIT_AND_BUG_REPORT.md) | E2E 浏览器全功能实测 & 缺陷深度排查 | Model Doctor / 素材库多态拖拽 / 扫描向导 / 模型恢复 | ✅ 已全数修复并验收闭环 |
|
||||
|
||||
---
|
||||
|
||||
## 维护规范
|
||||
1. 新增功能测试、跨版本兼容性体检或重大 Bug 排查报告时,请统一存放在本目录 (`docs/audits/`)。
|
||||
2. 保持插件根目录整洁,避免在根目录直接堆放临时或专项测试文档。
|
||||
@@ -0,0 +1,117 @@
|
||||
# Current Architecture Decisions
|
||||
|
||||
This index records concise reasons for enduring product and architecture
|
||||
boundaries. It is not a changelog. When a decision becomes obsolete, update or
|
||||
replace the record and rely on Git history for the former implementation.
|
||||
|
||||
## AD-001 — One command, exclusive browser entries
|
||||
|
||||
The floating trigger, action-bar button, and Extensions command share one
|
||||
`openBrowser()` command and one browser instance. Presentations are mutually
|
||||
exclusive, while the Extensions command always remains as a recovery route.
|
||||
Native ComfyUI command/keybinding infrastructure owns shortcut recording and
|
||||
modal guards.
|
||||
|
||||
## AD-002 — Recipes are templates; notebooks are immutable instances
|
||||
|
||||
A Workflow Recipe is the authoritative workflow template. Parameter Notebooks
|
||||
are immutable generation-value snapshots attached to a recipe and browsed inside
|
||||
its Parameters tab. The Node Assistant may reuse those snapshots for a selected
|
||||
same-type node, but this does not replace full-skeleton recipe application.
|
||||
|
||||
## AD-003 — Recipe canvas actions follow graph scope
|
||||
|
||||
Recipe cards and detail views append partial recipes because their open required
|
||||
connections make them composition fragments. Complete, independently runnable
|
||||
recipes load through ComfyUI's workflow path and therefore open as a new canvas
|
||||
in the current frontend. Structural editing remains a separate, explicitly
|
||||
confirmed workflow.
|
||||
|
||||
## AD-004 — Recipe save hides implementation policy
|
||||
|
||||
Save collects identity, notes, tags, and cover/source image. Presentation pins
|
||||
and snapshot policy are implementation choices, not per-save questions. Legacy
|
||||
pin data is preserved without exposing a control; new recipes default to none.
|
||||
|
||||
## AD-005 — Model identity excludes names and previews
|
||||
|
||||
Model Doctor automatically proves identity through provenance hash and required
|
||||
category; byte size may disambiguate that hash. A unique size-only match is a
|
||||
manual candidate, never proof or an automatic redirect. Hash/size conflicts are
|
||||
rejected. Local paths, filenames, display names, previews, and similarity are
|
||||
presentation or post-resolution routing data. This boundary prevents silent
|
||||
substitution after local rename or across categories.
|
||||
|
||||
## AD-006 — Foundation components are hash-only recovery categories
|
||||
|
||||
VAE, VAE Approx, CLIP/Text Encoder, and CLIP Vision files can commonly share
|
||||
sizes or ambiguous names. Automated redirection therefore requires one exact
|
||||
in-category hash match. A size-only candidate may still be shown for explicit,
|
||||
one-time confirmation on the current node. Existing native combo values remain
|
||||
usable with a visible identity-change warning when provenance differs. This does
|
||||
not restrict scan coverage: scanning may calculate hashes for any active
|
||||
registered folder, including categories whose Civitai metadata coverage is weak.
|
||||
|
||||
## AD-007 — Recipe origins and local availability are separate
|
||||
|
||||
Official origin metadata can be refreshed or edited independently of saved
|
||||
identity. Exact-path availability checks and previews describe the current
|
||||
machine; they do not rewrite imported provenance or activate a local match.
|
||||
|
||||
## AD-008 — Prompt roles prefer unknown over a guess
|
||||
|
||||
Automatic prompt role tracing follows only allowlisted native prompt,
|
||||
conditioning, and consumer nodes. Unknown third-party nodes are opaque. Legacy
|
||||
full-value recovery must be exact and unambiguous, and users may store explicit
|
||||
recipe-scoped overrides.
|
||||
|
||||
## AD-009 — Recipe-to-model navigation is recoverable and exclusive
|
||||
|
||||
Model detail temporarily replaces all main content surfaces while retaining a
|
||||
small recipe return token. Back reconstructs recipe detail; unrelated navigation
|
||||
abandons the token and cleans stale state. The transition never closes the outer
|
||||
browser or exposes model detail beside Node Assistant.
|
||||
|
||||
## AD-010 — Parameter selection must identify both panes
|
||||
|
||||
The active Parameter Notebook is visible in the list and in a right-pane banner
|
||||
with name and timestamp. Requests are selection-tokened so stale gallery results
|
||||
cannot overwrite the new notebook. Sidebar input/button rows must remain usable
|
||||
in the narrow docked layout.
|
||||
|
||||
## AD-011 — Output discovery remains bounded and user-refreshed
|
||||
|
||||
Recipe matching inspects at most the newest bounded set of PNG metadata and has
|
||||
no persistent index. The main Gallery scans on open and refreshes manually. This
|
||||
avoids background polling and hidden indexing cost while retaining predictable
|
||||
freshness.
|
||||
|
||||
## AD-012 — Licensing and branding are separate
|
||||
|
||||
The standard MIT `LICENSE` applies to project-owned code and documentation.
|
||||
`TRADEMARKS.md` separately governs the Anomalous Model Browser name and official
|
||||
branding. Project-specific restrictions are not appended to the MIT license.
|
||||
|
||||
## AD-013 — Beta guidance is scoped to experimental workflow features
|
||||
|
||||
Workflow Recipes and recipe-powered Assistant presets carry localized backup and
|
||||
data-safety guidance until an explicit stability decision removes it. Stable
|
||||
Node Assistant model replacement and LoRA insertion are outside that warning.
|
||||
Notices distinguish single-node preset application from full-skeleton Parameter
|
||||
Notebook application.
|
||||
|
||||
## AD-014 — Architecture documentation is state, not history
|
||||
|
||||
Architecture documents change only when an owner, data flow, interface contract,
|
||||
persistence format, security boundary, or critical invariant changes. Ordinary
|
||||
fixes do not append “unchanged” entries. Git is the implementation history;
|
||||
`CHANGELOG.md` is user-facing release history; `.agents/logs/ai_lessons.md` holds
|
||||
only durable recurring traps.
|
||||
|
||||
## AD-015 — Recipe model mutations update all identity representations
|
||||
|
||||
The authoritative workflow widget, structured model reference, and node-scoped
|
||||
Model Doctor Hash index form one mutation boundary. Local matching updates all
|
||||
three through the archived full-recipe path. Partial append remaps Hash records
|
||||
with node IDs and rolls them back with the inserted graph. Personal model notes
|
||||
are recipe-scoped presentation data and may be excluded from package export.
|
||||
@@ -0,0 +1,248 @@
|
||||
# 代码结构拆分计划(交给 GPT-5.6-sol)
|
||||
|
||||
日期:2026-09-17。代码基线:`29b14d2c7`。**状态:阶段 0–7 已完成;自动化验收通过,真实 ComfyUI 宿主视觉/交互验收仍需在运行中的宿主完成。**
|
||||
|
||||
用户当前优先级是整理代码结构,暂停其他 UI 美化与功能扩展。本文保留原实施任务单,并在末尾记录实际完成结果;当前架构以专题文档和代码为准。
|
||||
|
||||
## 1. 要达到的结果
|
||||
|
||||
将大文件按实际职责拆开,做到修改一个页面或一项行为时,不必理解整个浏览器。保留现有界面、操作、接口和用户数据。每完成一个可验证的小阶段就本地提交,继续下一阶段,不必逐项询问用户。
|
||||
|
||||
不是把大文件切成 `part1/part2`,也不是迁移到新框架。不得为拆分引入打包系统、事件总线、通用服务容器或新依赖。
|
||||
|
||||
阅读顺序:[AGENTS.md](../../AGENTS.md) → [架构入口](../../ARCHITECTURE.md) → 当前阶段对应的专题。不要每轮通读所有文档。本文的文件名是拟定归属,先搜索是否已有等价模块;存在时优先复用。
|
||||
|
||||
## 2. 当前规模与真正的拆分点
|
||||
|
||||
以下是含空行的物理行数,仅用于定位;执行时按函数和调用方搜索,不按固定行号切割。
|
||||
|
||||
| 文件 | 行数 | 主要问题 |
|
||||
| --- | ---: | --- |
|
||||
| `web/styles.css` | 14,620 | 多个页面、主题和响应式规则混在一起,层叠顺序容易被破坏 |
|
||||
| `web/modules/ui_recipe_detail.js` | 3,348 | 总览、模型匹配、参数编辑、版本、图库集中在一个模块 |
|
||||
| `web/modules/ui_sidebar.js` | 2,963 | `createDOM()` 约 2,250 行,包含扫描向导、设置、工具箱、导航和窗口交互 |
|
||||
| `web/modules/ui_doctor.js` | 2,186 | 模型诊断、节点助手、替换图库、参数方案交叉放置 |
|
||||
| `web/modules/ui_detail.js` | 1,938 | 模型详情、编辑弹窗、高级选择器合并 |
|
||||
| `api/recipes.py` | 1,514 | 工作流规范化、身份信息、图片、文件存储、历史和 HTTP 路由混合 |
|
||||
| `web/modules/ui_gallery_detail.js` | 1,453 | 图片工作台生命周期、媒体交互、参数检查与保存操作集中 |
|
||||
| `web/main.js` / `api/models.py` | 各 1,413 | 前者注册、设置、实例、浮动入口耦合;后者目录、媒体、修改、匹配路由耦合 |
|
||||
| `web/modules/ui_recipes.js` | 1,297 | 列表筛选、卡片、保存/编辑对话框与保存流程混合 |
|
||||
| `api/materials.py` | 1,294 | 素材构建、提示词规则、资源存储、查询、路由混合 |
|
||||
| `web/modules/ui_materials.js` | 1,186 | 目录、详情、节点选择监听、应用操作混合 |
|
||||
| `web/modules/ui_notebooks.js` | 1,042 | 工作台返回、笔记目录、编辑器和节点创建混合 |
|
||||
|
||||
`locales.js` 为 2,373 行,主体是字典,不优先拆。已有提示词工坊、来源中控等模块虽然仍较大,已有明确边界,放到后续复核,避免第一轮同时动所有系统。
|
||||
|
||||
**已确认的耦合:**
|
||||
|
||||
- `main.js` 通过 `AnomalousBrowser.prototype` 接入各 UI 模块。首轮保留方法名、参数和 `this` 绑定,只调整导入归属;不要同时更换整个实例模式。
|
||||
- `ui_sidebar.js` 的扫描状态、语言刷新、按钮和导航大量依赖 `createDOM()` 闭包。移动函数前必须列出它读取、修改的外部变量。
|
||||
- `api/materials.py` 从 `recipes.py` 导入了 12 个名称;`parameters.py` 也导入其中的参数签名、读取与图库辅助函数。后端不能把每份文件单独移动后再补循环导入。
|
||||
- `ui_gallery_detail.js` 有模块级 `wb` 和元数据缓存。它们不能在多个新文件各复制一份。
|
||||
- `api/__init__.py` 目前用星号导入路由。最后接线时应改成明确的模块引用,防止移动后隐式导出丢失或名称覆盖。
|
||||
|
||||
## 3. 拆分时保持的边界
|
||||
|
||||
依赖方向采用现有结构:**宿主接线 → 页面协调与视图 → 领域逻辑/存储与已有公共工具**。纯逻辑不能反向导入页面,子视图不能导入 `main.js` 获取实例。
|
||||
|
||||
- 每项状态只保留一份。已有跨页返回状态先留在当前 owner;局部 DOM、请求和清理归所属视图。迁移状态和移动代码分开提交,避免难以区分故障来源。
|
||||
- 为实际功能传入必要容器、数据和回调。若拆分需要几十个闭包参数,说明边界选错,应把相关状态和行为一起归入该功能模块。
|
||||
- 模块接入时直接修改所有内部调用方。只有确有外部调用或兼容需求的入口才保留薄转接,不建立永久的重复实现。
|
||||
- 同一阶段保持路由 URL、请求/响应结构、文件路径、字段、存储锁、撤销行为、节点连接与参数写入语义不变。不迁移用户数据。
|
||||
- 复用 `ui_lifecycle.js`、`ui_dom.js`、`recipe_actions.js`、`recipe_parser.js`、`recipe_identity.js`、`node_material_actions.js` 等现有能力,不再复制一套。
|
||||
- 普通逻辑模块通常以 200–600 行为参考。确有多个职责才继续拆,不能靠压缩代码、删注释或空壳包装达标。语言表和样式表不套这个阈值。
|
||||
- 文件改放子目录时重新计算相对导入,特别是 ComfyUI 的 `scripts/app.js` 和图片 URL。导入无副作用的模块不应注册第二个扩展、启动扫描或立即创建页面。
|
||||
|
||||
## 4. 分阶段执行
|
||||
|
||||
每个阶段允许拆成几个提交;一个提交完成一个可验证的职责迁移。不要一次把以下所有文件建好再尝试运行。
|
||||
|
||||
### 阶段 0:建立本轮基线
|
||||
|
||||
1. 查看状态和最新差异,记录起始提交;保留他人的改动。
|
||||
2. 为即将移动的职责记录:入口/调用方、读写状态、全局监听、请求、DOM/CSS 依赖、关闭路径。只写本阶段,不建立全仓超大清单。
|
||||
3. 先运行本阶段已有的相关测试,记录已有失败。需要补测试时优先覆盖真实行为,而不是源码中是否存在某个字符串。
|
||||
4. 记录相关页面的当前视觉和交互。后续比较必须使用同一主题、窗口尺寸和数据;不拿不同时期截图作基线。
|
||||
|
||||
### 阶段 1:先从侧栏移走独立功能
|
||||
|
||||
目标文件:`ui_sidebar.js`。第一刀选扫描向导,不先重写导航。
|
||||
|
||||
| 拟建模块 | 迁移范围与接口 |
|
||||
| --- | --- |
|
||||
| `ui_scan_wizard.js` | `createWizardModal()` 及其专属构建/扫描控制;接收全局或指定模型范围与完成回调,返回打开、关闭与语言刷新所需接口 |
|
||||
| `ui_folder_manager.js` | `openFolderManager()` 与该对话框专属请求、DOM、关闭逻辑;原来的目录浏览 `renderSidebar/loadFolders` 暂时保留 |
|
||||
| `ui_help.js` | `showHelp()` 及内容构建,仅在打开时创建所需内容 |
|
||||
|
||||
扫描进度继续使用 `scan_progress.js`,不能新增另一份“是否正在扫描”。若该状态也被全局按钮使用,明确由扫描功能拥有,按钮只读取/订阅实际状态。关闭向导是否取消扫描必须保持当前语义,不能把 UI 关闭误当成后台任务取消。
|
||||
|
||||
验收:扫描向导打开/关闭、单模型与全局入口、失败反馈、文件夹管理取消与保存刷新、帮助关闭。涉及扫描或写文件的验证优先使用隔离数据与桩服务,不自动扫描用户整个模型库。
|
||||
|
||||
### 阶段 2:收拢外壳和入口
|
||||
|
||||
在阶段 1 稳定后,从 `createDOM()` 提取:
|
||||
|
||||
- `ui_settings_hub.js`:设置中心与模型显示设置。接口覆盖 open/close/refreshLanguage,保留原来的设置来源、键名和生效时机。
|
||||
- `ui_toolbox.js`:工具箱内容、开关和底部按钮装配;复用 `tool_registry.js`、`shortcut_layout.js`、`sidebar_actions.js`、`ui_shortcut_organizer.js`。按**当前代码**保持固定栏与工具过滤规则,不顺便实施旧计划中的另一种交互。
|
||||
- `ui_browser_navigation.js`:主面板切换与返回协调。先收拢现有 `hideAllPanels/closeWorkspace` 相关调用,不引入新的路由系统;笔记模块最终不再独占其他工作台的公共关闭逻辑。
|
||||
- 窗口拖动、缩放、停靠先保留在外壳;仍形成独立大段时再提取 `browser_window.js`,复用生命周期工具,不改变尺寸偏好。
|
||||
|
||||
`createDOM()` 最终只创建骨架、装配功能、连接明确回调。初始化完所有被引用的控件后再绑定/刷新语言,避免迁移后出现 TDZ 或读取尚未初始化的按钮。
|
||||
|
||||
随后整理 `main.js`:浏览器类与方法接线放到 `browser.js`,浮动入口放到 `browser_entry.js`,设置描述与语言/主题应用放到 `interface_settings.js`。扩展注册、实例创建顺序和宿主钩子仍由 `main.js` 统一协调。设置模块通过参数/回调工作,不能反向导入 main;保留被其他模块调用的现有全局入口,先核实调用者再删除。
|
||||
|
||||
验收:首次打开、关闭重开、各主页面切换、配方→模型详情→配方、语言切换、设置保存、工具箱提示、停靠、窗口拖动与调整尺寸。确认只有一个浏览器实例和一份全局监听。
|
||||
|
||||
### 阶段 3:拆配方详情,再拆目录
|
||||
|
||||
`ui_recipe_detail.js` 留下 `showRecipeDetail()`、活动页签、一次详情会话的状态与生命周期协调。按下面顺序逐块移动:
|
||||
|
||||
1. `ui_recipe_versions.js`:`renderVersions/renderDiffPanel`。继续用 `recipe_diff.js`;恢复版本后的刷新由明确回调交回详情协调器。
|
||||
2. `ui_recipe_gallery.js`:`renderRecipeGallery/showGalleryComparison` 及专属资源处理;图片详情打开通过回调或直接依赖现有图片工作台入口,不能绕素材目录模块中转。
|
||||
3. `recipe_model_matching.js` + `ui_recipe_models.js`:前者负责匹配操作与结果,后者负责组成卡片、来源编辑和本地匹配操作界面;复用身份规则,不能从名称/预览图推断同一模型。
|
||||
4. `recipe_parameter_data.js` + `ui_recipe_parameters.js`:先移出 `parameterNodeOrder/topologicalSortNodes/parseEditorValue/paramsWithPromptRole` 等无 DOM 逻辑,再移参数方案 UI。较大的原始节点检查与编辑器可分别放 `ui_recipe_node_inspector.js`、`ui_recipe_parameter_editor.js`。
|
||||
5. `ui_recipe_overview.js`:总览与元数据编辑,参数正文读取继续遵循真实工作流,不改用列表摘要。
|
||||
|
||||
不要把所有小函数扔进 `recipe_detail_utils.js`。格式化工具仅在确有多个调用者时共享;请求成功后的刷新由协调器处理,不让子页互相直接调用重绘。
|
||||
|
||||
接着将 `ui_recipes.js` 的 `showRecipeSaveDialog/showRecipeEditDialog` 放到 `ui_recipe_dialogs.js`,卡片渲染放 `ui_recipe_cards.js`;列表查询、筛选、分页与页面状态留在目录模块。`handleSaveRecipe` 继续协调实际捕获和保存,不能在新对话框中复制该流程。
|
||||
|
||||
验收:配方筛选与详情、长提示词、原始参数、参数方案保存再读回、模型详情往返、历史比较/恢复、图库打开/关闭。配方包导入导出保持当前关闭状态,移除的入口不恢复。
|
||||
|
||||
### 阶段 4:拆其余混合页面
|
||||
|
||||
逐行执行下表,每行独立提交与验证,禁止把全部页面同时改成半成品。
|
||||
|
||||
| 当前文件 | 拟定归属 | 重点验证 |
|
||||
| --- | --- | --- |
|
||||
| `ui_doctor.js` | 保留诊断面板和全局扫描;节点助手到 `ui_node_assistant.js`;`_openGalleryReplacer` 到 `ui_node_model_picker.js`;参数方案卡片与预览到 `ui_node_presets.js` | 哈希依据不变;替换正确节点;取消无写入;助手历史可用 |
|
||||
| `ui_detail.js` | 保留 `showDetail`;`showEditModal` 到 `ui_model_editor.js`;`_openAdvancedModelSelector` 到 `ui_model_selector.js` | 详情往返、编辑保存、选择器取消、原节点与路径不串 |
|
||||
| `ui_gallery_detail.js` | 保留工作台打开/关闭与唯一 `wb`;舞台缩放/胶片栏到 `ui_image_stage.js`;参数、提示词、模型与节点检查到 `ui_image_inspector.js` | 快速换图无旧结果覆盖;关闭释放媒体与请求;选中块保存准确 |
|
||||
| `ui_materials.js` | 保留目录与分页;详情到 `ui_material_detail.js`;卡片到 `ui_material_cards.js`;应用和选择监听尽量归入已有 `ui_material_application.js` | 素材拖放仍整体替换;节点身份复核;详情读正文;翻页与筛选不丢 |
|
||||
| `ui_notebooks.js` | 保留笔记目录与保存协调;编辑器到 `ui_notebook_editor.js`;画布创建到 `notebook_canvas.js`;公共返回已归阶段 2 | 保存队列和重开不丢内容;正负提示词隔离;节点创建与连接不变 |
|
||||
|
||||
图片元数据缓存可以留在协调模块,缓存上限和失效规则保持不变。需要跨模块使用时暴露窄访问接口,不复制缓存,不把 `wb` 作为可随意修改的全局对象导出。
|
||||
|
||||
### 阶段 5:样式独立拆分,不同时重新设计
|
||||
|
||||
保留 `web/styles.css` 作为外部入口。先核对 `main.js` 的 CSS 加载逻辑,再把样式拆到 `web/styles/` 下的基础控件、外壳、模型、图库、配方、素材、提示词、工具与主题等文件。
|
||||
|
||||
**首先保证原始层叠顺序。** 不能按关键词把分散的同名选择器直接合并搬走。先记录规则顺序与主题/媒体条件;需要时按连续职责块分两步迁移,稳定后再清理重复声明。混合多个领域的选择器先归公共部分,未经确认不拆成几个不同位置的规则。
|
||||
|
||||
低风险方案是入口顶部有序 `@import`。若使用多 link,也必须只有一份确定顺序的清单,并保留防重复加载。二选一,不能双重加载。不要引入 CSS cascade layers,因为它会改变已有优先级。
|
||||
|
||||
迁移时检查相对 `url(...)` 资源路径、`@keyframes`、主题限定、媒体查询和插件作用域;子样式的缓存刷新也要验证,不能只刷新入口版本。检查网络中是否缺文件、串行请求造成明显闪烁。没有测试依据,不顺便做按页面动态加载。
|
||||
|
||||
验收:同条件截图/关键计算样式对比,普通主题与深海血族、普通窗口与停靠、浮层层级、文件夹隐藏/恢复、提示定位、拖放与键盘焦点。此阶段不换配色、间距、动画或 DOM 结构。
|
||||
|
||||
### 阶段 6:先拆后端共享领域,再移动路由
|
||||
|
||||
先读 [后端专题](../architecture/backend.md)、[配方专题](../architecture/recipes.md)、[素材专题](../architecture/material-library.md)。
|
||||
|
||||
1. 从 `recipes.py` 提取 `workflow_schema.py`:工作流校验、指纹、参数签名、易变字段规则等纯计算。确属配方专有的规范化放 `recipe_schema.py`;不要把文件路径和 HTTP request 带入纯逻辑。
|
||||
2. 提取 `recipe_store.py`:目录、读取、写入与历史操作;提取 `recipe_images.py`:来源图片验证、读取与封面处理。路径授权仍调用现有安全工具,I/O 仍放到原来的工作线程边界。
|
||||
3. 同步改 `materials.py`、`parameters.py` 和 `recipe_packages.py` 的调用。后者目前是 `from . import recipes as recipe_store`,这个别名指向旧路由模块,不是拟建的存储模块。逐项检查其 `recipe_store.xxx` 访问,不能只搜索导入行或直接把别名换成新文件。
|
||||
4. 路由模块依赖领域与存储模块,领域模块不得反向导入路由。锁和缓存只保留在实际拥有者中,同一类写入不能因拆文件变成两把独立锁。
|
||||
5. `api/recipes.py` 最终保留 HTTP 参数解析、错误/状态码映射、调用和响应。不要为了“薄路由”把整个旧文件原封不动搬进另一个 1,500 行的 service。
|
||||
|
||||
随后拆 `api/models.py`:目录查询/列表、身份解析、元数据修改、媒体与封面分别归入 `model_catalog.py`、`model_resolution.py`、`model_metadata.py`、`model_media.py`。先按现有端点边界移动,公共路径与类别规则继续复用 `utils.py` 和根目录的 `model_policies.py/model_identity.py`。保留跨目录回退和受保护类别规则。
|
||||
|
||||
最后拆 `api/materials.py`:`material_schema.py` 负责构建与提示词规范化,`material_store.py` 负责记录及资源生命周期,路由模块负责请求响应和有界查询。若存储模块仍大,再将图片资产操作独立成 `material_assets.py`,不预先造空模块。
|
||||
|
||||
`api/__init__.py` 改成明确的模块引用和注册,例如 `from . import models`、`models.api_get_models`;比较拆分前后的 **HTTP method + path** 清单,避免少注册、多注册或重复注册。用户设置和导入导出 gate 保持不变。
|
||||
|
||||
验收全部使用临时目录与隔离配置:保存/读取往返、非法路径拒绝、失败保留旧文件、历史恢复、素材分页过滤、修改封面/元数据、禁用导入导出的 503。不能通过删除或覆盖用户真实数据测试。
|
||||
|
||||
### 阶段 7:收尾与剩余热点复核
|
||||
|
||||
- 再统计大文件,按职责复核 `ui_prompt_workbench.js`、`ui_prompt_source_deck.js`、`ui_model_sources.js`、`material_inspector.js` 和 `api/utils.py`。有独立职责才继续拆,并记录边界;不能仅因超过阈值机械拆分。
|
||||
- 复核导入环、无人调用的旧导出、遗留重复实现和 `EXTRACTED` 占位。不要用任意 optional chaining 或空 catch 掩盖断掉的接线。
|
||||
- 更新实际发生变化的架构专题与入口所有者列表。此时删除过时说明,不能把本文的拟定方案提前复制成已实现架构。
|
||||
- 本计划逐阶段补充提交号、验证和剩余事项;发生中断时,下一轮从这里继续,不重新扫描整个仓库。
|
||||
|
||||
## 5. 验证与效率要求
|
||||
|
||||
每阶段先做 JS 语法/模块链接或 Python 编译/导入检查,再跑相关行为测试。涉及界面和宿主行为时还需真实 ComfyUI 验证,语法通过不能代替点击路径。移动模块后更新测试入口,尽量加载实际模块,不把待测行为本身 mock 成成功。
|
||||
|
||||
当前存在的定向检查(从插件根目录运行)。下表每个 JS 文件分别用 `node --experimental-vm-modules <文件路径>` 执行;此处参数表示替换成表内实际路径,不是一次执行整表:
|
||||
|
||||
| 影响范围 | 现有检查 |
|
||||
| --- | --- |
|
||||
| 外壳/快捷栏 | `tests/shortcut_layout.mjs`、`tests/shortcut_organizer.mjs`、`tests/update_guide.mjs` |
|
||||
| 素材与节点应用 | `tests/material_workspace_flow.mjs`、`tests/studio_contracts.mjs` |
|
||||
| 笔记 | `tests/notebook_render_test.mjs`、`tests/notebook_save_roundtrip.mjs` |
|
||||
| 提示词工坊/读取 | `tests/prompt_ui_lifecycle.mjs`、`tests/prompt_material_source.mjs`、`tests/prompt_library_sync.mjs` |
|
||||
| 模型来源 | `tests/model_sources_hub.mjs` |
|
||||
| 导出 gate | `tests/export_availability.mjs`;Python 用便携环境执行 `python -B tests/test_export_availability.py` |
|
||||
|
||||
表中测试存在不等于本轮已经通过。配方详情、模型编辑、扫描向导、后端存储等未被这些测试充分覆盖的路径,需要有针对性地补行为验证,不能拿无关测试通过数充当覆盖。
|
||||
|
||||
新增测试先核对 `.gitignore`,必须明确哪些随仓库交付。每次只跑受影响的集合;阶段完成后不重复跑同一组,除非又改了代码或发现新问题。
|
||||
|
||||
完成标准:原调用方都接入新归属;原文件没有第二份实现;关键行为验证通过;入口和返回仍可用;无新增持续轮询、全量数据加载或监听泄漏。行数下降本身不是验收标准。
|
||||
|
||||
## 6. 交给 GPT-5.6-sol 的启动指令
|
||||
|
||||
> 请按 docs/plans/code-structure-refactor.md 整理 Anomalous_Model_Browser 的代码结构。先读 AGENTS.md 和相关架构专题,确认最新差异,从阶段 0、阶段 1 开始,然后按依赖顺序继续。保留现有外观、功能、数据和接口,不顺手美化,不恢复已关闭或移除的功能。
|
||||
>
|
||||
> 每次完成一个职责迁移,检查调用方、状态归属、闭包依赖和关闭路径,运行相关验证后本地提交,再继续。常规实现选择自行处理;遇到已有故障先区分基线与回归,不能隐藏或伪造成功。不要把所有文件一次拆完才验证。
|
||||
>
|
||||
> 每阶段在计划中记录实际模块归属、提交号、验证结果与未完成项。需要中断时留下可接续状态;不推送远端。最终说明职责如何变清楚、哪些行为已验证,以及还有哪些真实限制。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 阶段 0(完成):以 `c39701773` 为本轮起点,开工时工作区干净。基线通过
|
||||
`shortcut_layout.mjs`、`shortcut_organizer.mjs`、`update_guide.mjs`;未建立真实
|
||||
ComfyUI 点击截图基线,因此后续不能把 DOM 模拟检查称为宿主视觉验收。
|
||||
- 阶段 1(完成,`2a53e3edf`):扫描向导归 `ui_scan_wizard.js`,文件夹管理归
|
||||
`ui_folder_manager.js`,帮助弹窗归 `ui_help.js`。单模型卡片与全局扫描入口共用
|
||||
`openScanWizard()`;新增行为检查覆盖帮助关闭、文件夹取消/保存刷新、单模型扫描请求
|
||||
与向导关闭不取消后台扫描。
|
||||
- 阶段 2(代码拆分完成,`1e42033ac`、`67d5be6277`、`a984ee4a31`):设置中心与模型卡显示设置归
|
||||
`ui_settings_hub.js`,工具目录、固定快捷栏和工具分派归 `ui_toolbox.js`,公共面板
|
||||
隐藏/配方详情清理归 `ui_browser_navigation.js`。`ui_sidebar.js` 已从约 2,963 行降至
|
||||
约 780 行,`createDOM()` 现在主要保留外壳、顶部导航、窗口交互和子模块装配。
|
||||
浏览器类和方法装配归 `browser.js`,浮动/顶栏/菜单入口及唯一实例归
|
||||
`browser_entry.js`,语言和主题设置归 `interface_settings.js`;`main.js` 现只协调
|
||||
扩展注册与宿主钩子。笔记、配方和素材共用的工作区返回协调也已迁入
|
||||
`ui_browser_navigation.js`。新增模块边界测试覆盖注册描述、入口设置、语言、主题与
|
||||
工作区恢复;真实 ComfyUI 拖动、停靠与窗口缩放仍待宿主验收。
|
||||
- 阶段 3(代码拆分完成,`d8691d881`、`413b876dfe`、`56774a2d99`、`881570e887`、`e5f3301971`):配方版本历史/差异/恢复归
|
||||
`ui_recipe_versions.js`,结果图库与图片工作台跳转归 `ui_recipe_gallery.js`,跨子页
|
||||
共用的 DOM、长值展示和复制控件归 `ui_recipe_detail_dom.js`。删除了无人调用的旧图库
|
||||
对比实现;图库不再借道素材目录模块打开图片工作台。目录筛选归
|
||||
`ui_recipe_catalog.js`,卡片与拖拽归 `ui_recipe_cards.js`,保存/编辑对话框归
|
||||
`ui_recipe_dialogs.js`,封面媒体归 `ui_recipe_media.js`;模型预览、匹配与显式替换归
|
||||
`ui_recipe_model_matching.js`,总览归 `ui_recipe_overview.js`,提示词角色、参数编辑、原始节点与
|
||||
参数预设归 `ui_recipe_parameters.js`,内联持久化归 `ui_recipe_metadata.js`。协调器
|
||||
`ui_recipe_detail.js` 从约 2,983 行降至约 760 行,`ui_recipes.js` 从约 1,297 行降至约
|
||||
690 行。新增检查覆盖版本恢复、图库交接、目录卡片隔离、模型替换持久化、参数拓扑/解析、
|
||||
总览和提示词提取;真实 ComfyUI 视觉与交互仍待宿主验收。
|
||||
- 阶段 4(完成,`e6217bb557`、`12875f9053`、`3e3e26d2b2`、`2951d610c5`、`7afa7518bc`):
|
||||
模型诊断保留诊断、全局扫描和哈希协调,助手、原生控件选择器、参数方案分别归
|
||||
`ui_node_assistant.js`、`ui_node_model_picker.js`、`ui_node_presets.js`。模型详情保留展示协调,
|
||||
编辑器与高级选择器归 `ui_model_editor.js`、`ui_model_selector.js`。图片工作台仍唯一持有 `wb`
|
||||
和元数据缓存,舞台/缩放/胶片条归 `ui_image_stage.js`,检查器标签归 `ui_image_inspector.js`。
|
||||
素材目录、卡片、详情与节点应用/监听分别归 `ui_materials.js`、`ui_material_cards.js`、
|
||||
`ui_material_detail.js`、`ui_material_application.js`。笔记目录与保存留在 `ui_notebooks.js`,
|
||||
编辑器归 `ui_notebook_editor.js`,画布节点创建归 `notebook_canvas.js`。新增边界与行为检查覆盖
|
||||
以上接线、监听器清理、控件路径、素材应用和笔记保存队列;真实 ComfyUI 视觉仍待宿主验收。
|
||||
- 阶段 5(完成,`e8b50ecaf5`):`web/styles.css` 保留为唯一外部入口,并按原始字节顺序有序导入
|
||||
`web/styles/00-foundation-models.css` 至 `10-model-sources.css`。所有子样式带同一缓存版本;
|
||||
`css_bundle_order.mjs` 校验导入唯一/有序,且子文件拼接 SHA-256 与拆分前完全一致
|
||||
(`d014a9d1826e2c48a46ac72389fe4e5bdd0b2ba85618f653d13852cd93812bc1`)。
|
||||
未引入 cascade layer、动态加载或视觉改版;真实主题、停靠和浮层对比仍需宿主验收。
|
||||
- 阶段 6(完成,`6ad975c1cd`、`9cebdbd994`、`a0c6849878`):配方的工作流规则、配方规范化、
|
||||
图片和存储分别归 `workflow_schema.py`、`recipe_schema.py`、`recipe_images.py`、`recipe_store.py`;
|
||||
模型目录、身份解析、元数据写入和媒体归 `model_catalog.py`、`model_resolution.py`、
|
||||
`model_metadata.py`、`model_media.py`;素材规范化、资产和单一锁/缓存存储归
|
||||
`material_schema.py`、`material_assets.py`、`material_store.py`。路由入口改为显式模块注册,
|
||||
`test_route_manifest.py` 固定 68 个 method/path 对。兼容门面仅保留历史内部测试/调用点,不复制状态。
|
||||
- 阶段 7(完成,`a63a615514`):复核后保留 `ui_prompt_workbench.js` 与
|
||||
`ui_prompt_source_deck.js` 的单工厂闭包边界,避免拆出大量可变状态参数;`ui_model_sources.js`
|
||||
仍是模型来源这一项功能的查询/编辑/持久化协调器;`material_inspector.js` 仍聚焦共享元数据与
|
||||
节点参数检查。发现 `api/utils.py` 确有五类独立职责,已拆为 `path_utils.py`、
|
||||
`media_routes.py`、`translation_routes.py`、`gallery_routes.py`、`folder_types.py`,原文件仅作兼容导出。
|
||||
Python/JavaScript 相对导入图均无环,未发现 `EXTRACTED`/待提取占位或旧模型/配方/素材星号路由导入。
|
||||
最终通过全部 29 个 `.mjs` 检查、89 个 Python `unittest`、全量 `api/*.py` 编译和路由清单检查。
|
||||
本轮未连接运行中的 ComfyUI,因此不声称已完成真实拖放、停靠、主题、媒体释放和点击路径视觉验收。
|
||||
@@ -0,0 +1,133 @@
|
||||
# 工具箱与快捷栏自定义:交给 Gemini 的实施计划
|
||||
|
||||
状态:待实施的产品与工程计划,不代表当前版本已经具备以下功能。
|
||||
基线:`77ae7ce88`;开工时以实际 Git 差异和现有代码为准,不回退后续工作。
|
||||
|
||||
## 目标与范围
|
||||
|
||||
把左下角做成用户可整理的快捷栏:工具箱的工具能拖到栏中,栏中的可移动工具能拖回工具箱,也能调整顺序。
|
||||
同时完成短文字、详细提示的出现时间与位置调整。保留当前紧凑布局、工具箱外框尺寸和现有工具行为。
|
||||
先阅读 `AGENTS.md`、`docs/architecture/frontend.md`、`docs/architecture/update-guide.md`,仅加载本次相关内容。
|
||||
本任务有拖动状态与持久化,按下面阶段实施;不借机重写整个侧栏、换 UI 框架或引入拖拽依赖。
|
||||
|
||||
## 一、交互设计定稿要求
|
||||
|
||||
实施前用简短文字或草图说明:正常、拖出、拖回、排序、容量已满、取消六种状态。
|
||||
按本计划做出完整方案后直接实施;常规视觉细节由 Gemini 决定,不增加多轮审批。若与现有代码冲突,优先保持业务语义。
|
||||
|
||||
### 1. 工具箱是完整目录,底栏是快捷入口
|
||||
|
||||
- 工具箱始终保留所有可用工具。拖到快捷栏代表“固定”,工具箱卡片仍在,并标注“已固定”。
|
||||
- 从快捷栏拖回工具箱代表“从快捷栏移除”,不删除工具、不关闭正在执行的任务、不更改数据。
|
||||
- 第一版固定两个锚点:最左的工具箱、最右的设置。它们不能被拖走,防止用户丢失整理和恢复入口。
|
||||
- 中间最多 4 个可自定义入口,默认依旧是扫描、模型医生、节点助手、素材库,总共最多 6 个按钮。
|
||||
- 工具箱中现有的导入导出中心、提示词工坊、翻译助手、模型来源也能固定到中间位置。
|
||||
- 工具箱卡片顺序固定,快捷栏顺序由用户决定。第一版不做工具箱内部自由排序、分组、分页或自由浮动窗口。
|
||||
- 拖出只接受快捷栏区域;丢到画布、图库、模型卡片或窗口外一律取消,不能生成节点或触发文件操作。
|
||||
- 容量已满时不自动挤走其他工具,提示“快捷栏已满,请先将一个工具移回工具箱”;菜单固定与拖动规则一致。
|
||||
- 重复固定不产生副本;已固定工具拖到快捷栏只调整已有位置。
|
||||
- 提供“恢复默认快捷栏”,只重置本布局,不调用重置插件全部设置的功能。
|
||||
|
||||
### 2. 工具箱内的可发现性
|
||||
|
||||
- 在工具箱空白背景区域设置低干扰、可读的提示:“拖到下方,固定常用工具;拖回这里,收起快捷入口”。
|
||||
- 使用独立的提示/放置区域,不把文字覆盖在卡片上;空间不足时改为底部短提示,不能撑大工具箱。
|
||||
- 保持工具箱外框尺寸,工具增多只让内部列表滚动;底部提示/放置区保持可见,不遮挡卡片和关闭按钮。
|
||||
- 工具卡片悬停或键盘聚焦时显露小拖动把手和“更多”操作入口;不要用常驻的大段教程挤占工具区域。
|
||||
- 拖动快捷入口进入工具箱时,放置区域高亮并显示“松开,从快捷栏移除”;工具箱按钮本身也可作为拖回目标。
|
||||
- 工具箱关闭时,拖到工具箱按钮上约 350 毫秒后自动展开;计时离开即取消。只在确实拖动快捷入口时启用。
|
||||
- 没有完成放置时,不提前改动排列或保存设置。预览插入位置使用占位符/指示线。
|
||||
|
||||
### 3. 前一轮文字、时间与布局调整(本任务必做)
|
||||
|
||||
| 元素 | 第一版参数与行为 |
|
||||
| --- | --- |
|
||||
| 按钮内短文字 | 鼠标停留约 100 毫秒后,以约 120 毫秒的向上淡入替换图标;保持按钮尺寸与点击区域不变 |
|
||||
| 完整说明框 | 从进入按钮开始累计约 600 毫秒后出现;不是短文字结束后再多等 600 毫秒 |
|
||||
| 离开按钮 | 取消未触发的等待,详细提示立即隐藏;图标在约 100 毫秒内恢复,不保留离场延迟 |
|
||||
| 说明框位置 | 默认在按钮上方,间隔约 8 像素;左右限制在实际侧栏边界内,并兼顾视口 |
|
||||
| 排版 | 名称稍突出;正文常规字重、颜色稍淡、自然换行;不把整段解释都加粗 |
|
||||
| 键盘 | focus-visible 立即显示短文字和清晰焦点,详细说明遵循约 600 毫秒等待;失焦立即清理 |
|
||||
| 无悬停设备 | 短文字直接可见;提供点击“更多”的整理方式,不依赖悬停或长按才能使用 |
|
||||
| 减少动态效果 | 保留识别文字,但取消位移动画,遵守已有 prefers-reduced-motion 规则 |
|
||||
|
||||
这些时间是初始设计值,真实浏览器验证时允许小幅调整,最终只在一处定义,不能散落在多个模块。
|
||||
从一个按钮快速移到另一个时,重新计时,不闪现上一个按钮的说明。
|
||||
拖动开始立即隐藏所有按钮提示,拖动期间不显示短文字过渡和说明框;结束后等待新的悬停/聚焦再触发。
|
||||
提示框不能拦截点击、遮挡拖放目标;不要再使用超宽且正文加粗的提示框。
|
||||
当前 tooltip 使用伪元素:若要分开标题/正文或准确限制边界,替换这一组按钮的提示渲染为一个共享提示节点,并移除旧提示属性/规则,避免出现两套提示。
|
||||
不要改动插件所有其他 tooltip。新增可固定工具也必须有短标签、完整名称、说明与中英文翻译。
|
||||
|
||||
### 4. 拖动和普通点击必须分清
|
||||
|
||||
- 鼠标按下不立即拖动,移动超过约 6 CSS 像素才进入拖动态;工具箱优先从把手起拖,卡片正文点击继续打开工具。
|
||||
- 快捷栏支持按住按钮拖动。超过阈值后的 pointerup/click 不能打开工具或触发扫描,即使最后取消也不能误点击。
|
||||
- 执行原有动作只允许来自明确的普通点击或键盘激活;拖动预览不能调用工具 action。
|
||||
- 拖动时保留来源占位、显示小型拖动预览和有效放置提示,不扩大按钮、推动整个侧栏或影响画布缩放。
|
||||
- 拖动过程暂缓工具箱的“点击外部关闭”。与现有 document mousedown 关闭监听统一协调,不能边拖边把目标关闭。
|
||||
- Esc、pointercancel、失焦、关闭插件、语言切换或重建相关视图均取消拖动并恢复原布局;清理指针捕获、全局监听、计时器和预览。
|
||||
- 自动展开的工具箱取消后恢复此前开关状态;原本打开的工具箱保持打开。放置后可保留打开,方便继续整理。
|
||||
- 在插件缩放和停靠模式下统一使用视口坐标与 getBoundingClientRect,不能混用缩放前尺寸计算落点。
|
||||
|
||||
### 5. 不会拖动也能完成整理
|
||||
|
||||
工具箱的“更多”操作提供“固定到快捷栏/从快捷栏移除”;快捷入口提供“左移/右移/移回工具箱”。
|
||||
不要仅提供右键菜单,也不要依赖鼠标。使用真实 button、可读名称、可见焦点;操作后把焦点留在合理的位置。
|
||||
菜单和拖动走同一个布局修改函数,包含相同的容量、去重、固定入口约束;成功放置可用 aria-live 简短播报。
|
||||
|
||||
## 二、工程结构
|
||||
|
||||
### 现有责任与风险
|
||||
|
||||
- `web/modules/ui_sidebar.js`:目前拥有工具箱卡片、侧栏按钮、扫描状态、打开/关闭和语言刷新;它已经很大,新完整拖动实现不要继续塞入 createDOM。
|
||||
- `web/modules/sidebar_actions.js`:目前只认识六个硬编码 DOM ID;自定义后必须让新增快捷工具也能获取标签和用途。
|
||||
- `web/modules/ui_lifecycle.js`:复用视图资源清理机制;不要新建永久全局拖动监听。
|
||||
- `web/styles.css`:找到现有 sidebar-actions、toolbox、tooltip 规则并修改,移除本次取代的规则;禁止追加覆盖包。
|
||||
- `web/modules/ui_update_guide.js`:保持上方手动入口,不因自定义布局或新引导版本恢复自动弹窗。
|
||||
|
||||
### 建议职责拆分(可按仓库命名习惯调整,不为拆分增加空壳)
|
||||
|
||||
1. 工具定义:一个稳定 ID 对应一份名称键、短标签键、说明键、图标和可固定属性;目录和快捷栏共用。动作回调在侧栏装配时显式传入,纯数据模块不读取画布。
|
||||
2. 布局状态:单独的小模块负责默认值、去重/过滤、增删排序、容量与存储;只保存可移动 ID 的顺序,固定两端由视图添加。
|
||||
3. 整理视图:独立模块负责拖动、放置预览、菜单与取消清理。只在有效放置/菜单确认时调用布局修改接口。
|
||||
4. 现有 sidebar_actions 模块负责短标签和说明提示,可改为接收工具定义;禁止同时保留两套工具元信息和两套点击动作。
|
||||
|
||||
不要直接搬移或克隆工具 DOM 来实现固定:这会带走旧关闭逻辑、造成重复 ID 或丢失状态。
|
||||
每个工具在两处调用同一个动作。保留现有隐藏其他面板、初始化、扫描中状态和错误反馈;不要通过隐藏按钮的 `.click()` 间接调用。
|
||||
特别核对 `setScanButtonState`、`anomalous-scan-btn` 及语言刷新的所有调用方:扫描运行中移动入口,不能丢失进度、重复启动、产生失效引用。
|
||||
DOM ID 保证唯一;两个可见入口用稳定 toolId 关联。当前未固定的工具也不能因为入口不在底栏而失去业务状态。
|
||||
|
||||
### 持久化与兼容
|
||||
|
||||
- 使用一个带 schemaVersion 的独立 localStorage 键,例如 `anomalous_shortcut_layout_v1`,内容仅为 `{ schemaVersion: 1, pinned: [...] }`。
|
||||
- 首次使用采用原来的四个中间入口;显式的空数组是合法选择,不得又补回默认值。
|
||||
- 读取时拒绝错误结构/未知版本;去掉未知工具 ID、重复项和固定锚点,限制容量;删除工具或升级后仍能打开界面。
|
||||
- 不保存 DOM、翻译文本、回调代码或文件路径;不修改后端配置、工作流或素材数据。
|
||||
- 写入失败保留当前会话布局,准确提示刷新后可能丢失;禁止伪报“已保存”,也不能让插件打不开。
|
||||
- 第一版只在当前浏览器配置中持久化,不实现账户同步、多设备同步或布局导入导出。
|
||||
|
||||
## 三、不能改变的产品边界
|
||||
|
||||
- 导入导出快捷入口只打开现有已验证的 AMB0/AMB1 中心,仍是同一个中心;不能恢复配方、素材库、提示词工坊的独立文件导入导出。
|
||||
- 保留素材提示词自动同步、来源标注和不可在工坊删除来源卡片的规则。
|
||||
- 不修改图片/工作流哈希、模型识别、模型扫描内容、节点写入语义或用户文件。
|
||||
- 不新增全局自动教程;功能上线后再更新引导内容配置和双语文案,仍由用户点击感叹号查看。
|
||||
|
||||
## 四、实施顺序与验收
|
||||
|
||||
1. **设计与状态**:说明六种交互状态,提取工具定义和布局纯逻辑,验证默认、空数组、去重、容量、损坏数据及存储失败。此阶段保留旧界面可运行。
|
||||
2. **无拖动整理**:先让目录与快捷栏共用动作,通过菜单完成固定、移除、排序、恢复默认和刷新恢复;核对扫描状态、语言刷新、唯一 DOM ID。验证通过即本地提交。
|
||||
3. **拖动与提示**:接入同一状态接口,实现阈值、放置、取消和文字/说明时间布局;验证后再提交,不另造第二套状态。
|
||||
4. **交付**:仅更新改变的架构契约与用户可见更新说明。汇报实际验证、未测环境及本地提交,不推送 GitHub。
|
||||
|
||||
自动测试加载真实布局模块与交互模块,只模拟 DOM/存储边界。拖动至少覆盖成功放置、移出目标取消、Esc/父视图关闭清理、拖后不触发点击。
|
||||
现有相关测试是 `node --experimental-vm-modules tests/update_guide.mjs`;扫描动作和已验证导入导出入口发生接线变化时追加相应回归,不默认跑全部后端测试。
|
||||
新增测试确保被 Git 跟踪。语法/模块链接检查不能代替下面的浏览器操作:
|
||||
|
||||
- 工具箱 → 快捷栏、快捷栏 → 工具箱、栏内排序、重复固定、满栏拒绝、取消拖动;默认工具都能打开且只执行一次。
|
||||
- 刷新、恢复默认、所有可移动项移除、扫描中移动入口、中英文切换;工具箱与设置始终可达。
|
||||
- 快速掠过不闪说明,停留出现分层文案,边缘按钮提示不越出侧栏;拖动无提示遮挡。
|
||||
- 普通窗口、窄侧栏、插件缩放、侧边停靠;工具箱尺寸稳定、内部可滚动、拖放坐标准确。
|
||||
- 键盘菜单能完成同样操作;点击感叹号才显示更新引导。至少验证常用主题,未测环境如实说明。
|
||||
|
||||
按 AGENTS.md 及时保存阶段快照;若额度将尽或任务中断,保存有明确“验证待完成”说明的恢复快照,而不是无提交地把全部工作留到最后。
|
||||
@@ -0,0 +1,88 @@
|
||||
# 前端设计与 Gemini 协作方案
|
||||
|
||||
更新:2026-09-16。取代 9 月 9–11 日的前端视觉、布局建议,后续直接更新本文,不再按日期追加同类建议文件。
|
||||
|
||||
这是设计任务说明;工程规范以根目录 [AGENTS.md](../../AGENTS.md) 为准。具体任务若已有更新的专门方案,以专门方案为准。
|
||||
|
||||
## 1. 当前起点
|
||||
|
||||
本次核对基于 `3f71ddfcb` 的代码与计划,没有重新进行浏览器视觉验收。
|
||||
|
||||
- 配方已经有独立容器和工具栏,笔记有自己的标题,不再把“拆开创作工作台”列为待办。
|
||||
- 提示词工坊已经独立。继续使用现有工坊,不恢复旧嵌入式组合面板,也不拿早期的简单组合设想限制现在已有的能力。
|
||||
- 文件夹与配方覆盖层的定位问题已有修复,不重复下发修复任务。
|
||||
- 工具箱已有入口;快捷栏自定义是**待实施计划**,见 [工具箱与快捷栏方案](customizable-tool-shortcuts.md)。不要把计划当作已经实现的功能。
|
||||
- 当前配方包导入按钮处于禁用状态。设计可以解释不可用原因,不能为了演示效果直接启用、伪造成功或隐藏这个限制。
|
||||
|
||||
以上用于避免重复劳动,不代表所有关联功能已经验收通过。旧审查报告中的问题需要复现后判断,不能整份搬成当前待办,也不能直接宣布全部解决。
|
||||
|
||||
## 2. 怎样发挥 Gemini 的优势
|
||||
|
||||
让 Gemini 负责视觉层次、排版、密度、图标、过渡和响应式细节,并完成对应前端实现。不给每个像素下指令,也不要求每个小调整都等待批准。
|
||||
|
||||
任务边界和操作语义先明确:用户从哪里进入、想完成什么、写入什么、如何取消、完成后去哪里。Codex 可以负责梳理这些约束与检查数据、节点和生命周期;Gemini 做实现时也必须覆盖它们,不能把操作闭环全部留给下一位补。
|
||||
|
||||
**每轮交付一条完整操作流程。** “把工具箱做漂亮”范围模糊;“让用户从工具箱固定一个工具,再移除或取消,且任何时候都能恢复默认”才是可完成的任务。
|
||||
|
||||
## 3. 统一的设计方向
|
||||
|
||||
目标是清楚、紧凑、有质感的 ComfyUI 创作工具。图片和用户内容是视觉重点,装饰只辅助识别。
|
||||
|
||||
| 区域 | 设计职责 |
|
||||
| --- | --- |
|
||||
| 顶部导航 | 模型、图库、工作流配方;进入和返回的位置稳定 |
|
||||
| 模型文件夹 | 只负责模型目录,不承担全局导航 |
|
||||
| 左下工具区 | 默认保留素材库;工具箱是完整目录,快捷栏是常用入口。自定义行为遵循专门方案 |
|
||||
| 列表工具栏 | 搜索、筛选、结果数、主操作;窄窗口可换行,控件不能互相挤没 |
|
||||
| 内容卡片 | 图片、名称、类型和一项主操作。技术细节按需要展开,次操作可集中到“更多” |
|
||||
| 详情与编辑 | 返回路径明确;区分查看、编辑、应用,明确应用到哪个节点或画布 |
|
||||
|
||||
不要为了统一外观强行让所有卡片显示同样字段。模型用于选择资产,配方用于复用工作流,素材用于复用内容,信息重点各不相同。
|
||||
|
||||
外观由 Gemini 在现有主题内自行发挥:统一按钮层级、间距、圆角与文字密度;选中、警告、禁用和键盘焦点要清楚。普通按钮不加持续发光或跳动。两套主题共享布局与操作规则,不能各自长出一套交互。
|
||||
|
||||
不把新的全站导航重构与本轮工具箱改动捆绑实施。发现其他页面需要调整时,写一句后续项即可。
|
||||
|
||||
## 4. 防止只考虑一个局部
|
||||
|
||||
动手前用几行列出本次操作链,并填下面这张表。只检查与本次变更有关的部分,不做每轮全仓审查。
|
||||
|
||||
| 必须想清楚的事 | 交付时需要回答 |
|
||||
| --- | --- |
|
||||
| 入口与出口 | 从哪个页面进入?关闭、返回、再次打开后分别是什么状态? |
|
||||
| 正常与边界状态 | 加载、空内容、筛选无结果、失败、禁用、取消是否可理解?没有此状态的说明不适用 |
|
||||
| 关联影响 | 改了共享按钮或浮层,哪些相邻页面、主题、提示框会受影响? |
|
||||
| 操作对象 | 当前改的是快捷入口、素材数据、节点参数还是画布?文案是否说清楚? |
|
||||
| 尺寸与输入 | 普通窗口、侧边停靠、长中文/英文、鼠标与键盘是否能完成同一任务? |
|
||||
| 离开后的行为 | 悬浮预览、拖动、视频和请求会不会在关闭后继续活动或写错对象? |
|
||||
|
||||
例如“更多”菜单变漂亮,仍必须考虑靠右时是否出界、被父容器裁切、滚动时是否漂移,以及 Esc 关闭的是菜单还是整个浏览器。选择一个代表页面打磨公共样式后,再核对受影响页面,不要求一次重做它们。
|
||||
|
||||
## 5. 性能怎么兼顾
|
||||
|
||||
- 默认采用静态布局与局部短过渡。大面积模糊、连续光效、鼠标移动驱动全页动画必须有实际价值,否则不用。
|
||||
- 大列表继续按现有分页、缩略图和懒加载策略工作;不能为视觉预览一次性加载全部原图、详情和视频。
|
||||
- 工具提示随内容和交互更新,不为一小段文字增加持续轮询。隐藏页面的事件、预览和请求按照已有生命周期释放。
|
||||
- 验证本次增加的成本:是否多了持续任务、重复请求或重复监听,滚动和拖动是否出现明显卡顿。需要声称性能提升时提供同一条件下的测量;没有测量就不报百分比,也不猜显存变化。
|
||||
|
||||
## 6. 推荐工作顺序
|
||||
|
||||
**第一轮:完成工具箱与快捷栏专门方案。** 这是明确的当前任务单;视觉与交互一起完成,包含正常放置、拖回、取消、重复固定、容量已满和恢复默认,不顺手改其他页面导航。
|
||||
|
||||
**第二轮:统一受影响的公共控件。** 以工具箱的按钮、提示、选中和禁用状态为起点,检查它们在模型、配方、素材库中的实际使用。修改原规则,清掉本次替代的旧声明。
|
||||
|
||||
**第三轮:逐页优化信息密度。** 先选一个真实使用频繁的页面,完整处理列表、详情、返回和失败反馈,再决定下一页。无需为了执行本文件同时美化全插件。
|
||||
|
||||
每轮完成后更新状态,删除已完成待办。验证发现的新问题直接修复;超出本轮范围的只记录具体触发条件,避免积累泛泛的“以后再优化”。
|
||||
|
||||
## 7. 可直接发给 Gemini 的任务模板
|
||||
|
||||
> 先阅读 GEMINI.md、AGENTS.md,以及 docs/plans/frontend-design-brief.md。本轮只完成【具体操作流程】,具体规则以【对应方案】为准。
|
||||
>
|
||||
> 先简述入口、正常操作、取消/失败、返回路径,以及会受影响的相邻界面,然后直接实施。颜色、排版和细节由你在现有主题内决定,不必逐项询问。
|
||||
>
|
||||
> 交付完整交互,不只做正常状态的截图。保留真实数据和操作语义,覆盖相关的窄窗口、主题、键盘与关闭路径。发现接口或业务阻塞时明确说明,不能用演示数据、假成功或静默隐藏功能替代。
|
||||
>
|
||||
> 完成后用简短清单说明:改变了什么、实际验证了哪些路径、哪些未验证、剩余的具体问题和本地提交。验证范围与改动相称,不默认跑全部测试;未完成项不得写成已完成。
|
||||
|
||||
这样用户主要判断“是否顺手、是否好看”,无需每次替实现者补齐所有边界情况。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 后续构想:让 AI 从图库中帮助创作
|
||||
|
||||
记录日期:2026-09-18。
|
||||
**状态:仅保存想法,现阶段不开发。本文只描述想实现什么,不规定代码、接口或技术方案。**
|
||||
|
||||
## 核心想法
|
||||
|
||||
图库不仅用于看图,也积累了生成图片时使用的提示词、模型、参数和工作流。未来允许用户连接自己选择的 AI 服务,从这些创作记录中总结经验,推荐值得尝试的工作流组合。
|
||||
|
||||
希望它能回答:“根据我喜欢的结果、已经拥有的模型和现在想画的内容,下一次可以怎样搭配?”
|
||||
|
||||
## 希望提供的体验
|
||||
|
||||
- **分析选中的作品**:选择一张或一组图片,查看它们使用了哪些模型、提示词和参数,有哪些共同点与差异。
|
||||
- **推荐工作流组合**:结合创作目标与现有模型,推荐可尝试的节点、模型、提示词和参数组合,并解释推荐理由、参考了哪些作品、需要补充哪些条件。
|
||||
- **总结个人偏好**:结合收藏、喜欢/不喜欢及主动评价,帮助用户发现自己偏好的风格和搭配,而不是把生成次数最多的组合当成最好。
|
||||
- **比较与改进**:对比满意与不满意的结果,提出值得尝试的调整方向;通过后续试图和反馈,逐步判断建议是否适合用户。
|
||||
- **整理可复用经验**:把用户认可的总结整理成提示词笔记、素材或工作流配方,方便再次使用。
|
||||
- **探索更多玩法**:以后可以考虑用自然语言寻找历史作品、归纳某个模型的常用搭配、为新题材寻找可参考的旧工作流。逐项评估,不要求一次做完。
|
||||
|
||||
## 对结果的期望
|
||||
|
||||
推荐应当具体、可解释,区分“图库记录中的事实”和“AI 提出的尝试建议”。缺少必要模型或节点时提前说明,用户确认后再决定是否保存或应用。
|
||||
|
||||
仅凭参数不能保证找出“最佳工作流”,也不能把相关性当作某个参数造成画面改善的证明。有图片分析能力时可结合实际画面;没有时应明确仅依据参数与用户评价。
|
||||
|
||||
## 保留的边界
|
||||
|
||||
- 图库中并非每张图片都带有完整参数或工作流;缺失信息要明确标注,不让 AI 编造来源和配置。
|
||||
- 用户主动选择分析范围,并能在发送前了解将交给外部 AI 的图片、提示词和工作流内容;不默认上传整个图库。
|
||||
- 分析与应用分开:不自动改写现有工作流、不覆盖记录、不自动下载模型或启动生成。
|
||||
- 用户能控制分析规模与费用;现有看图和素材功能保持独立,不连接 AI 也能正常使用。
|
||||
|
||||
这是一项长期探索方向,当前优先完成现有功能收尾。真正启动时再讨论最小可用范围。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 素材库:保存画布选中节点
|
||||
|
||||
记录日期:2026-09-18。
|
||||
**状态:后续需求,仅记录,本轮不开发。**
|
||||
|
||||
## 想做什么
|
||||
|
||||
在 ComfyUI 画布中选中一个或多个节点,通过一个方便的入口直接保存到素材库,方便以后复用,不必先生成图片或建立工作流配方。
|
||||
|
||||
- 单选:将这个节点及其当前参数保存为一份素材。
|
||||
- 多选:将全部选中节点作为一个整体,保存为一份素材,不拆成多个独立条目。
|
||||
- 保存选中节点之间的内部连线;与未选中节点相连的部分不自动扩展收录,不能留下指向不存在节点的无效连线。
|
||||
- 保存不改变当前画布、节点参数或现有连接。
|
||||
|
||||
## 建议的最小交互
|
||||
|
||||
- 优先考虑节点或画布右键菜单中的“保存到素材库”,单选和多选共用入口;具体入口接入方式开发时核对 ComfyUI 能力。
|
||||
- 轻量确认框显示“已选 N 个节点”,填写名称即可保存,标签可选;不强制提供封面。
|
||||
- 未选中节点时不允许保存;成功提示只在实际保存完成后出现,并提供查看新素材的入口。失败保留输入,便于重试。
|
||||
- 点击保存时固定节点选择与参数快照,后续切换选择不改变本次保存对象。
|
||||
|
||||
## 开发时注意
|
||||
|
||||
- 先核对现有节点素材、参数素材的保存与应用能力,复用素材库的存储、列表、详情和校验流程,不另建一套收藏系统。
|
||||
- 当前图片/配方来源的保存接口不能直接假定支持画布快照;需要改协议时,明确新增来源的校验与兼容规则,不伪造图片来源。
|
||||
- 单节点素材应能继续参与已有节点参数应用;多节点素材保留整体结构。首次开发需明确复用入口,保存成功后必须能在库中查看并通过相应流程使用。
|
||||
- 先做好单选、多选、内部连线、外部连接边界、无选中节点和保存失败的验证;不借此扩展复杂节点组编辑器或自动收集依赖。
|
||||
|
||||
正式开发前遵循 [AGENTS.md](../../AGENTS.md),阅读 [素材库架构](../architecture/material-library.md),按最新实现细化方案。本文件不表示功能已完成。
|
||||
@@ -0,0 +1,157 @@
|
||||
# 模型来源中控:链接管理范围与后续方案
|
||||
|
||||
更新:2026-09-18。实施基线:`1b2013fe69`;接手时先核对最新代码。
|
||||
**状态:2026-09-18 已完成“交给 5.6Sol”范围的代码与自动化验收;真实 ComfyUI 后端需重启后再做最终页面复核。** 本文是唯一交接计划,后续原地更新。
|
||||
|
||||
## 交给 5.6Sol:本轮只做目录范围对齐与基础组件折叠
|
||||
|
||||
### 目标与已确认原因
|
||||
|
||||
来源中控仍只管理链接。补齐本地模型列表,让用户通过现有文件夹管理决定纳入哪些目录,同时将基础组件默认收起,降低整理负担。
|
||||
|
||||
已确认的代码问题:
|
||||
|
||||
- 中控 `fetchAllLibraryModels()` 调用 `/anomalous/all_scan_models`,后端 `_collect_all_scan_models()` 只读分类模式的 `get_active_folder_types()`,没有遵循当前物理目录模式。
|
||||
- 本次本地配置中,物理目录模式的 `clip` 已开启,但分类配置的 `text_encoders` 关闭。这能解释本地库遗漏 CLIP;不能因此认定所有缺项都只有这一种原因。
|
||||
- 此接口只枚举 `.safetensors/.ckpt/.pt/.bin/.sft`,遗漏 `.gguf/.pth`,且扩展名比较区分大小写。
|
||||
- 上轮提交只补了工作流收集、基础组件标签与对应类别的元数据读取,没有修复本地目录范围。
|
||||
|
||||
### 一、目录范围与文件格式
|
||||
|
||||
1. **本地库遵循文件夹管理当前模式。** 分类模式按分类勾选;物理目录模式按物理目录勾选。隐藏目录不遍历、不读取元数据;不自动开启 CLIP 等目录,不改用户配置。
|
||||
2. 复用 `api/folder_types.py` 的模式、启用目录规则。若需提取目录枚举,放在该职责内,返回真实的 `type/path_idx/base_dir`;不能只拿物理目录名当模型类别。物理模式下同一类别可对应多个根目录,必须逐根判断,不能把启用其中一个解释为全部启用。
|
||||
3. 对同一实际根目录进行规范化去重,保留可被后续接口解析的类别与路径索引。不要按 basename 合并模型;不同目录里的同名文件是独立条目。
|
||||
4. 补齐此列表的 GGUF/PTH,并统一大小写判断。仅表示“可列出并记录链接”,不代表这些格式已支持元数据解析、身份恢复或云端识别。不要直接扩大全局 `MODEL_EXTENSIONS` 后让所有读写路径被动接受新格式。
|
||||
5. `/all_scan_models` 同时被 `ui_model_selector.js` 使用。先检查两处调用;保持返回结构、分页与 `limit=0` 语义,验证选择器没有因此丢项或出现重复。确需区分调用用途时再加最小兼容参数,不先造专用数据库或另一套扫描系统。
|
||||
6. **当前工作流仍列出实际引用的模型**,不被本地库隐藏目录设置抹掉。文件夹设置控制库盘点范围,不能让分享工作流时遗漏依赖。
|
||||
|
||||
### 二、基础组件默认折叠
|
||||
|
||||
- 工作流和本地库都保留两个显示组:主要模型直接显示;基础组件放进默认收起的 **“基础组件(高级) · N”**。使用可键盘操作的折叠控件,不再加一层弹窗。
|
||||
- 本轮基础组件固定为现有策略已覆盖的 `clip/text_encoders/clip_vision/vae/vae_approx`。Checkpoint、扩散模型、LoRA、ControlNet 留在主要列表。其他已启用类别维持原有展示,避免顺手建设全站“核心/非核心”分类系统。
|
||||
- 展开状态由当前中控会话持有,筛选、编辑、保存后的刷新应保留;重新打开默认收起。折叠时不要生成所有组件行的 DOM。
|
||||
- 无来源的基础组件显示 **“未填写(可选)”**,不警告、不计入主要模型“来源待补充”数量;已有来源照常查看、修改和保存。
|
||||
- “全部/已填写来源/来源待补充”筛选作用于主要模型;组件组独立于这三个来源状态筛选,展开后显示全部组件。搜索同时作用于两组,折叠栏数量随搜索更新;文案明确“基础组件来源可选,不计入待补”。
|
||||
- 工作流里确定缺少本地文件的组件,折叠栏仍显示 **“缺少本地文件 × 项”**;该警告按当前范围全部组件计算,不被搜索或来源筛选隐藏。动态下拉选项未知不等于文件缺失,保留上轮判断保护。
|
||||
- 底部统计明确标为“主要模型来源:已填写 X / Y”,使用当前搜索与筛选后的主要列表;组件数量单列在折叠栏,不把未填可选来源算成未完成任务。
|
||||
- 无主要模型但存在组件时,保留组件入口;不能显示“未检测到任何模型”。目录未纳入范围时提供简短提示“本地库范围跟随设置 → 文件夹管理”,不增加重复配置界面。
|
||||
- 所有新增文案同步中英文。使用现有样式变量和中性标签,不加发光、动效或强提醒。
|
||||
|
||||
### 三、保存、性能与职责边界
|
||||
|
||||
- 折叠只影响显示,不能删除已有来源或改变底层模型集合。保存到工作流、生成 Note 保留当前整份工作流语义;复制摘要应明确当前显示范围,不能因折叠悄悄改成导出所有内容。
|
||||
- 本地保存继续使用准确的 `type/path_idx/subfolder/filename`。未定位实际文件的工作流组件只允许记录工作流链接,不得猜测路径写入 `.civitai.info`。
|
||||
- 保留人工链接、原文件名、封面和已有 info。打开、展开、筛选均不触发云端扫描、哈希计算或附属文件写入。
|
||||
- 列表枚举继续放在已有工作线程路径;折叠、搜索只处理已加载数据,不反复全盘读取。加载成功但为空、加载失败、尚未加载要能区分;打开时若指定 library 范围也须正确加载。
|
||||
- `ui_model_sources.js` 已超过规模阈值:优先把目录结果整形、分组/筛选等有明确职责的逻辑提取到来源中控专用模块,弹窗保留生命周期与协调。只在本轮需要处提取,不做全仓拆分,也不把业务塞入通用 utils。
|
||||
|
||||
### 修改落点
|
||||
|
||||
| 位置 | 本轮责任 |
|
||||
| --- | --- |
|
||||
| `api/folder_types.py`、`api/model_catalog.py` | 当前模式对应的目录范围、路径去重、列表格式支持 |
|
||||
| `web/modules/ui_model_sources.js` 及必要的来源专用模块 | 组件折叠、分组筛选、统计、空态与加载状态 |
|
||||
| `web/modules/model_policies.js` | 复用现有类型判断,避免改动重命名或身份恢复策略 |
|
||||
| `web/modules/locales.js`、`web/styles/10-model-sources.css` | 中英文文案、局部折叠布局 |
|
||||
| `tests/model_sources_hub.mjs`、`tests/model_source_components.mjs` | 更新为新交互,同时保留准确定位与保存保护测试 |
|
||||
| 对应 Python 测试 | 使用临时目录验证目录模式、过滤与去重,不能拿真实模型做写入夹具 |
|
||||
|
||||
### 验收清单
|
||||
|
||||
1. 物理模式开启 `clip`、分类配置关闭 `text_encoders` 时,库仍显示所选物理目录里的 CLIP;反向关闭物理目录时不枚举它。分类模式按自身配置工作。
|
||||
2. 多根目录只启用其中一个、路径别名指向同一实际根、不同根有同名文件,均得到正确范围与去重结果;隐藏根目录零遍历、零元数据读取。
|
||||
3. 无来源模型仍出现;GGUF/PTH 及大写扩展名可列出。现有格式、分页、选择器调用继续可用。
|
||||
4. 基础组件初始折叠,展开后可查看/编辑;切换筛选或保存不丢展开状态与输入内容。主要待补计数不含组件;搜索、仅有组件、全空、加载失败均有明确结果。
|
||||
5. 折叠时仍能看到组件缺文件告警;“无来源”和“文件缺失”独立。搜索无匹配不能抹掉当前工作流的缺文件告警。
|
||||
6. 折叠与筛选不清空已保存链接。已定位文件准确保存;未知目标不写本地;关闭窗口后迟到请求不重新渲染。
|
||||
7. 跑上述两组 JS 测试和对应 Python 目录枚举测试。实际 ComfyUI 验证两个范围、展开/收起、筛选搜索、窄窗口和原有选择器;写入/缺失场景用隔离夹具,不清空真实链接来制造测试条件。
|
||||
8. Python 改动需重启 ComfyUI 才生效;只有前端刷新不能证明新接口已加载。不要强停有任务的实例;如暂不能验证新后端,明确报告缺口。
|
||||
9. 完成后审查差异、更新本文状态与最窄的架构专题、本地提交,不推送。架构中的目录范围说明也要同步区分分类与物理模式。
|
||||
|
||||
### 本轮实施记录(2026-09-18)
|
||||
|
||||
- 已增加文件夹管理范围的统一根目录枚举,来源库按当前分类/物理模式取根,保留 `type/path_idx`、真实路径去重,并只在来源库列表补充大小写无关的 GGUF/PTH。
|
||||
- 已提取来源中控专用的数据整形与分组模块;工作流和本地库的基础组件默认折叠,主要模型筛选、组件搜索计数、缺文件提示和当前可见范围复制各自保持独立语义。
|
||||
- 自动化验证:来源中控两组 JS 专项测试、95 项 Python 测试、CSS/本地化契约通过;临时目录覆盖物理/分类范围、隐藏根零遍历与零元数据、路径别名、同名文件和 GGUF/PTH 大小写。
|
||||
- 已在运行中的 ComfyUI 页面验证默认折叠、键盘展开、来源筛选不隐藏组件、搜索计数、主要模型统计与本地库范围提示;并在独立 Python 进程按真实物理配置确认 7 个启用根,其中 `text_encoders/path_idx=1` 正确对应已开启的 `clip` 根。运行实例未强停;Python 路由仍是重启前模块,目录范围新逻辑须在安全重启后复核。当前浏览器控制器不支持临时窄视口覆盖,窄窗口实机检查仍待补。
|
||||
|
||||
### 本轮不做
|
||||
|
||||
多来源切换、HF 抓图/info、平台多选、一键补全、扫描结果共享、全站格式支持和目录设置重做均不做。下文“链接可信度”中的历史识别/保存问题另列后续,不因本次计划一并扩张;若阻断上述验收,才做最小必要修复并说明。
|
||||
|
||||
## 已完成:工作流基础组件链接展示(2026-09-18)
|
||||
|
||||
- 复用模型类别判断,显示文本编码器、VAE、预览 VAE、视觉编码器标签;纳入无扩展名的组件选项及 PTH/GGUF 文件引用。
|
||||
- 来源待补充使用普通提示,与本地缺失警告独立;保留手动填写和保存到工作流的能力。
|
||||
- 基础组件只按对应类别、明确相对路径读取本地元数据,不搜索其他类别或进行云端扫描。不能定位实际文件时不提供本地附属文件写入;无扩展名选项、后端尚不支持定位的文件格式仍可保存工作流链接。
|
||||
- 本地库目录范围、封面、文件名保持现状。大于 16 项的组件分批读取,覆盖后端请求上限。
|
||||
- 验证:来源中控与组件专项测试通过;真实 ComfyUI 页面确认 CLIP/VAE 标签和已有链接正常。真实库当前没有待补来源条目,缺失、手动填写和保存分支使用隔离夹具验证,未改写用户链接或画布。
|
||||
|
||||
## 当前方向:先把链接管好
|
||||
|
||||
中控现阶段定位为模型链接管理页,负责查看、填写、修改、打开与保存来源链接。保留已有工作流/本地库范围与各自保存含义;不因为缩小范围顺手删除现有功能。
|
||||
|
||||
下次实施优先解决与链接可信度直接相关的问题:
|
||||
|
||||
- 无来源的模型必须能够被看到并补链接。“没有来源链接”不能成为排除模型的条件。
|
||||
- 区分“未填写来源”和“本地缺少模型文件”,不把两者都显示成缺失模型。
|
||||
- 自动识别失败返回的搜索页只能作为“去搜索”入口,不能保存为已识别来源、显示识别成功或计入已补全数量。当前 `autoDetectModelSource()` 的搜索回退及调用方提示需要一起检查。
|
||||
- 保存成功以实际写入结果为准;明确“保存到本地”和“写入当前工作流”的区别。无法确定本地文件身份时,不凭同名文件猜测写入位置。
|
||||
- 保留手工录入的合法网页链接,不把用户填写的地址称为“已验证官方来源”。打开链接不隐式抓图、覆盖介绍或启动扫描。
|
||||
|
||||
用户报告“缺失来源的模型没有显示”,当前静态代码没有直接按空链接排除列表项。本次已确认目录模式不一致与格式遗漏,见上面的实施范围;其他来源识别与保存问题仍需分别复现,不将它们统称为已修复。
|
||||
|
||||
## 暂缓一:模型详情的多来源入口
|
||||
|
||||
双链接可行,但应理解为“同一模型有多个来源”,不是切换到另一份模型数据。以下是后续建议,尚未实现。
|
||||
|
||||
- 只有一个来源时保持现有入口;两个来源时,详情的来源区域并列显示 `Civitai`、`Hugging Face` 两个文字按钮。无需为了两个链接引入整个页面的切换标签。
|
||||
- 如果确实需要默认入口,可提供“首选来源”单选;切换只影响默认打开哪个链接,不改变封面、介绍、模型文件、哈希或扫描设置。
|
||||
- 第三个及更多来源再考虑菜单,不预先建设无限多平台管理器。无链接的平台不显示空按钮。
|
||||
- 当前 `civitai_url` 是独立字段,`source_url` 是通用自定义来源,不能假定后者必然是 HF。初版可优先复用现有字段;确需额外首选来源字段时,单独定义兼容规则。
|
||||
- 多个链接并存不证明文件一致。确认匹配仍依赖现有模型身份规则;不能根据名称近似自动合并模型。
|
||||
|
||||
不建议首版让“切换来源”同时切换整套封面和 info:这会引入两套详情状态、缓存和失败回退,超出当前链接管理范围。
|
||||
|
||||
## 暂缓二:扫描来源的单选/多选
|
||||
|
||||
放在现有扫描向导或相关设置里,不在中控再做另一套扫描控制台。
|
||||
|
||||
明确区分三件事:
|
||||
|
||||
| 选项 | 含义 |
|
||||
| --- | --- |
|
||||
| 模型范围 | 扫描哪些本地模型,沿用现有选择能力 |
|
||||
| 来源平台 | 去哪些已支持的平台获取信息,可单选或多选 |
|
||||
| 首选链接 | 默认打开哪个已保存来源,与扫描平台互不联动 |
|
||||
|
||||
推荐平台选择采用复选框:只勾一个就是单来源,勾多个就是多来源,无需再增加“单选模式/多选模式”开关。只展示真正实现并可用的提供方,不能提前让 HF 扫描成为可点击的空功能。
|
||||
|
||||
多平台补全规则另行实施时必须一起确定:优先复用本地信息、只补缺项、保留人工内容、一个平台失败不丢其他结果、明确取消语义。已有链接不等于已有封面;已有 info 文件也不等于每个字段完整。
|
||||
|
||||
## 暂缓三:中控与扫描共享结果
|
||||
|
||||
两者共用现有模型附属信息读写能力,不建立中控专用数据库,也不让两个界面分别维护一份权威副本。
|
||||
|
||||
- 向导保存结果后,中控在打开时或收到完成通知后重新读取;不用持续轮询所有模型。
|
||||
- 中控保存的手工链接,向导可读取并保护。链接只说明用户记录的来源,不代表扫描器已经支持解析该平台。
|
||||
- 如增加“补全缺失来源”,仅复用已有扫描任务,限定当前范围并显示真实成功/未找到/失败结果,不自动扫描整个模型库。
|
||||
|
||||
## 暂缓四:HF 信息补全
|
||||
|
||||
- 工作流基础组件展示已完成,见上文;不据此扩大本地库目录范围或重做全站模型类型系统。
|
||||
- 基础组件保留原文件名,不强制提供封面;没有封面时使用类型图标。
|
||||
- 填写 HF 链接时保留现有封面、介绍和 Civitai 信息,不将自定义链接写进 Civitai 身份字段。
|
||||
- 将来如实现 HF 信息读取,先只读明确仓库的模型卡与有明确声明的缩略图;缺失时保留本地信息。不做自动抓任意 README 图片、全网找封面或无限平台适配。
|
||||
- 图片和介绍的来源与下载链接可以不同,但应可辨别,且不能把仓库级说明误当成已验证的具体文件信息。
|
||||
|
||||
参考:[HF 模型卡官方说明](https://huggingface.co/docs/hub/model-cards)。这只是后续技术参考,不表示本项目已支持自动抓取。
|
||||
|
||||
## 后续实施顺序
|
||||
|
||||
1. 先完成本文“交给 5.6Sol”范围:目录模式对齐、列表格式补齐、基础组件折叠与统计。
|
||||
2. 再单独核查历史识别结果与保存反馈,完成链接管理的基本用途。
|
||||
3. 确有需要时再做模型详情双来源入口;此时仍不联动封面或扫描。
|
||||
4. 最后评估扫描结果共享、平台多选及 HF 元数据读取,逐项实施,不一次打包上线。
|
||||
|
||||
实施时遵循 [AGENTS.md](../../AGENTS.md),核对最新代码和相关测试;本文不替代架构契约,也不授权自动启用所有暂缓功能。
|
||||
@@ -0,0 +1,39 @@
|
||||
"""File SHA-256 evidence shared by the standalone scanner and API."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def normalise_sha256(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", value.strip()):
|
||||
return ""
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def computed_file_identity(path, digest):
|
||||
digest = normalise_sha256(digest)
|
||||
if not digest:
|
||||
raise ValueError("Invalid file SHA-256")
|
||||
stat = os.stat(path)
|
||||
return {
|
||||
"algorithm": "sha256", "scope": "file", "value": digest,
|
||||
"size": stat.st_size, "mtime_ns": stat.st_mtime_ns, "source": "computed",
|
||||
}
|
||||
|
||||
|
||||
def sidecar_file_hash(data, path, selected_file):
|
||||
"""Keep cached file evidence separate from unverified embedded model hashes."""
|
||||
identity = data.get("anomalous_file_identity")
|
||||
if identity is not None:
|
||||
if not isinstance(identity, dict) or identity.get("algorithm") != "sha256" or identity.get("scope") != "file":
|
||||
return "", ""
|
||||
stat = os.stat(path)
|
||||
if identity.get("size") != stat.st_size or identity.get("mtime_ns") != stat.st_mtime_ns:
|
||||
return "", ""
|
||||
return normalise_sha256(identity.get("value")), "computed file SHA-256"
|
||||
# Older offline records could label a BLAKE3/tensor digest as SHA-256.
|
||||
# Keep their presentation data, but require a scan or on-demand verification.
|
||||
if data.get("id") == -1 or data.get("modelId") == -1:
|
||||
return "", ""
|
||||
hashes = selected_file.get("hashes", {}) if isinstance(selected_file, dict) else {}
|
||||
return normalise_sha256(hashes.get("SHA256")), "sidecar file SHA-256"
|
||||
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# These components are normally referenced by stable ecosystem filenames.
|
||||
# Renaming them breaks existing workflows without providing meaningful value.
|
||||
PHYSICAL_RENAME_PROTECTED_TYPES = frozenset({
|
||||
"vae",
|
||||
"vae_approx",
|
||||
"clip",
|
||||
"text_encoders",
|
||||
"clip_vision",
|
||||
})
|
||||
|
||||
# This is a recovery-confidence policy, not a scanner-support list. The scan
|
||||
# wizard may hash these folders, but Model Doctor must not redirect one of these
|
||||
# components from size, filename, metadata labels, or Civitai availability.
|
||||
HASH_ONLY_RECOVERY_TYPES = frozenset({
|
||||
"vae",
|
||||
"vae_approx",
|
||||
"clip",
|
||||
"text_encoders",
|
||||
"clip_vision",
|
||||
})
|
||||
|
||||
|
||||
def is_physical_rename_protected(folder_type=None, folder_path=None):
|
||||
"""Return True when a model category must keep its physical filename."""
|
||||
normalized_type = str(folder_type or "").strip().lower()
|
||||
if normalized_type in PHYSICAL_RENAME_PROTECTED_TYPES:
|
||||
return True
|
||||
|
||||
if not folder_path:
|
||||
return False
|
||||
|
||||
try:
|
||||
parts = {
|
||||
os.path.normcase(part).lower()
|
||||
for part in Path(os.path.abspath(folder_path)).parts
|
||||
}
|
||||
except (OSError, TypeError, ValueError):
|
||||
return False
|
||||
return bool(parts.intersection(PHYSICAL_RENAME_PROTECTED_TYPES))
|
||||
|
||||
|
||||
def requires_hash_for_model_recovery(folder_types):
|
||||
"""Return True when every requested category requires a hash match."""
|
||||
normalized = {
|
||||
str(folder_type or "").strip().lower()
|
||||
for folder_type in (folder_types or ())
|
||||
if str(folder_type or "").strip()
|
||||
}
|
||||
return bool(normalized) and normalized.issubset(HASH_ONLY_RECOVERY_TYPES)
|
||||
@@ -1,6 +1,9 @@
|
||||
import os
|
||||
import sys
|
||||
try:
|
||||
import os
|
||||
import sys
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, _SCRIPT_DIR)
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except:
|
||||
pass
|
||||
@@ -18,6 +21,8 @@ import urllib.parse
|
||||
import argparse
|
||||
import shutil
|
||||
from typing import Dict, Optional
|
||||
from model_policies import is_physical_rename_protected
|
||||
from model_identity import computed_file_identity
|
||||
|
||||
|
||||
# Fixed tuples avoid rebuilding long extension lists for every scanned model.
|
||||
@@ -33,6 +38,37 @@ SIDECAR_SUFFIXES = (
|
||||
*PREVIEW_SUFFIXES,
|
||||
*CIVITAI_BACKUP_SUFFIXES,
|
||||
)
|
||||
|
||||
|
||||
def write_scan_progress(progress_file, phase, total=0, current=0, filename=""):
|
||||
if not progress_file:
|
||||
return
|
||||
temp_file = f"{progress_file}.{os.getpid()}.tmp"
|
||||
try:
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"phase": phase,
|
||||
"total": total,
|
||||
"current": current,
|
||||
"filename": filename,
|
||||
}, f, ensure_ascii=False)
|
||||
os.replace(temp_file, progress_file)
|
||||
except OSError:
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def count_scan_files(target_folder, target_files_basenames):
|
||||
total = 0
|
||||
selected = set(target_files_basenames)
|
||||
for _, _, files in os.walk(target_folder):
|
||||
total += sum(
|
||||
1 for filename in files
|
||||
if filename.endswith(".safetensors") and (not selected or filename in selected)
|
||||
)
|
||||
return total
|
||||
|
||||
# ==============================================================================
|
||||
# CIVITAI API 配置读取
|
||||
@@ -69,35 +105,6 @@ def calculate_sha256(file_path: str) -> str:
|
||||
|
||||
import struct
|
||||
|
||||
def extract_safetensors_hash(file_path: str) -> Optional[str]:
|
||||
"""尝试从 safetensors 头文件中以 O(1) 速度提取内置的 Hash,跳过全量计算"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header_size_bytes = f.read(8)
|
||||
if len(header_size_bytes) < 8:
|
||||
return None
|
||||
header_size = struct.unpack('<Q', header_size_bytes)[0]
|
||||
if header_size > 100 * 1024 * 1024: # 异常大小保护 (头文件大于100MB)
|
||||
return None
|
||||
|
||||
header_json_bytes = f.read(header_size)
|
||||
header_str = header_json_bytes.decode('utf-8')
|
||||
header_json = json.loads(header_str)
|
||||
|
||||
metadata = header_json.get('__metadata__', {})
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
# 优先级1: 标准 modelspec
|
||||
if 'modelspec.hash.sha256' in metadata:
|
||||
return metadata['modelspec.hash.sha256']
|
||||
if 'modelspec.hash.blake3' in metadata:
|
||||
return metadata['modelspec.hash.blake3']
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
return None
|
||||
|
||||
def infer_base_model_from_header(file_path: str) -> str:
|
||||
"""从 safetensors 头文件的张量键名推断底层 Base Model (用于脱机/HuggingFace 兼容)"""
|
||||
try:
|
||||
@@ -219,13 +226,22 @@ def main():
|
||||
parser.add_argument("--offline-only", action="store_true", help="跳过 Civitai 联网获取,强制使用本地脱机张量推断提取 Base Model")
|
||||
parser.add_argument("--force-overwrite", action="store_true", help="强制覆盖已存在的信息文件")
|
||||
parser.add_argument("--skip-local-metadata", action="store_true", help="忽略本地已有的.info / .json文件")
|
||||
parser.add_argument("--target-files", type=str, default="", help="仅扫描逗号分隔的具体文件(相对路径)")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("--target-files", type=str, default="", help="仅扫描逗号分隔的具体文件(相对路径)")
|
||||
parser.add_argument("--folder-type", default="", help="由 ComfyUI 传入的模型目录类型,用于执行安全策略")
|
||||
parser.add_argument("--progress-file", default="", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
if args.offline_only:
|
||||
# Offline means no network at all: previews and model pages come from Civitai.
|
||||
args.skip_media = True
|
||||
|
||||
target_folder = args.folder
|
||||
if not os.path.isdir(target_folder):
|
||||
print(f"[-] 错误: 文件夹不存在 -> {target_folder}")
|
||||
sys.exit(1)
|
||||
if not os.path.isdir(target_folder):
|
||||
print(f"[-] 错误: 文件夹不存在 -> {target_folder}")
|
||||
sys.exit(1)
|
||||
|
||||
if args.physical_rename and is_physical_rename_protected(args.folder_type, target_folder):
|
||||
print("[*] 基础组件目录受保护:VAE、CLIP/Text Encoder 与 CLIP Vision 不执行物理重命名。")
|
||||
args.physical_rename = False
|
||||
|
||||
backup_log_path = os.path.join(target_folder, "backup_rename_log.json")
|
||||
|
||||
@@ -286,19 +302,31 @@ def main():
|
||||
if args.target_files:
|
||||
target_files_basenames.extend([os.path.basename(t.strip()) for t in args.target_files.split(',')])
|
||||
|
||||
print(f"[*] 开始扫描文件夹: {target_folder}")
|
||||
print(f"[*] 开始扫描文件夹: {target_folder}")
|
||||
if args.dry_run:
|
||||
print("==================================================")
|
||||
print("[警告]: 当前处于 Dry-Run (空跑) 模式,不会修改系统中的任何文件!")
|
||||
print("==================================================")
|
||||
|
||||
for root, _, files in os.walk(target_folder):
|
||||
for filename in files:
|
||||
write_scan_progress(args.progress_file, "enumerating")
|
||||
total_files = count_scan_files(target_folder, target_files_basenames)
|
||||
write_scan_progress(args.progress_file, "scanning", total_files)
|
||||
current_file = 0
|
||||
last_progress_write = 0.0
|
||||
|
||||
for root, _, files in os.walk(target_folder):
|
||||
for filename in files:
|
||||
if not filename.endswith(".safetensors"):
|
||||
continue
|
||||
|
||||
if target_files_basenames and filename not in target_files_basenames:
|
||||
continue
|
||||
if target_files_basenames and filename not in target_files_basenames:
|
||||
continue
|
||||
|
||||
current_file += 1
|
||||
now = time.monotonic()
|
||||
if current_file == 1 or current_file == total_files or now - last_progress_write >= 0.2:
|
||||
write_scan_progress(args.progress_file, "scanning", total_files, current_file, filename)
|
||||
last_progress_write = now
|
||||
|
||||
file_path = os.path.join(root, filename)
|
||||
old_base = os.path.splitext(file_path)[0]
|
||||
@@ -327,6 +355,7 @@ def main():
|
||||
print(f"\n---> 处理文件: {filename} (位于 {root})")
|
||||
|
||||
civitai_data = None
|
||||
file_hash = None
|
||||
if info_exists and needs_rename:
|
||||
info_path = old_base + ".info"
|
||||
if not os.path.exists(info_path):
|
||||
@@ -339,29 +368,10 @@ def main():
|
||||
pass
|
||||
|
||||
if not civitai_data:
|
||||
file_hash = None
|
||||
|
||||
file_hash = calculate_sha256(file_path)
|
||||
if not args.offline_only:
|
||||
# Fallback 1: Try header hash on Civitai
|
||||
header_hash = extract_safetensors_hash(file_path)
|
||||
if header_hash:
|
||||
print(f"[*] 成功从头文件提取 Hash: {header_hash},尝试请求 Civitai...")
|
||||
civitai_data = fetch_civitai_info(header_hash)
|
||||
if civitai_data:
|
||||
file_hash = header_hash
|
||||
|
||||
# Fallback 2: If header hash fails (or doesn't exist), compute full SHA256
|
||||
if not civitai_data:
|
||||
print(f"[*] 头文件 Hash 未命中或不存在,计算全量物理 SHA256...")
|
||||
full_hash = calculate_sha256(file_path)
|
||||
civitai_data = fetch_civitai_info(full_hash)
|
||||
file_hash = full_hash
|
||||
else:
|
||||
print(f"[*] Offline-only: 跳过 Civitai 获取,将强制使用脱机张量推断")
|
||||
file_hash = extract_safetensors_hash(file_path)
|
||||
if not file_hash:
|
||||
file_hash = calculate_sha256(file_path)
|
||||
|
||||
civitai_data = fetch_civitai_info(file_hash)
|
||||
|
||||
# Fallback 3: Local Offline Inference (if Civitai still fails or offline_only)
|
||||
if not civitai_data:
|
||||
if args.skip_local_metadata:
|
||||
@@ -389,7 +399,7 @@ def main():
|
||||
|
||||
# --- 额外获取模型主页的说明文字 ---
|
||||
model_id = civitai_data.get("modelId")
|
||||
if model_id and model_id != -1:
|
||||
if model_id and model_id != -1 and not args.offline_only:
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
@@ -419,6 +429,8 @@ def main():
|
||||
# 兼容性大刀阔斧改革:直接保存全宇宙最原汁原味的格式
|
||||
# ==========================================
|
||||
info_data = civitai_data
|
||||
if file_hash:
|
||||
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
|
||||
|
||||
if args.virtual_rename:
|
||||
info_data["anomalous_custom_name"] = f"{model_name}_{version_name}"
|
||||
@@ -522,8 +534,9 @@ def main():
|
||||
success_count += 1
|
||||
|
||||
|
||||
# Save scan results
|
||||
if not args.dry_run:
|
||||
# Save scan results
|
||||
write_scan_progress(args.progress_file, "complete", total_files, total_files)
|
||||
if not args.dry_run:
|
||||
result_path = os.path.join(target_folder, ".scan_result.json")
|
||||
try:
|
||||
with open(result_path, 'w', encoding='utf-8') as f:
|
||||
|
||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,5 +1,11 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { translate } from './modules/locales.js';
|
||||
import { anomalousConfirm } from './modules/ui_dialog.js';
|
||||
import { findWorkflowHashRecord } from './modules/recipe_provenance.js';
|
||||
import {
|
||||
inferModelFolderTypes,
|
||||
requiresHashForModelRecovery,
|
||||
} from './modules/model_policies.js';
|
||||
|
||||
// Global cache for hashes: filename -> hash
|
||||
window.anomalous_hash_cache = window.anomalous_hash_cache || {};
|
||||
@@ -17,17 +23,12 @@ window.anomalous_update_hash_cache = function (models) {
|
||||
}
|
||||
};
|
||||
|
||||
function inferExpectedModelTypes(node, widget) {
|
||||
const widgetName = String(widget?.name || '').toLowerCase();
|
||||
const nodeType = String(node?.type || '').toLowerCase();
|
||||
const key = `${widgetName} ${nodeType}`;
|
||||
function isModelFilename(value) {
|
||||
return typeof value === 'string' && /\.(safetensors|ckpt|pt|bin)$/i.test(value);
|
||||
}
|
||||
|
||||
if (widgetName.includes('vae') || nodeType === 'vaeloader' || key.includes('vae loader')) return 'vae';
|
||||
if (widgetName.includes('lora') || key.includes('lora')) return 'loras';
|
||||
if (widgetName.includes('control_net') || widgetName.includes('controlnet') || key.includes('controlnet')) return 'controlnet';
|
||||
if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) return 'checkpoints';
|
||||
if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) return 'diffusion_models,unet';
|
||||
return '';
|
||||
function inferExpectedModelTypes(node, widget) {
|
||||
return inferModelFolderTypes(node, widget).join(',');
|
||||
}
|
||||
|
||||
window.anomalous_resolve_all_missing_nodes = async function (is_manual = false, silent = false) {
|
||||
@@ -44,7 +45,7 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
|
||||
if (node.widgets) {
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const w = node.widgets[i];
|
||||
if (typeof w.value === 'string' && (w.value.endsWith('.safetensors') || w.value.endsWith('.ckpt') || w.value.endsWith('.pt'))) {
|
||||
if (isModelFilename(w.value)) {
|
||||
if (w.options && w.options.values && !w.options.values.includes(w.value)) {
|
||||
has_missing = true;
|
||||
break;
|
||||
@@ -67,13 +68,26 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
|
||||
}
|
||||
|
||||
const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
|
||||
const getWorkflowHash = (nodeId, val) => {
|
||||
if (!hashes || typeof val !== 'string') return null;
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const winVal = val.replace(/\//g, '\\');
|
||||
return hashes[`${nodeId}_${val}`] ||
|
||||
hashes[`${nodeId}_${normVal}`] ||
|
||||
hashes[`${nodeId}_${winVal}`] ||
|
||||
hashes[val] ||
|
||||
hashes[normVal] ||
|
||||
hashes[winVal] ||
|
||||
null;
|
||||
};
|
||||
|
||||
let needsGlobalHashRefresh = false;
|
||||
provenanceCheck:
|
||||
for (const node of app.graph._nodes) {
|
||||
for (const widget of (node.widgets || [])) {
|
||||
const value = widget.value;
|
||||
if (typeof value !== 'string' || !(value.endsWith('.safetensors') || value.endsWith('.ckpt') || value.endsWith('.pt'))) continue;
|
||||
if (!hashes[`${node.id}_${value}`]) {
|
||||
if (!isModelFilename(value)) continue;
|
||||
if (!getWorkflowHash(node.id, value)) {
|
||||
needsGlobalHashRefresh = true;
|
||||
break provenanceCheck;
|
||||
}
|
||||
@@ -96,12 +110,22 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
|
||||
|
||||
let fixed_count = 0;
|
||||
|
||||
// These are per-check results shown by Model Doctor. Clear stale evidence
|
||||
// before resolving the current graph again.
|
||||
for (const node of app.graph._nodes) {
|
||||
for (const widget of (node.widgets || [])) {
|
||||
delete widget.anomalous_resolution_status;
|
||||
delete widget.anomalous_size_candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const findHashData = (node, widget, value) => {
|
||||
let hashData = hashes[`${node.id}_${value}`];
|
||||
if (!hashData && window.anomalous_hash_cache) {
|
||||
let hashData = getWorkflowHash(node.id, value);
|
||||
if (!hashData && !requiresHashForModelRecovery(node, widget) && window.anomalous_hash_cache && typeof value === 'string') {
|
||||
const parts = value.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
const cacheData = window.anomalous_hash_cache[value] || window.anomalous_hash_cache[basename];
|
||||
const normVal = value.replace(/\\/g, '/');
|
||||
const cacheData = window.anomalous_hash_cache[value] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
|
||||
if (cacheData) hashData = typeof cacheData === 'string' ? { hash: cacheData, size: "" } : cacheData;
|
||||
}
|
||||
return hashData;
|
||||
@@ -116,7 +140,7 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const widget = node.widgets[i];
|
||||
const value = widget.value;
|
||||
if (typeof value !== 'string' || !(value.endsWith('.safetensors') || value.endsWith('.ckpt') || value.endsWith('.pt'))) continue;
|
||||
if (!isModelFilename(value)) continue;
|
||||
if (widget.options && widget.options.values && !widget.options.values.includes(value)) {
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
const nativeMatch = widget.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normalized);
|
||||
@@ -159,7 +183,7 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const w = node.widgets[i];
|
||||
const val = w.value;
|
||||
if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
|
||||
if (isModelFilename(val)) {
|
||||
|
||||
// Native slash mismatch fix: if val isn't in options, but matches if we normalize slashes
|
||||
if (w.options && w.options.values && !w.options.values.includes(val)) {
|
||||
@@ -204,7 +228,39 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
|
||||
resData = await res.json();
|
||||
}
|
||||
|
||||
if (resData.found) {
|
||||
if (resData.identity_conflict) {
|
||||
w.anomalous_resolution_status = 'identity_conflict';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resData.confirmation_required) {
|
||||
w.anomalous_resolution_status = 'size_candidate';
|
||||
w.anomalous_size_candidate = {
|
||||
filename: resData.filename,
|
||||
type: resData.type,
|
||||
size: resData.size,
|
||||
};
|
||||
|
||||
// Background checks may discover a candidate, but only an
|
||||
// explicit manual check is allowed to ask for and apply it.
|
||||
if (!is_manual || silent) continue;
|
||||
const confirmed = await anomalousConfirm(
|
||||
translate('doctorSizeCandidatePrompt', {
|
||||
original: val,
|
||||
candidate: resData.filename,
|
||||
size: resData.size ?? s ?? '',
|
||||
}),
|
||||
translate('doctorSizeCandidateTitle'),
|
||||
{
|
||||
okLabel: translate('doctorUseSizeCandidate'),
|
||||
cancelLabel: translate('doctorKeepMissing'),
|
||||
},
|
||||
);
|
||||
if (!confirmed) continue;
|
||||
resData = { ...resData, found: true, manually_confirmed: true };
|
||||
}
|
||||
|
||||
if (resData.found) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const normRes = resData.filename.replace(/\\/g, '/');
|
||||
|
||||
@@ -240,7 +296,8 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
|
||||
finalValue = refreshedMatch;
|
||||
}
|
||||
|
||||
console.log(`[Anomalous Hash Resolver] Auto-fixed missing model: ${val} -> ${finalValue}`);
|
||||
const repairKind = resData.manually_confirmed ? 'Manually confirmed' : 'Auto-fixed';
|
||||
console.log(`[Anomalous Hash Resolver] ${repairKind} missing model: ${val} -> ${finalValue}`);
|
||||
w.value = finalValue;
|
||||
const wIdx = node.widgets.indexOf(w);
|
||||
if (wIdx !== -1 && node.widgets_values) {
|
||||
@@ -249,8 +306,10 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
|
||||
delete node.color;
|
||||
delete node.bgcolor;
|
||||
node.has_errors = false;
|
||||
node.anomalous_auto_resolved = true;
|
||||
node.anomalous_original_missing_val = val;
|
||||
node.anomalous_auto_resolved = !resData.manually_confirmed;
|
||||
node.anomalous_original_missing_val = val;
|
||||
delete w.anomalous_resolution_status;
|
||||
delete w.anomalous_size_candidate;
|
||||
if (w.callback) {
|
||||
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
|
||||
}
|
||||
@@ -335,65 +394,102 @@ app.registerExtension({
|
||||
window.anomalous_unscanned_models = [];
|
||||
graphClass.prototype.__anomalousHashResolverPatched = true;
|
||||
graphClass.prototype.serialize = function () {
|
||||
const data = origSerialize.apply(this, arguments);
|
||||
|
||||
if (localStorage.getItem('anomalous_inject_hash') === 'false') {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Clone extra to avoid mutating the live graph's extra object
|
||||
const extraObj = data.extra ? JSON.parse(JSON.stringify(data.extra)) : {};
|
||||
extraObj.anomalous_hashes = {};
|
||||
let unscanned_models = [];
|
||||
|
||||
if (data.nodes) {
|
||||
const liveNodes = this._nodes || [];
|
||||
for (const node of data.nodes) {
|
||||
const liveNode = liveNodes.find(n => n.id === node.id);
|
||||
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
for (const val of node.widgets_values) {
|
||||
if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
|
||||
const parts = val.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
|
||||
let valIsMissing = false;
|
||||
if (liveNode && liveNode.widgets) {
|
||||
const matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo");
|
||||
if (matchingWidget && matchingWidget.options && matchingWidget.options.values && !matchingWidget.options.values.includes(val)) {
|
||||
valIsMissing = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!valIsMissing) {
|
||||
const cache_data = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[basename];
|
||||
if (cache_data) {
|
||||
if (typeof cache_data === 'string') {
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = { hash: cache_data, size: "" };
|
||||
} else {
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = cache_data;
|
||||
}
|
||||
} else {
|
||||
if (!unscanned_models.includes(basename)) {
|
||||
unscanned_models.push(basename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data.extra = extraObj;
|
||||
|
||||
data.extra = extraObj;
|
||||
window.anomalous_unscanned_models = unscanned_models;
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Intercept loadGraphData to resolve missing models
|
||||
const data = origSerialize.apply(this, arguments);
|
||||
|
||||
if (localStorage.getItem('anomalous_inject_hash') === 'false') {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Clone extra to avoid mutating the live graph's extra object
|
||||
const extraObj = data.extra ? JSON.parse(JSON.stringify(data.extra)) : {};
|
||||
const existingProvenance = {
|
||||
extra: {
|
||||
anomalous_hashes: extraObj.anomalous_hashes
|
||||
? JSON.parse(JSON.stringify(extraObj.anomalous_hashes))
|
||||
: {},
|
||||
},
|
||||
};
|
||||
extraObj.anomalous_hashes = {};
|
||||
const liveSources = (this.extra && this.extra.anomalous_model_sources) ||
|
||||
(app.graph?.extra && app.graph.extra.anomalous_model_sources) ||
|
||||
extraObj.anomalous_model_sources || null;
|
||||
if (liveSources && typeof liveSources === 'object') {
|
||||
extraObj.anomalous_model_sources = JSON.parse(JSON.stringify(liveSources));
|
||||
}
|
||||
let unscanned_models = [];
|
||||
|
||||
if (data.nodes) {
|
||||
const liveNodes = this._nodes || [];
|
||||
for (const node of data.nodes) {
|
||||
const liveNode = liveNodes.find(n => n.id === node.id);
|
||||
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
for (const val of node.widgets_values) {
|
||||
if (isModelFilename(val)) {
|
||||
const parts = val.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
|
||||
let valIsMissing = false;
|
||||
let matchingWidget = null;
|
||||
if (liveNode && liveNode.widgets) {
|
||||
matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo") || null;
|
||||
if (matchingWidget && matchingWidget.options && matchingWidget.options.values && !matchingWidget.options.values.includes(val)) {
|
||||
valIsMissing = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!valIsMissing) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const cache_data = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
|
||||
if (cache_data) {
|
||||
let hashObj = typeof cache_data === 'string' ? { hash: cache_data, size: "" } : cache_data;
|
||||
if (matchingWidget && requiresHashForModelRecovery(liveNode, matchingWidget) && !hashObj.hash) {
|
||||
const preserved = findWorkflowHashRecord(existingProvenance, node.id, val);
|
||||
if (preserved?.hash) hashObj = preserved;
|
||||
else {
|
||||
if (!unscanned_models.includes(basename)) unscanned_models.push(basename);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = hashObj;
|
||||
if (normVal !== val) {
|
||||
extraObj.anomalous_hashes[`${node.id}_${normVal}`] = hashObj;
|
||||
}
|
||||
} else {
|
||||
const preserved = findWorkflowHashRecord(existingProvenance, node.id, val);
|
||||
if (preserved) {
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = preserved;
|
||||
if (normVal !== val) extraObj.anomalous_hashes[`${node.id}_${normVal}`] = preserved;
|
||||
} else if (!unscanned_models.includes(basename)) {
|
||||
unscanned_models.push(basename);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Missing dropdown values cannot be rediscovered from the
|
||||
// local cache. Preserve recipe/workflow provenance so Model
|
||||
// Doctor can still recover the exact appended reference.
|
||||
const preserved = findWorkflowHashRecord(existingProvenance, node.id, val);
|
||||
if (preserved) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = preserved;
|
||||
if (normVal !== val) extraObj.anomalous_hashes[`${node.id}_${normVal}`] = preserved;
|
||||
} else {
|
||||
if (!unscanned_models.includes(basename)) {
|
||||
unscanned_models.push(basename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data.extra = extraObj;
|
||||
window.anomalous_unscanned_models = unscanned_models;
|
||||
return data;
|
||||
};
|
||||
|
||||
// Intercept loadGraphData to resolve missing models
|
||||
if (typeof app.loadGraphData === 'function') {
|
||||
const origLoadGraphData = app.loadGraphData;
|
||||
app.loadGraphData = function (graphData) {
|
||||
@@ -401,5 +497,5 @@ app.registerExtension({
|
||||
return origLoadGraphData.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,589 +1,32 @@
|
||||
import { showDetail, showEditModal, _openAdvancedModelSelector, setWidgetValuePath } from './modules/ui_detail.js';
|
||||
import { loadModels, applyModelToCanvas, stopMediaInContainer } from './modules/ui_grid.js';
|
||||
import { createDOM, renderSidebar, loadFolders, showHelp, hideAllPanels, openFolderManager } from './modules/ui_sidebar.js';
|
||||
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode, showGalleryViewer } from './modules/ui_gallery.js';
|
||||
import { showNotebooks, closeWorkspace, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook, renderNotebookEditor, fillNotebookGalleries, sendNotebookToCanvas } from './modules/ui_notebooks.js';
|
||||
import { showRecipes, refreshRecipes, renderRecipeList, handleSaveRecipe } from './modules/ui_recipes.js';
|
||||
import { initDoctorPanel, diagnoseNode, renderGlobalDashboard, initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory, _openGalleryReplacer, openLoraInsertionPicker, runGlobalDoctorScan } from './modules/ui_doctor.js';
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('anomalous_btn_x');
|
||||
localStorage.removeItem('anomalous_btn_y');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { normalizeLocale, resolveLocale, translate } from './modules/locales.js';
|
||||
// ============================================================================
|
||||
// TABLE OF CONTENTS (TOC)
|
||||
// 1. App Registration & Entry (Search for "app.registerExtension")
|
||||
// 2. State & Class Constructor (Search for "class AnomalousBrowser")
|
||||
// 3. UI - Sidebar (Search for "createDOM")
|
||||
// 4. UI - Main Grid (Search for "renderGrid")
|
||||
// 5. UI - Detail Panel (Search for "showDetail")
|
||||
// 6. UI - Gallery Viewer (Search for "createGalleryViewer")
|
||||
// 7. UI - Doctor Panel (Search for "createDoctorPanel")
|
||||
// 8. Notebooks & Workflow Recipes (Search for "Notebook" or "Recipes")
|
||||
// ============================================================================
|
||||
|
||||
let defaultLang = 'zh';
|
||||
try {
|
||||
let comfyDetected = false;
|
||||
const aglLang = localStorage.getItem('Comfy.Settings.AIGODLIKE-COMFYUI-TRANSLATION.Language');
|
||||
if (aglLang) {
|
||||
defaultLang = aglLang.toLowerCase().includes('en') ? 'en' : 'zh';
|
||||
comfyDetected = true;
|
||||
} else {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.toLowerCase().includes('lang') || key.toLowerCase().includes('locale'))) {
|
||||
const val = localStorage.getItem(key);
|
||||
if (typeof val === 'string') {
|
||||
const lowerVal = val.toLowerCase();
|
||||
if (lowerVal.includes('zh') || lowerVal.includes('chinese')) {
|
||||
defaultLang = 'zh';
|
||||
comfyDetected = true;
|
||||
break;
|
||||
} else if (lowerVal.includes('en') || lowerVal.includes('english')) {
|
||||
defaultLang = 'en';
|
||||
comfyDetected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!comfyDetected && navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
|
||||
defaultLang = 'en';
|
||||
}
|
||||
} catch (e) {
|
||||
if (navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
|
||||
defaultLang = 'en';
|
||||
}
|
||||
}
|
||||
let currentLang = resolveLocale(localStorage.getItem('anomalous_lang') || defaultLang);
|
||||
window.anomalous_browser_lang = currentLang;
|
||||
const t = (key, params) => translate(key, params, window.anomalous_browser_lang || currentLang);
|
||||
|
||||
class AnomalousBrowser {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
this.sidebar = null;
|
||||
this.grid = null;
|
||||
this.detailPanel = null;
|
||||
this.currentType = 'loras';
|
||||
this.currentPathIdx = 0;
|
||||
this.currentSubfolder = '/';
|
||||
this.foldersData = null;
|
||||
this.expandedFolders = new Set(['/', 'checkpoints', 'loras', 'unet', 'diffusion_models']);
|
||||
this.energySaving = localStorage.getItem('anomalous_energy_saving') === 'true';
|
||||
this.cardThumbnailMode = localStorage.getItem('anomalous_card_thumbnail_mode') === 'original'
|
||||
? 'original'
|
||||
: 'balanced';
|
||||
this.createDOM();
|
||||
}
|
||||
// [EXTRACTED] createDOM
|
||||
// [EXTRACTED] showHelp
|
||||
// [EXTRACTED] loadFolders
|
||||
// [EXTRACTED] renderSidebar
|
||||
// [EXTRACTED] loadModels
|
||||
// [EXTRACTED] applyModelToCanvas
|
||||
// [EXTRACTED] stopMediaInContainer
|
||||
// [EXTRACTED] showDetail
|
||||
|
||||
show() {
|
||||
if (this._idleReleaseTimer) {
|
||||
clearTimeout(this._idleReleaseTimer);
|
||||
this._idleReleaseTimer = null;
|
||||
}
|
||||
this.setTriggerVisible(false);
|
||||
this.modal.classList.add('visible');
|
||||
if (!this.foldersData) {
|
||||
this.loadFolders();
|
||||
} else {
|
||||
this.loadModels();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.classList.remove('visible');
|
||||
this.setTriggerVisible(true);
|
||||
if (this._modelLoadController) this._modelLoadController.abort();
|
||||
if (this._modelMediaObserver) this._modelMediaObserver.disconnect();
|
||||
this.modal.querySelectorAll('video, audio').forEach(media => media.pause());
|
||||
this.stopMediaInContainer(this.grid);
|
||||
if (this._idleReleaseTimer) clearTimeout(this._idleReleaseTimer);
|
||||
this._idleReleaseTimer = setTimeout(() => {
|
||||
if (this.modal.classList.contains('visible')) return;
|
||||
this.stopMediaInContainer(this.grid);
|
||||
this.grid.replaceChildren();
|
||||
this.models = [];
|
||||
}, 90000);
|
||||
}
|
||||
import { createBrowserEntry } from "./modules/browser_entry.js?v=20260929-hotfix-1";
|
||||
import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js";
|
||||
|
||||
setTriggerVisible(visible) {
|
||||
const trigger = this.triggerButton || document.getElementById('anomalous-trigger-btn');
|
||||
trigger?.classList.toggle('anomalous-trigger-hidden', !visible);
|
||||
}
|
||||
// [EXTRACTED] showNotebooks
|
||||
|
||||
async translateText(text) {
|
||||
if (!text || !text.trim()) return "";
|
||||
try {
|
||||
const res = await fetch('/anomalous/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text, target_lang: window.anomalous_browser_lang === 'zh' ? 'zh-CN' : 'en' })
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.translated || text;
|
||||
} catch (e) { return text; }
|
||||
}
|
||||
// [EXTRACTED] loadGalleryImages
|
||||
// [EXTRACTED] showEditModal
|
||||
// [EXTRACTED] showGeneratedGallery
|
||||
// [EXTRACTED] showGallerySelectMode
|
||||
// [EXTRACTED] showGalleryViewer
|
||||
// [EXTRACTED] refreshNotebooks
|
||||
// [EXTRACTED] saveCurrentNotebook
|
||||
// [EXTRACTED] deleteCurrentNotebook
|
||||
// [EXTRACTED] renderNotebookEditor
|
||||
// [EXTRACTED] fillNotebookGalleries
|
||||
// [EXTRACTED] sendNotebookToCanvas
|
||||
// [EXTRACTED] setWidgetValuePath
|
||||
// [EXTRACTED] hideAllPanels
|
||||
|
||||
// [Doctor & Assistant Panel methods extracted to ui_doctor.js]
|
||||
|
||||
showPreflightImportModal() {
|
||||
let overlay = document.getElementById('anomalous-import-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'anomalous-import-overlay';
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100vw';
|
||||
overlay.style.height = '100vh';
|
||||
overlay.style.backgroundColor = 'rgba(0,0,0,0.85)';
|
||||
overlay.style.zIndex = '9999999';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.alignItems = 'center';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.style.width = '600px';
|
||||
modal.style.maxWidth = '90vw';
|
||||
modal.style.background = '#222';
|
||||
modal.style.borderRadius = '12px';
|
||||
modal.style.padding = '20px';
|
||||
modal.style.border = '1px solid rgba(255,255,255,0.1)';
|
||||
modal.style.boxShadow = '0 10px 30px rgba(0,0,0,0.5)';
|
||||
modal.style.display = 'flex';
|
||||
modal.style.flexDirection = 'column';
|
||||
modal.style.gap = '15px';
|
||||
|
||||
const header = document.createElement('h2');
|
||||
header.style.margin = '0';
|
||||
header.style.color = '#fff';
|
||||
header.textContent = t('mainPreflightTitle');
|
||||
modal.appendChild(header);
|
||||
|
||||
const desc = document.createElement('div');
|
||||
desc.style.color = '#aaa';
|
||||
desc.style.fontSize = '14px';
|
||||
desc.textContent = t('mainPreflightDesc');
|
||||
modal.appendChild(desc);
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.id = 'anomalous-import-textarea';
|
||||
textarea.style.width = '100%';
|
||||
textarea.style.height = '150px';
|
||||
textarea.style.background = '#111';
|
||||
textarea.style.color = '#fff';
|
||||
textarea.style.border = '1px solid #444';
|
||||
textarea.style.borderRadius = '6px';
|
||||
textarea.style.padding = '10px';
|
||||
textarea.style.fontFamily = 'monospace';
|
||||
textarea.style.resize = 'vertical';
|
||||
textarea.style.boxSizing = 'border-box';
|
||||
textarea.placeholder = '{"last_node_id": ...}';
|
||||
modal.appendChild(textarea);
|
||||
|
||||
const resultArea = document.createElement('div');
|
||||
resultArea.id = 'anomalous-import-result';
|
||||
resultArea.style.maxHeight = '300px';
|
||||
resultArea.style.overflowY = 'auto';
|
||||
resultArea.style.display = 'none';
|
||||
resultArea.style.flexDirection = 'column';
|
||||
resultArea.style.gap = '10px';
|
||||
resultArea.style.background = 'rgba(0,0,0,0.2)';
|
||||
resultArea.style.padding = '15px';
|
||||
resultArea.style.borderRadius = '8px';
|
||||
modal.appendChild(resultArea);
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.style.display = 'flex';
|
||||
btnRow.style.justifyContent = 'flex-end';
|
||||
btnRow.style.gap = '10px';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = t('mainCancel');
|
||||
closeBtn.style.padding = '8px 16px';
|
||||
closeBtn.style.background = 'transparent';
|
||||
closeBtn.style.color = '#ccc';
|
||||
closeBtn.style.border = '1px solid #555';
|
||||
closeBtn.style.borderRadius = '6px';
|
||||
closeBtn.style.cursor = 'pointer';
|
||||
closeBtn.onclick = () => { overlay.style.display = 'none'; };
|
||||
|
||||
const analyzeBtn = document.createElement('button');
|
||||
analyzeBtn.id = 'anomalous-import-analyze-btn';
|
||||
analyzeBtn.textContent = t('mainAnalyzePreflight');
|
||||
analyzeBtn.style.padding = '8px 16px';
|
||||
analyzeBtn.style.background = '#1a73e8';
|
||||
analyzeBtn.style.color = '#fff';
|
||||
analyzeBtn.style.border = 'none';
|
||||
analyzeBtn.style.borderRadius = '6px';
|
||||
analyzeBtn.style.cursor = 'pointer';
|
||||
|
||||
const loadBtn = document.createElement('button');
|
||||
loadBtn.id = 'anomalous-import-load-btn';
|
||||
loadBtn.textContent = t('mainForceLoad');
|
||||
loadBtn.style.padding = '8px 16px';
|
||||
loadBtn.style.background = '#28a745';
|
||||
loadBtn.style.color = '#fff';
|
||||
loadBtn.style.border = 'none';
|
||||
loadBtn.style.borderRadius = '6px';
|
||||
loadBtn.style.cursor = 'pointer';
|
||||
loadBtn.style.display = 'none';
|
||||
|
||||
let parsedGraphData = null;
|
||||
|
||||
analyzeBtn.onclick = async () => {
|
||||
try {
|
||||
const jsonStr = textarea.value.trim();
|
||||
if (!jsonStr) return;
|
||||
parsedGraphData = JSON.parse(jsonStr);
|
||||
|
||||
try {
|
||||
const res = await fetch('/anomalous/all_hashes');
|
||||
const hData = await res.json();
|
||||
const hObj = hData.hashes ? hData.hashes : hData;
|
||||
Object.assign(window.anomalous_hash_cache, hObj);
|
||||
} catch (e) { }
|
||||
|
||||
const models = [];
|
||||
if (parsedGraphData.nodes) {
|
||||
for (const node of parsedGraphData.nodes) {
|
||||
if (node.widgets_values) {
|
||||
for (const v of node.widgets_values) {
|
||||
if (typeof v === 'string' && (v.endsWith('.safetensors') || v.endsWith('.ckpt') || v.endsWith('.pt') || v.endsWith('.sft') || v.endsWith('.bin'))) {
|
||||
models.push({ nodeType: node.type, value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea.style.display = 'none';
|
||||
resultArea.style.display = 'flex';
|
||||
analyzeBtn.style.display = 'none';
|
||||
loadBtn.style.display = 'block';
|
||||
|
||||
if (models.length === 0) {
|
||||
const noDependencies = document.createElement('div');
|
||||
noDependencies.style.color = '#28a745';
|
||||
noDependencies.textContent = t('mainNoDependencies');
|
||||
resultArea.replaceChildren(noDependencies);
|
||||
return;
|
||||
}
|
||||
export { setAbyssalScarletTheme };
|
||||
|
||||
const detectedHeader = document.createElement('div');
|
||||
detectedHeader.style.color = '#fff';
|
||||
detectedHeader.style.fontWeight = 'bold';
|
||||
detectedHeader.style.marginBottom = '10px';
|
||||
detectedHeader.textContent = t('mainDetectedModels', { count: models.length });
|
||||
resultArea.replaceChildren(detectedHeader);
|
||||
|
||||
for (const m of models) {
|
||||
const val = m.value;
|
||||
const parts = val.split(/[\\/]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
|
||||
let isHealthy = false;
|
||||
let cacheHit = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[basename];
|
||||
|
||||
const fetchRes = await fetch(`/anomalous/resolve_hash?hash=unknown&size=&filename=${encodeURIComponent(val)}`);
|
||||
const fetchData = await fetchRes.json();
|
||||
if (fetchData.found) {
|
||||
isHealthy = true;
|
||||
} else if (cacheHit) {
|
||||
const resolveRes = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(cacheHit.hash || cacheHit)}&size=`);
|
||||
const resolveData = await resolveRes.json();
|
||||
if (resolveData.found) isHealthy = true;
|
||||
}
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.style.display = 'flex';
|
||||
item.style.alignItems = 'center';
|
||||
item.style.justifyContent = 'space-between';
|
||||
item.style.padding = '8px';
|
||||
item.style.background = 'rgba(255,255,255,0.05)';
|
||||
item.style.borderRadius = '4px';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.style.display = 'flex';
|
||||
left.style.alignItems = 'center';
|
||||
left.style.gap = '8px';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.innerText = isHealthy ? '✅' : '⚠️';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.innerText = `[${m.nodeType}] ${basename}`;
|
||||
text.style.color = isHealthy ? '#ccc' : '#ff6b6b';
|
||||
text.style.fontSize = '14px';
|
||||
|
||||
left.appendChild(icon);
|
||||
left.appendChild(text);
|
||||
item.appendChild(left);
|
||||
|
||||
if (!isHealthy) {
|
||||
const right = document.createElement('a');
|
||||
const searchHash = cacheHit ? (typeof cacheHit === 'string' ? cacheHit : cacheHit.hash) : null;
|
||||
const searchStr = searchHash || basename.replace('.safetensors', '').replace('.ckpt', '').replace('.pt', '').replace('.sft', '');
|
||||
right.href = `https://civitai.com/search/models?sortBy=models_v9&query=${encodeURIComponent(searchStr)}`;
|
||||
right.target = '_blank';
|
||||
right.textContent = t('mainDownloadCivitai');
|
||||
right.style.color = '#1a73e8';
|
||||
right.style.fontSize = '12px';
|
||||
right.style.textDecoration = 'none';
|
||||
item.appendChild(right);
|
||||
} else {
|
||||
const right = document.createElement('span');
|
||||
right.textContent = t('mainReady');
|
||||
right.style.color = '#28a745';
|
||||
right.style.fontSize = '12px';
|
||||
item.appendChild(right);
|
||||
}
|
||||
|
||||
resultArea.appendChild(item);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
alert(t('mainInvalidJson') + ' ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
loadBtn.onclick = () => {
|
||||
if (parsedGraphData && app) {
|
||||
app.loadGraphData(parsedGraphData);
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
btnRow.appendChild(closeBtn);
|
||||
btnRow.appendChild(analyzeBtn);
|
||||
btnRow.appendChild(loadBtn);
|
||||
modal.appendChild(btnRow);
|
||||
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
const textarea = document.getElementById('anomalous-import-textarea');
|
||||
const resultArea = document.getElementById('anomalous-import-result');
|
||||
const analyzeBtn = document.getElementById('anomalous-import-analyze-btn');
|
||||
const loadBtn = document.getElementById('anomalous-import-load-btn');
|
||||
|
||||
if (textarea) {
|
||||
textarea.value = '';
|
||||
textarea.style.display = 'block';
|
||||
}
|
||||
if (resultArea) {
|
||||
resultArea.style.display = 'none';
|
||||
resultArea.innerHTML = '';
|
||||
}
|
||||
if (analyzeBtn) analyzeBtn.style.display = 'block';
|
||||
if (loadBtn) loadBtn.style.display = 'none';
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
// [EXTRACTED] _openAdvancedModelSelector
|
||||
|
||||
}
|
||||
|
||||
|
||||
// --- Extracted Module Bindings ---
|
||||
AnomalousBrowser.prototype.initDoctorPanel = initDoctorPanel;
|
||||
AnomalousBrowser.prototype.diagnoseNode = diagnoseNode;
|
||||
AnomalousBrowser.prototype.renderGlobalDashboard = renderGlobalDashboard;
|
||||
AnomalousBrowser.prototype.initAssistantPanel = initAssistantPanel;
|
||||
AnomalousBrowser.prototype.renderAssistantModelCard = renderAssistantModelCard;
|
||||
AnomalousBrowser.prototype._loadAssistantHistory = _loadAssistantHistory;
|
||||
AnomalousBrowser.prototype._openGalleryReplacer = _openGalleryReplacer;
|
||||
AnomalousBrowser.prototype.openLoraInsertionPicker = openLoraInsertionPicker;
|
||||
AnomalousBrowser.prototype.runGlobalDoctorScan = runGlobalDoctorScan;
|
||||
const browserEntry = createBrowserEntry({ translate: t, getCurrentLanguage });
|
||||
|
||||
AnomalousBrowser.prototype.showNotebooks = showNotebooks;
|
||||
AnomalousBrowser.prototype.closeWorkspace = closeWorkspace;
|
||||
AnomalousBrowser.prototype.refreshNotebooks = refreshNotebooks;
|
||||
AnomalousBrowser.prototype.saveCurrentNotebook = saveCurrentNotebook;
|
||||
AnomalousBrowser.prototype.deleteCurrentNotebook = deleteCurrentNotebook;
|
||||
AnomalousBrowser.prototype.renderNotebookEditor = renderNotebookEditor;
|
||||
AnomalousBrowser.prototype.fillNotebookGalleries = fillNotebookGalleries;
|
||||
AnomalousBrowser.prototype.sendNotebookToCanvas = sendNotebookToCanvas;
|
||||
app.registerExtension({
|
||||
name: "Anomalous.ModelBrowser",
|
||||
settings: [...browserEntry.settings, ...createInterfaceSettings()],
|
||||
actionBarButtons: browserEntry.actionBarButtons,
|
||||
commands: browserEntry.commands,
|
||||
keybindings: browserEntry.keybindings,
|
||||
menuCommands: browserEntry.menuCommands,
|
||||
setup: browserEntry.setup
|
||||
});
|
||||
|
||||
AnomalousBrowser.prototype.showRecipes = showRecipes;
|
||||
AnomalousBrowser.prototype.refreshRecipes = refreshRecipes;
|
||||
AnomalousBrowser.prototype.renderRecipeList = renderRecipeList;
|
||||
AnomalousBrowser.prototype.handleSaveRecipe = handleSaveRecipe;
|
||||
|
||||
AnomalousBrowser.prototype.loadGalleryImages = loadGalleryImages;
|
||||
AnomalousBrowser.prototype.refreshGalleryImages = refreshGalleryImages;
|
||||
AnomalousBrowser.prototype.showGeneratedGallery = showGeneratedGallery;
|
||||
AnomalousBrowser.prototype.showGallerySelectMode = showGallerySelectMode;
|
||||
AnomalousBrowser.prototype.showGalleryViewer = showGalleryViewer;
|
||||
|
||||
AnomalousBrowser.prototype.createDOM = createDOM;
|
||||
AnomalousBrowser.prototype.openFolderManager = openFolderManager;
|
||||
AnomalousBrowser.prototype.renderSidebar = renderSidebar;
|
||||
AnomalousBrowser.prototype.loadFolders = loadFolders;
|
||||
AnomalousBrowser.prototype.showHelp = showHelp;
|
||||
AnomalousBrowser.prototype.hideAllPanels = hideAllPanels;
|
||||
|
||||
AnomalousBrowser.prototype.loadModels = loadModels;
|
||||
AnomalousBrowser.prototype.applyModelToCanvas = applyModelToCanvas;
|
||||
AnomalousBrowser.prototype.stopMediaInContainer = stopMediaInContainer;
|
||||
|
||||
AnomalousBrowser.prototype.showDetail = showDetail;
|
||||
AnomalousBrowser.prototype.showEditModal = showEditModal;
|
||||
AnomalousBrowser.prototype._openAdvancedModelSelector = _openAdvancedModelSelector;
|
||||
AnomalousBrowser.prototype.setWidgetValuePath = setWidgetValuePath;
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Anomalous.ModelBrowser',
|
||||
async setup() {
|
||||
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=' + Date.now();
|
||||
if (!document.querySelector(`link[href^="/extensions/Anomalous_Model_Browser/styles.css"]`)) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.type = "text/css";
|
||||
link.href = cssUrl;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (!localStorage.getItem('anomalous_lang')) {
|
||||
try {
|
||||
if (app && app.ui && app.ui.settings) {
|
||||
const locale = app.ui.settings.getSettingValue('Comfy.Locale') || app.ui.settings.getSettingValue('Comfy.Locale.Language');
|
||||
if (locale) {
|
||||
currentLang = normalizeLocale(locale) || defaultLang;
|
||||
window.anomalous_browser_lang = currentLang;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
let browser = null;
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'anomalous-trigger-btn';
|
||||
btn.textContent = '📦';
|
||||
btn.setAttribute('aria-label', 'Anomalous Model Browser');
|
||||
|
||||
const ensureBrowser = () => {
|
||||
if (browser) return browser;
|
||||
try {
|
||||
browser = new AnomalousBrowser();
|
||||
browser.triggerButton = btn;
|
||||
window.anomalousBrowserInstance = browser;
|
||||
btn.classList.remove('anomalous-trigger-error');
|
||||
return browser;
|
||||
} catch (error) {
|
||||
console.error('[Anomalous Model Browser] UI initialization failed:', error);
|
||||
btn.classList.add('anomalous-trigger-error');
|
||||
btn.title = currentLang === 'zh' ? t('mainRetryInit') : t('mainRetryInitEn');
|
||||
btn.setAttribute('aria-label', btn.title);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
btn.title = t('mainOpenTitle');
|
||||
let isDragging = false;
|
||||
let startX, startY, initialX, initialY;
|
||||
|
||||
btn.addEventListener('mousedown', (e) => {
|
||||
isDragging = true;
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
initialX = rect.left; initialY = rect.top;
|
||||
btn.style.transition = 'none';
|
||||
});
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
let newX = initialX + (e.clientX - startX);
|
||||
let newY = initialY + (e.clientY - startY);
|
||||
if (newX < 0) newX = 0;
|
||||
if (newY < 0) newY = 0;
|
||||
if (newX > window.innerWidth - 60) newX = window.innerWidth - 60;
|
||||
if (newY > window.innerHeight - 60) newY = window.innerHeight - 60;
|
||||
btn.style.left = newX + 'px';
|
||||
btn.style.top = newY + 'px';
|
||||
btn.style.right = 'auto'; btn.style.bottom = 'auto';
|
||||
});
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
btn.style.transition = 'transform 0.15s, box-shadow 0.15s';
|
||||
localStorage.setItem('anomalous_btn_x', btn.style.left);
|
||||
localStorage.setItem('anomalous_btn_y', btn.style.top);
|
||||
if (Math.abs(e.clientX - startX) < 5 && Math.abs(e.clientY - startY) < 5) {
|
||||
ensureBrowser()?.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const savedX = localStorage.getItem('anomalous_btn_x');
|
||||
const savedY = localStorage.getItem('anomalous_btn_y');
|
||||
|
||||
const updateBtnBounds = () => {
|
||||
let numX = parseInt(btn.style.left || savedX);
|
||||
let numY = parseInt(btn.style.top || savedY);
|
||||
if (isNaN(numX)) numX = window.innerWidth - 90;
|
||||
if (isNaN(numY)) numY = window.innerHeight - 90;
|
||||
|
||||
if (numX < 0) numX = 0;
|
||||
if (numY < 0) numY = 0;
|
||||
if (numX > window.innerWidth - 60) numX = window.innerWidth - 60;
|
||||
if (numY > window.innerHeight - 60) numY = window.innerHeight - 60;
|
||||
btn.style.left = numX + 'px';
|
||||
btn.style.top = numY + 'px';
|
||||
};
|
||||
|
||||
if (savedX && savedY && savedX !== 'NaN' && savedY !== 'NaN') {
|
||||
btn.style.right = 'auto';
|
||||
btn.style.bottom = 'auto';
|
||||
btn.style.left = savedX;
|
||||
btn.style.top = savedY;
|
||||
}
|
||||
|
||||
// Always trigger an update slightly after load to ensure it's in bounds
|
||||
setTimeout(updateBtnBounds, 200);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (btn.style.left) updateBtnBounds();
|
||||
});
|
||||
|
||||
document.body.appendChild(btn);
|
||||
// Keep the entry point visible even when a panel regression prevents
|
||||
// the full browser UI from being constructed.
|
||||
ensureBrowser();
|
||||
|
||||
// Pre-create a lightweight, translucent, and aesthetic drag ghost image for huge Hires Fix images
|
||||
window.anomalousDragGhostImg = new Image();
|
||||
window.anomalousDragGhostImg.src = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='76' height='76' x='2' y='2' fill='%231a1a1a' fill-opacity='0.6' rx='16' stroke='%2300ffcc' stroke-width='2'/><text x='40' y='50' font-family='sans-serif' font-size='32' font-weight='bold' fill='%2300ffcc' text-anchor='middle'>W</text></svg>";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- INJECTED WORKFLOW SHARE MODULE ---
|
||||
// Workflow Share and Preview Modal for Anomalous_Model_Browser
|
||||
|
||||
const AMB_WorkflowShare = {
|
||||
const AMB_WorkflowShare = {
|
||||
// ----------------------------------------------------------------------
|
||||
// 1. Data Compression & Base64 Utils
|
||||
// ----------------------------------------------------------------------
|
||||
@@ -758,7 +201,7 @@ const AMB_WorkflowShare = {
|
||||
// ----------------------------------------------------------------------
|
||||
// 4. Encode / Decode
|
||||
// ----------------------------------------------------------------------
|
||||
async encodeShareCode(workflowJson, isSkeleton) {
|
||||
async encodeShareCode(workflowJson, isSkeleton) {
|
||||
let targetJson = workflowJson;
|
||||
if (isSkeleton) {
|
||||
targetJson = this.skeletonize(workflowJson);
|
||||
@@ -812,7 +255,7 @@ const AMB_WorkflowShare = {
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
showExportModal() {
|
||||
showExportModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'amb-export-modal';
|
||||
overlay.style.cssText = `
|
||||
@@ -1001,8 +444,8 @@ const AMB_WorkflowShare = {
|
||||
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.textContent = t('mainExportWorkflow');
|
||||
exportBtn.style.cssText = `padding: 12px; background: #4a90e2; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px;`;
|
||||
exportBtn.onclick = () => { overlay.remove(); this.showExportModal(); };
|
||||
exportBtn.style.cssText = `padding: 12px; background: #4a90e2; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px;`;
|
||||
exportBtn.onclick = () => { overlay.remove(); this.showExportModal(); };
|
||||
|
||||
const importBtn = document.createElement('button');
|
||||
importBtn.textContent = t('mainImportWorkflow');
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { showDetail } from './ui_detail.js';
|
||||
import { showEditModal } from './ui_model_editor.js';
|
||||
import { _openAdvancedModelSelector, setWidgetValuePath } from './ui_model_selector.js';
|
||||
import { loadModels, applyModelToCanvas, stopMediaInContainer } from './ui_grid.js';
|
||||
import { createDOM, renderSidebar, loadFolders } from './ui_sidebar.js';
|
||||
import { closeWorkspace, hideAllPanels } from './ui_browser_navigation.js';
|
||||
import { openScanWizard, triggerDirectModelScan } from './ui_scan_wizard.js';
|
||||
import { openFolderManager } from './ui_folder_manager.js';
|
||||
import { showHelp } from './ui_help.js';
|
||||
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode, showGalleryViewer } from './ui_gallery.js';
|
||||
import { showNotebooks, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook } from './ui_notebooks.js';
|
||||
import { renderNotebookEditor, fillNotebookGalleries } from './ui_notebook_editor.js';
|
||||
import { sendNotebookToCanvas } from './notebook_canvas.js';
|
||||
import { showRecipes, refreshRecipes } from './ui_recipe_catalog.js';
|
||||
import { renderRecipeList, handleSaveRecipe } from './ui_recipes.js';
|
||||
import { showMaterials, refreshMaterials, openSavedMaterial, openMaterialLibrary } from './ui_materials.js';
|
||||
import { openPromptStudio } from './ui_prompt_composer.js';
|
||||
import { openPromptTranslator } from './ui_prompt_translator.js';
|
||||
import { closeUpdateGuide } from './ui_update_guide.js';
|
||||
import { showImageWorkbench } from './ui_gallery_detail.js';
|
||||
import { initDoctorPanel, diagnoseNode, renderGlobalDashboard, openLoraInsertionPicker, runGlobalDoctorScan } from './ui_doctor.js';
|
||||
import { initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory } from './ui_node_assistant.js';
|
||||
import { _openGalleryReplacer } from './ui_node_model_picker.js';
|
||||
|
||||
export class AnomalousBrowser {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
this.sidebar = null;
|
||||
this.grid = null;
|
||||
this.detailPanel = null;
|
||||
this.currentType = 'loras';
|
||||
this.currentPathIdx = 0;
|
||||
this.currentSubfolder = '/';
|
||||
this.foldersData = null;
|
||||
this.expandedFolders = new Set(['/', 'checkpoints', 'loras', 'unet', 'diffusion_models']);
|
||||
this.energySaving = localStorage.getItem('anomalous_energy_saving') === 'true';
|
||||
this.cardThumbnailMode = localStorage.getItem('anomalous_card_thumbnail_mode') === 'original'
|
||||
? 'original'
|
||||
: 'balanced';
|
||||
this.entryMode = 'floating';
|
||||
this.createDOM();
|
||||
}
|
||||
|
||||
show() {
|
||||
if (this._idleReleaseTimer) {
|
||||
clearTimeout(this._idleReleaseTimer);
|
||||
this._idleReleaseTimer = null;
|
||||
}
|
||||
this.setTriggerVisible(false);
|
||||
this.modal.classList.add('visible');
|
||||
if (!this.foldersData) {
|
||||
this.loadFolders();
|
||||
} else {
|
||||
this.loadModels();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
closeUpdateGuide(this);
|
||||
this.modal.classList.remove('visible');
|
||||
this.setTriggerVisible(true);
|
||||
const canvas = document.getElementById('graph-canvas');
|
||||
if (canvas instanceof HTMLElement) canvas.focus({ preventScroll: true });
|
||||
if (this._modelLoadController) this._modelLoadController.abort();
|
||||
if (this._modelMediaObserver) this._modelMediaObserver.disconnect();
|
||||
this.modal.querySelectorAll('video, audio').forEach(media => media.pause());
|
||||
this.stopMediaInContainer(this.grid);
|
||||
if (this._idleReleaseTimer) clearTimeout(this._idleReleaseTimer);
|
||||
this._idleReleaseTimer = setTimeout(() => {
|
||||
if (this.modal.classList.contains('visible')) return;
|
||||
this.stopMediaInContainer(this.grid);
|
||||
this.grid.replaceChildren();
|
||||
this.models = [];
|
||||
}, 90000);
|
||||
}
|
||||
|
||||
setTriggerVisible(visible) {
|
||||
const trigger = this.triggerButton || document.getElementById('anomalous-trigger-btn');
|
||||
trigger?.classList.toggle('anomalous-trigger-hidden', !visible || this.entryMode !== 'floating');
|
||||
}
|
||||
}
|
||||
|
||||
AnomalousBrowser.prototype.initDoctorPanel = initDoctorPanel;
|
||||
AnomalousBrowser.prototype.diagnoseNode = diagnoseNode;
|
||||
AnomalousBrowser.prototype.renderGlobalDashboard = renderGlobalDashboard;
|
||||
AnomalousBrowser.prototype.initAssistantPanel = initAssistantPanel;
|
||||
AnomalousBrowser.prototype.renderAssistantModelCard = renderAssistantModelCard;
|
||||
AnomalousBrowser.prototype._loadAssistantHistory = _loadAssistantHistory;
|
||||
AnomalousBrowser.prototype._openGalleryReplacer = _openGalleryReplacer;
|
||||
AnomalousBrowser.prototype.openLoraInsertionPicker = openLoraInsertionPicker;
|
||||
AnomalousBrowser.prototype.runGlobalDoctorScan = runGlobalDoctorScan;
|
||||
|
||||
AnomalousBrowser.prototype.showNotebooks = showNotebooks;
|
||||
AnomalousBrowser.prototype.closeWorkspace = closeWorkspace;
|
||||
AnomalousBrowser.prototype.refreshNotebooks = refreshNotebooks;
|
||||
AnomalousBrowser.prototype.saveCurrentNotebook = saveCurrentNotebook;
|
||||
AnomalousBrowser.prototype.deleteCurrentNotebook = deleteCurrentNotebook;
|
||||
AnomalousBrowser.prototype.renderNotebookEditor = renderNotebookEditor;
|
||||
AnomalousBrowser.prototype.fillNotebookGalleries = fillNotebookGalleries;
|
||||
AnomalousBrowser.prototype.sendNotebookToCanvas = sendNotebookToCanvas;
|
||||
|
||||
AnomalousBrowser.prototype.showRecipes = showRecipes;
|
||||
AnomalousBrowser.prototype.refreshRecipes = refreshRecipes;
|
||||
AnomalousBrowser.prototype.renderRecipeList = renderRecipeList;
|
||||
AnomalousBrowser.prototype.handleSaveRecipe = handleSaveRecipe;
|
||||
AnomalousBrowser.prototype.showMaterials = showMaterials;
|
||||
AnomalousBrowser.prototype.openSavedMaterial = openSavedMaterial;
|
||||
AnomalousBrowser.prototype.openMaterialLibrary = openMaterialLibrary;
|
||||
AnomalousBrowser.prototype.openPromptStudio = openPromptStudio;
|
||||
AnomalousBrowser.prototype.openPromptTranslator = function() { openPromptTranslator(this); };
|
||||
AnomalousBrowser.prototype.refreshMaterials = refreshMaterials;
|
||||
|
||||
AnomalousBrowser.prototype.loadGalleryImages = loadGalleryImages;
|
||||
AnomalousBrowser.prototype.refreshGalleryImages = refreshGalleryImages;
|
||||
AnomalousBrowser.prototype.showGeneratedGallery = showGeneratedGallery;
|
||||
AnomalousBrowser.prototype.showGallerySelectMode = showGallerySelectMode;
|
||||
AnomalousBrowser.prototype.showGalleryViewer = showGalleryViewer;
|
||||
AnomalousBrowser.prototype.showImageWorkbench = showImageWorkbench;
|
||||
|
||||
AnomalousBrowser.prototype.createDOM = createDOM;
|
||||
AnomalousBrowser.prototype.openScanWizard = openScanWizard;
|
||||
AnomalousBrowser.prototype.scanSingleModel = triggerDirectModelScan;
|
||||
AnomalousBrowser.prototype.openFolderManager = openFolderManager;
|
||||
AnomalousBrowser.prototype.renderSidebar = renderSidebar;
|
||||
AnomalousBrowser.prototype.loadFolders = loadFolders;
|
||||
AnomalousBrowser.prototype.showHelp = showHelp;
|
||||
AnomalousBrowser.prototype.hideAllPanels = hideAllPanels;
|
||||
|
||||
AnomalousBrowser.prototype.loadModels = loadModels;
|
||||
AnomalousBrowser.prototype.applyModelToCanvas = applyModelToCanvas;
|
||||
AnomalousBrowser.prototype.stopMediaInContainer = stopMediaInContainer;
|
||||
|
||||
AnomalousBrowser.prototype.showDetail = showDetail;
|
||||
AnomalousBrowser.prototype.showEditModal = showEditModal;
|
||||
AnomalousBrowser.prototype._openAdvancedModelSelector = _openAdvancedModelSelector;
|
||||
AnomalousBrowser.prototype.setWidgetValuePath = setWidgetValuePath;
|
||||
@@ -0,0 +1,482 @@
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('anomalous_btn_x');
|
||||
localStorage.removeItem('anomalous_btn_y');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { AnomalousBrowser } from './browser.js';
|
||||
import {
|
||||
clampFloatingTriggerPosition,
|
||||
clearSavedTriggerPosition,
|
||||
DEFAULT_SAFE_LEFT,
|
||||
DEFAULT_SAFE_TOP,
|
||||
isValidSavedTriggerPosition,
|
||||
loadSavedTriggerPosition,
|
||||
normalizeEntryMode,
|
||||
normalizeFloatingTriggerSize,
|
||||
normalizeFloatingTriggerStyle,
|
||||
saveTriggerPosition
|
||||
} from './entry_controls.js?v=20260921-flicker-free-1';
|
||||
import {
|
||||
createShortcutSettingControl,
|
||||
DEFAULT_BROWSER_SHORTCUT,
|
||||
DEFAULT_MATERIALS_SHORTCUT,
|
||||
installDeferredShortcutFallback,
|
||||
} from './shortcut_controls.js';
|
||||
|
||||
export const ENTRY_MODE_SETTING_ID = 'Anomalous.ModelBrowser.EntryMode';
|
||||
export const SHORTCUT_SETTING_ID = 'Anomalous.ModelBrowser.Shortcut';
|
||||
export const MATERIALS_SHORTCUT_SETTING_ID = 'Anomalous.ModelBrowser.MaterialsShortcut';
|
||||
export const FLOATING_TRIGGER_SIZE_SETTING_ID = 'Anomalous.ModelBrowser.FloatingTriggerSize';
|
||||
export const FLOATING_TRIGGER_STYLE_SETTING_ID = 'Anomalous.ModelBrowser.FloatingTriggerStyle';
|
||||
export const OPEN_BROWSER_COMMAND_ID = 'Anomalous.ModelBrowser.Open';
|
||||
export const OPEN_MATERIALS_COMMAND_ID = 'Anomalous.ModelBrowser.OpenMaterials';
|
||||
export const RESET_TRIGGER_POSITION_COMMAND_ID = 'Anomalous.ModelBrowser.ResetFloatingTriggerPosition';
|
||||
|
||||
export function createBrowserEntry({ translate, getCurrentLanguage }) {
|
||||
let entryMode = 'floating';
|
||||
let floatingTriggerSize = 'medium';
|
||||
let floatingTriggerStyle = 'icon';
|
||||
let browserInstance = null;
|
||||
let triggerButton = null;
|
||||
let triggerBoundsUpdater = null;
|
||||
let disposeMaterialsShortcutFallback = null;
|
||||
const t = translate;
|
||||
|
||||
function getSettingTranslationPatches() {
|
||||
const category = t('mainInterfaceCategory');
|
||||
return {
|
||||
[FLOATING_TRIGGER_STYLE_SETTING_ID]: {
|
||||
name: t('mainFloatingTriggerStyleSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'floating-trigger-style'],
|
||||
tooltip: t('mainFloatingTriggerStyleTooltip'),
|
||||
options: [
|
||||
{ value: 'icon', text: t('mainFloatingTriggerStyleIcon') },
|
||||
{ value: 'pill', text: t('mainFloatingTriggerStylePill') }
|
||||
]
|
||||
},
|
||||
[FLOATING_TRIGGER_SIZE_SETTING_ID]: {
|
||||
name: t('mainFloatingTriggerSizeSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'floating-trigger-size'],
|
||||
tooltip: t('mainFloatingTriggerSizeTooltip'),
|
||||
options: [
|
||||
{ value: 'small', text: t('mainFloatingTriggerSizeSmall') },
|
||||
{ value: 'medium', text: t('mainFloatingTriggerSizeMedium') },
|
||||
{ value: 'large', text: t('mainFloatingTriggerSizeLarge') }
|
||||
]
|
||||
},
|
||||
[SHORTCUT_SETTING_ID]: {
|
||||
name: t('mainShortcutSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'shortcut'],
|
||||
tooltip: t('mainShortcutTooltip')
|
||||
},
|
||||
[MATERIALS_SHORTCUT_SETTING_ID]: {
|
||||
name: t('mainMaterialsShortcutSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'materials-shortcut'],
|
||||
tooltip: t('mainMaterialsShortcutTooltip')
|
||||
},
|
||||
[ENTRY_MODE_SETTING_ID]: {
|
||||
name: t('mainEntryModeSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'entry-mode'],
|
||||
tooltip: t('mainEntryModeTooltip'),
|
||||
options: [
|
||||
{ value: 'floating', text: t('mainEntryModeFloating') },
|
||||
{ value: 'topbar', text: t('mainEntryModeTopbar') },
|
||||
{ value: 'menu', text: t('mainEntryModeMenu') }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRegisteredSettings() {
|
||||
const settingsApi = app.extensionManager?.setting;
|
||||
const registry = settingsApi?.settings?.value || settingsApi?.settings;
|
||||
if (!registry || typeof registry !== 'object') return;
|
||||
for (const [id, patch] of Object.entries(getSettingTranslationPatches())) {
|
||||
if (registry[id]) registry[id] = { ...registry[id], ...patch };
|
||||
}
|
||||
}
|
||||
|
||||
function syncVisibility() {
|
||||
document.documentElement.classList.toggle('anomalous-topbar-entry-enabled', entryMode === 'topbar');
|
||||
document.documentElement.classList.toggle('anomalous-floating-entry-enabled', entryMode === 'floating');
|
||||
const browserIsOpen = browserInstance?.modal?.classList.contains('visible') === true;
|
||||
triggerButton?.classList.toggle('anomalous-trigger-hidden', entryMode !== 'floating' || browserIsOpen);
|
||||
}
|
||||
|
||||
function applyPresentation() {
|
||||
if (!triggerButton) return;
|
||||
triggerButton.dataset.size = floatingTriggerSize;
|
||||
triggerButton.dataset.style = floatingTriggerStyle;
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'anomalous-trigger-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;display:block;"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'anomalous-trigger-label';
|
||||
label.textContent = t('mainFloatingTriggerLabel');
|
||||
triggerButton.replaceChildren(icon, label);
|
||||
triggerButton.title = t('mainOpenTitle');
|
||||
triggerButton.setAttribute('aria-label', t('mainOpenTitle'));
|
||||
if (loadSavedTriggerPosition()) {
|
||||
requestAnimationFrame(() => triggerBoundsUpdater?.());
|
||||
}
|
||||
}
|
||||
|
||||
function resetPosition() {
|
||||
clearSavedTriggerPosition();
|
||||
if (!triggerButton) return;
|
||||
triggerButton.style.left = '';
|
||||
triggerButton.style.top = '';
|
||||
triggerButton.style.right = '';
|
||||
triggerButton.style.bottom = '';
|
||||
}
|
||||
|
||||
function ensureBrowser() {
|
||||
if (browserInstance) return browserInstance;
|
||||
try {
|
||||
browserInstance = new AnomalousBrowser();
|
||||
browserInstance.entryMode = entryMode;
|
||||
browserInstance.triggerButton = triggerButton;
|
||||
window.anomalousBrowserInstance = browserInstance;
|
||||
triggerButton?.classList.remove('anomalous-trigger-error');
|
||||
if (triggerButton) {
|
||||
triggerButton.title = t('mainOpenTitle');
|
||||
triggerButton.setAttribute('aria-label', t('mainOpenTitle'));
|
||||
}
|
||||
syncVisibility();
|
||||
return browserInstance;
|
||||
} catch (error) {
|
||||
console.error('[Anomalous Model Browser] UI initialization failed:', error);
|
||||
triggerButton?.classList.add('anomalous-trigger-error');
|
||||
if (triggerButton) {
|
||||
triggerButton.title = getCurrentLanguage() === 'zh' ? t('mainRetryInit') : t('mainRetryInitEn');
|
||||
triggerButton.setAttribute('aria-label', triggerButton.title);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
ensureBrowser()?.show();
|
||||
}
|
||||
|
||||
async function openMaterials() {
|
||||
const browser = ensureBrowser();
|
||||
if (!browser) return;
|
||||
await browser.openMaterialLibrary();
|
||||
}
|
||||
|
||||
function getMaterialsShortcutCombo() {
|
||||
const commands = app.extensionManager?.command?.commands;
|
||||
const command = Array.isArray(commands)
|
||||
? commands.find(item => item.id === OPEN_MATERIALS_COMMAND_ID)
|
||||
: null;
|
||||
return command ? command.keybinding?.combo || null : DEFAULT_MATERIALS_SHORTCUT;
|
||||
}
|
||||
|
||||
function materialLibraryIsOpen() {
|
||||
return browserInstance?.modal?.classList.contains('visible') === true
|
||||
&& browserInstance?.nbPanel?.style.display === 'flex'
|
||||
&& browserInstance?.materialContainer?.style.display === 'flex';
|
||||
}
|
||||
|
||||
function installMaterialsShortcutFallback() {
|
||||
disposeMaterialsShortcutFallback?.();
|
||||
disposeMaterialsShortcutFallback = installDeferredShortcutFallback({
|
||||
target: window,
|
||||
getCombo: getMaterialsShortcutCombo,
|
||||
isHandled: materialLibraryIsOpen,
|
||||
onFallback: openMaterials,
|
||||
onError: error => console.error('[Anomalous Model Browser] Material shortcut failed:', error),
|
||||
});
|
||||
}
|
||||
|
||||
const translationPatches = getSettingTranslationPatches();
|
||||
const settings = [
|
||||
{
|
||||
id: FLOATING_TRIGGER_STYLE_SETTING_ID,
|
||||
...translationPatches[FLOATING_TRIGGER_STYLE_SETTING_ID],
|
||||
type: 'combo',
|
||||
defaultValue: 'icon',
|
||||
onChange(value) {
|
||||
floatingTriggerStyle = normalizeFloatingTriggerStyle(value);
|
||||
applyPresentation();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: FLOATING_TRIGGER_SIZE_SETTING_ID,
|
||||
...translationPatches[FLOATING_TRIGGER_SIZE_SETTING_ID],
|
||||
type: 'combo',
|
||||
defaultValue: 'medium',
|
||||
onChange(value) {
|
||||
floatingTriggerSize = normalizeFloatingTriggerSize(value);
|
||||
applyPresentation();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: SHORTCUT_SETTING_ID,
|
||||
...translationPatches[SHORTCUT_SETTING_ID],
|
||||
type: () => createShortcutSettingControl({ app, commandId: OPEN_BROWSER_COMMAND_ID, translate: t }),
|
||||
defaultValue: '',
|
||||
telemetry: { trackChanges: false }
|
||||
},
|
||||
{
|
||||
id: MATERIALS_SHORTCUT_SETTING_ID,
|
||||
...translationPatches[MATERIALS_SHORTCUT_SETTING_ID],
|
||||
type: () => createShortcutSettingControl({
|
||||
app,
|
||||
commandId: OPEN_MATERIALS_COMMAND_ID,
|
||||
translate: t,
|
||||
settingLabelKey: 'mainMaterialsShortcutSetting',
|
||||
}),
|
||||
defaultValue: '',
|
||||
telemetry: { trackChanges: false }
|
||||
},
|
||||
{
|
||||
id: ENTRY_MODE_SETTING_ID,
|
||||
...translationPatches[ENTRY_MODE_SETTING_ID],
|
||||
type: 'combo',
|
||||
defaultValue: 'floating',
|
||||
onChange(value) {
|
||||
entryMode = normalizeEntryMode(value);
|
||||
if (browserInstance) browserInstance.entryMode = entryMode;
|
||||
syncVisibility();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
window.addEventListener('anomalous-language-change', () => {
|
||||
applyPresentation();
|
||||
refreshRegisteredSettings();
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20260927-hotfix-1';
|
||||
if (!document.querySelector('link[href^="/extensions/Anomalous_Model_Browser/styles.css"]')) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.type = 'text/css';
|
||||
link.href = cssUrl;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
try {
|
||||
const registeredSettings = app.extensionManager?.setting;
|
||||
entryMode = normalizeEntryMode(registeredSettings?.get(ENTRY_MODE_SETTING_ID));
|
||||
floatingTriggerSize = normalizeFloatingTriggerSize(registeredSettings?.get(FLOATING_TRIGGER_SIZE_SETTING_ID));
|
||||
floatingTriggerStyle = normalizeFloatingTriggerStyle(registeredSettings?.get(FLOATING_TRIGGER_STYLE_SETTING_ID));
|
||||
} catch (error) {
|
||||
console.warn('[Anomalous Model Browser] Unable to read entry preferences:', error);
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'anomalous-trigger-btn';
|
||||
btn.setAttribute('aria-label', 'Anomalous Model Browser');
|
||||
// Prevent FOUC: Start hidden until presentation & exact coordinates are bound
|
||||
btn.classList.add('anomalous-trigger-initializing');
|
||||
triggerButton = btn;
|
||||
if (browserInstance) browserInstance.triggerButton = btn;
|
||||
btn.title = t('mainOpenTitle');
|
||||
|
||||
// Pre-configure content & presentation BEFORE mounting
|
||||
applyPresentation();
|
||||
|
||||
// Pre-calculate and assign exact target coordinates BEFORE appending to DOM
|
||||
const savedInitialPos = loadSavedTriggerPosition();
|
||||
if (savedInitialPos) {
|
||||
const estSize = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60);
|
||||
const initialClamped = clampFloatingTriggerPosition({
|
||||
x: savedInitialPos.x,
|
||||
y: savedInitialPos.y,
|
||||
width: estSize,
|
||||
height: estSize,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
minX: 70
|
||||
});
|
||||
btn.style.position = 'fixed';
|
||||
btn.style.left = initialClamped.x + 'px';
|
||||
btn.style.top = initialClamped.y + 'px';
|
||||
btn.style.right = 'auto';
|
||||
btn.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
let isDragging = false;
|
||||
let hasMoved = false;
|
||||
let startPointerX = 0;
|
||||
let startPointerY = 0;
|
||||
let startBtnLeft = 0;
|
||||
let startBtnTop = 0;
|
||||
let currentDragX = null;
|
||||
let currentDragY = null;
|
||||
let activePointerId = null;
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
if (!isDragging) return;
|
||||
const dx = event.clientX - startPointerX;
|
||||
const dy = event.clientY - startPointerY;
|
||||
if (!hasMoved && Math.hypot(dx, dy) >= 4) {
|
||||
hasMoved = true;
|
||||
}
|
||||
if (!hasMoved) return;
|
||||
event.preventDefault();
|
||||
const btnW = btn.offsetWidth || 60;
|
||||
const btnH = btn.offsetHeight || 60;
|
||||
const clamped = clampFloatingTriggerPosition({
|
||||
x: startBtnLeft + dx,
|
||||
y: startBtnTop + dy,
|
||||
width: btnW,
|
||||
height: btnH,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
minX: 70
|
||||
});
|
||||
currentDragX = clamped.x;
|
||||
currentDragY = clamped.y;
|
||||
btn.style.left = clamped.x + 'px';
|
||||
btn.style.top = clamped.y + 'px';
|
||||
btn.style.right = 'auto';
|
||||
btn.style.bottom = 'auto';
|
||||
};
|
||||
|
||||
const finishDrag = (event) => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
|
||||
btn.removeEventListener('pointermove', onPointerMove);
|
||||
btn.removeEventListener('pointerup', finishDrag);
|
||||
btn.removeEventListener('pointercancel', finishDrag);
|
||||
btn.removeEventListener('lostpointercapture', finishDrag);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', finishDrag);
|
||||
window.removeEventListener('pointercancel', finishDrag);
|
||||
|
||||
if (activePointerId != null) {
|
||||
try {
|
||||
if (btn.hasPointerCapture(activePointerId)) {
|
||||
btn.releasePointerCapture(activePointerId);
|
||||
}
|
||||
} catch (_) {}
|
||||
activePointerId = null;
|
||||
}
|
||||
|
||||
btn.style.transition = '';
|
||||
|
||||
if (hasMoved && currentDragX != null && currentDragY != null) {
|
||||
saveTriggerPosition({ x: currentDragX, y: currentDragY });
|
||||
} else if (!hasMoved) {
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
btn.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
isDragging = true;
|
||||
hasMoved = false;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
activePointerId = event.pointerId;
|
||||
|
||||
const rect = btn.getBoundingClientRect();
|
||||
startBtnLeft = rect.left;
|
||||
startBtnTop = rect.top;
|
||||
currentDragX = rect.left;
|
||||
currentDragY = rect.top;
|
||||
|
||||
btn.style.transition = 'none';
|
||||
|
||||
try {
|
||||
btn.setPointerCapture(event.pointerId);
|
||||
} catch (_) {}
|
||||
|
||||
btn.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
btn.addEventListener('pointerup', finishDrag);
|
||||
btn.addEventListener('pointercancel', finishDrag);
|
||||
btn.addEventListener('lostpointercapture', finishDrag);
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
window.addEventListener('pointerup', finishDrag);
|
||||
window.addEventListener('pointercancel', finishDrag);
|
||||
});
|
||||
|
||||
const updateBtnBounds = () => {
|
||||
const saved = loadSavedTriggerPosition();
|
||||
if (saved) {
|
||||
const estFallback = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60);
|
||||
const btnW = btn.offsetWidth || estFallback;
|
||||
const btnH = btn.offsetHeight || btnW;
|
||||
const clamped = clampFloatingTriggerPosition({
|
||||
x: saved.x,
|
||||
y: saved.y,
|
||||
width: btnW,
|
||||
height: btnH,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
minX: 70
|
||||
});
|
||||
btn.style.left = clamped.x + 'px';
|
||||
btn.style.top = clamped.y + 'px';
|
||||
btn.style.right = 'auto';
|
||||
btn.style.bottom = 'auto';
|
||||
} else {
|
||||
btn.style.left = '';
|
||||
btn.style.top = '';
|
||||
btn.style.right = '';
|
||||
btn.style.bottom = '';
|
||||
}
|
||||
};
|
||||
triggerBoundsUpdater = updateBtnBounds;
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
updateBtnBounds();
|
||||
syncVisibility();
|
||||
});
|
||||
|
||||
// Mount pre-positioned and styled element to DOM
|
||||
document.body.appendChild(btn);
|
||||
syncVisibility();
|
||||
ensureBrowser();
|
||||
installMaterialsShortcutFallback();
|
||||
|
||||
// Reveal smoothly on first animation frame without any positional jump or flicker
|
||||
requestAnimationFrame(() => {
|
||||
updateBtnBounds();
|
||||
btn.classList.remove('anomalous-trigger-initializing');
|
||||
});
|
||||
|
||||
window.anomalousDragGhostImg = new Image();
|
||||
window.anomalousDragGhostImg.src = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='76' height='76' x='2' y='2' fill='%23140812' fill-opacity='0.85' rx='16' stroke='%23f59e0b' stroke-width='2'/><text x='40' y='50' font-family='sans-serif' font-size='32' font-weight='bold' fill='%23f59e0b' text-anchor='middle'>W</text></svg>";
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
actionBarButtons: [{
|
||||
icon: 'pi pi-box',
|
||||
label: t('mainTopbarTriggerLabel'),
|
||||
tooltip: t('mainOpenTitle'),
|
||||
class: 'anomalous-topbar-entry',
|
||||
onClick: open
|
||||
}],
|
||||
commands: [
|
||||
{ id: OPEN_BROWSER_COMMAND_ID, label: t('mainOpenTitle'), function: open },
|
||||
{ id: OPEN_MATERIALS_COMMAND_ID, label: t('mainOpenMaterialsTitle'), function: openMaterials },
|
||||
{ id: RESET_TRIGGER_POSITION_COMMAND_ID, label: t('mainResetFloatingTriggerPosition'), function: resetPosition }
|
||||
],
|
||||
keybindings: [
|
||||
{ combo: DEFAULT_BROWSER_SHORTCUT, commandId: OPEN_BROWSER_COMMAND_ID },
|
||||
{ combo: DEFAULT_MATERIALS_SHORTCUT, commandId: OPEN_MATERIALS_COMMAND_ID },
|
||||
],
|
||||
menuCommands: [{
|
||||
path: ['Extensions', 'Anomalous Model Browser'],
|
||||
commands: [OPEN_BROWSER_COMMAND_ID, OPEN_MATERIALS_COMMAND_ID, RESET_TRIGGER_POSITION_COMMAND_ID]
|
||||
}],
|
||||
setup,
|
||||
open,
|
||||
openMaterials,
|
||||
ensureBrowser,
|
||||
resetPosition,
|
||||
syncVisibility
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Unconditionally purge legacy contaminated storage keys on module load
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('anomalous_btn_x');
|
||||
localStorage.removeItem('anomalous_btn_y');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
export const FLOATING_TRIGGER_SIZES = new Set(['small', 'medium', 'large']);
|
||||
export const FLOATING_TRIGGER_STYLES = new Set(['icon', 'pill']);
|
||||
export const ENTRY_MODES = new Set(['floating', 'topbar', 'menu']);
|
||||
|
||||
export function normalizeFloatingTriggerSize(value) {
|
||||
return FLOATING_TRIGGER_SIZES.has(value) ? value : 'medium';
|
||||
}
|
||||
|
||||
export function normalizeFloatingTriggerStyle(value) {
|
||||
return FLOATING_TRIGGER_STYLES.has(value) ? value : 'icon';
|
||||
}
|
||||
|
||||
export function normalizeEntryMode(value) {
|
||||
return ENTRY_MODES.has(value) ? value : 'floating';
|
||||
}
|
||||
|
||||
export const DEFAULT_SAFE_TOP = 80;
|
||||
export const DEFAULT_SAFE_LEFT = 80; // Safe position outside ComfyUI left sidebar dock
|
||||
export const DEFAULT_SAFE_MARGIN_RIGHT = 24;
|
||||
export const DEFAULT_MIN_SAFE_X = 70; // Avoid ComfyUI left sidebar dock
|
||||
export const DEFAULT_TRIGGER_POSITION = Object.freeze({ top: DEFAULT_SAFE_TOP, left: DEFAULT_SAFE_LEFT });
|
||||
export const TRIGGER_POSITION_STORAGE_KEY = 'anomalous_trigger_pos_v3';
|
||||
export const LEGACY_TRIGGER_X_KEY = 'anomalous_btn_x';
|
||||
export const LEGACY_TRIGGER_Y_KEY = 'anomalous_btn_y';
|
||||
|
||||
export function isValidSavedTriggerPosition(x, y) {
|
||||
if (x == null || y == null) return false;
|
||||
const px = Number.parseFloat(x);
|
||||
const py = Number.parseFloat(y);
|
||||
// Must be a finite number, py >= 0, and px >= DEFAULT_MIN_SAFE_X (70px) to prevent docking inside sidebar
|
||||
return Number.isFinite(px) && Number.isFinite(py) && px >= DEFAULT_MIN_SAFE_X && py >= 0;
|
||||
}
|
||||
|
||||
export function loadSavedTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
if (!storage) return null;
|
||||
try {
|
||||
const raw = storage.getItem(TRIGGER_POSITION_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (isValidSavedTriggerPosition(parsed?.x, parsed?.y)) {
|
||||
return { x: Math.round(Number(parsed.x)), y: Math.round(Number(parsed.y)) };
|
||||
} else {
|
||||
storage.removeItem(TRIGGER_POSITION_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function saveTriggerPosition(pos, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
if (!storage || !pos) return;
|
||||
const x = Number.parseFloat(pos.x);
|
||||
const y = Number.parseFloat(pos.y);
|
||||
if (!isValidSavedTriggerPosition(x, y)) return;
|
||||
try {
|
||||
storage.setItem(TRIGGER_POSITION_STORAGE_KEY, JSON.stringify({
|
||||
x: Math.round(x),
|
||||
y: Math.round(y)
|
||||
}));
|
||||
storage.removeItem(LEGACY_TRIGGER_X_KEY);
|
||||
storage.removeItem(LEGACY_TRIGGER_Y_KEY);
|
||||
storage.removeItem('anomalous_trigger_pos_v2');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function clearSavedTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.removeItem(TRIGGER_POSITION_STORAGE_KEY);
|
||||
storage.removeItem(LEGACY_TRIGGER_X_KEY);
|
||||
storage.removeItem(LEGACY_TRIGGER_Y_KEY);
|
||||
storage.removeItem('anomalous_trigger_pos_v2');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function sanitizeSavedTriggerPosition(x, y) {
|
||||
if (!isValidSavedTriggerPosition(x, y)) return null;
|
||||
return { x: Number.parseFloat(x), y: Number.parseFloat(y) };
|
||||
}
|
||||
|
||||
export function normalizeSavedTriggerPosition(x, y) {
|
||||
return sanitizeSavedTriggerPosition(x, y);
|
||||
}
|
||||
|
||||
export function clampFloatingTriggerPosition({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
minX = DEFAULT_MIN_SAFE_X,
|
||||
margin = 0
|
||||
}) {
|
||||
const safeWidth = Math.max(1, Number(width) || 60);
|
||||
const safeHeight = Math.max(1, Number(height) || 60);
|
||||
const rawVw = Number(viewportWidth) || (typeof window !== 'undefined' ? window.innerWidth : 1024) || 1024;
|
||||
const rawVh = Number(viewportHeight) || (typeof window !== 'undefined' ? window.innerHeight : 768) || 768;
|
||||
const safeVw = rawVw >= 300 ? rawVw : 1024;
|
||||
const safeVh = rawVh >= 200 ? rawVh : 768;
|
||||
const maxX = Math.max(minX, safeVw - safeWidth);
|
||||
const maxY = Math.max(0, safeVh - safeHeight);
|
||||
|
||||
const parsedX = Number.parseFloat(x);
|
||||
const parsedY = Number.parseFloat(y);
|
||||
|
||||
const fallbackX = Math.max(minX, Math.min(maxX, DEFAULT_SAFE_LEFT));
|
||||
const fallbackY = Math.min(maxY, DEFAULT_SAFE_TOP);
|
||||
|
||||
const targetX = Number.isFinite(parsedX) ? parsedX : fallbackX;
|
||||
const targetY = Number.isFinite(parsedY) ? parsedY : fallbackY;
|
||||
|
||||
return {
|
||||
x: Math.min(maxX, Math.max(minX, Math.round(targetX))),
|
||||
y: Math.min(maxY, Math.max(0, Math.round(targetY)))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { normalizeLocale, resolveLocale, translate } from './locales.js';
|
||||
|
||||
export const LANGUAGE_SETTING_ID = 'Anomalous.ModelBrowser.Language';
|
||||
export const ABYSSAL_SCARLET_SETTING_ID = 'Anomalous.ModelBrowser.AbyssalScarletTheme';
|
||||
|
||||
let defaultLang = 'zh';
|
||||
try {
|
||||
let comfyDetected = false;
|
||||
const aglLang = localStorage.getItem('Comfy.Settings.AIGODLIKE-COMFYUI-TRANSLATION.Language');
|
||||
if (aglLang) {
|
||||
defaultLang = aglLang.toLowerCase().includes('en') ? 'en' : 'zh';
|
||||
comfyDetected = true;
|
||||
} else {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key || (!key.toLowerCase().includes('lang') && !key.toLowerCase().includes('locale'))) continue;
|
||||
const value = localStorage.getItem(key);
|
||||
if (typeof value !== 'string') continue;
|
||||
const normalizedValue = value.toLowerCase();
|
||||
if (normalizedValue.includes('zh') || normalizedValue.includes('chinese')) {
|
||||
defaultLang = 'zh';
|
||||
comfyDetected = true;
|
||||
break;
|
||||
}
|
||||
if (normalizedValue.includes('en') || normalizedValue.includes('english')) {
|
||||
defaultLang = 'en';
|
||||
comfyDetected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!comfyDetected && navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
|
||||
defaultLang = 'en';
|
||||
}
|
||||
} catch (_) {
|
||||
if (navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
|
||||
defaultLang = 'en';
|
||||
}
|
||||
}
|
||||
|
||||
let currentLang = resolveLocale(localStorage.getItem('anomalous_lang') || defaultLang);
|
||||
window.anomalous_browser_lang = currentLang;
|
||||
|
||||
export const t = (key, params) => translate(key, params, window.anomalous_browser_lang || currentLang);
|
||||
export const getCurrentLanguage = () => currentLang;
|
||||
|
||||
function normalizeLanguagePreference(value) {
|
||||
return value === 'zh' || value === 'en' ? value : 'auto';
|
||||
}
|
||||
|
||||
function resolveComfyLanguage() {
|
||||
try {
|
||||
const settings = app.extensionManager?.setting;
|
||||
const locale = settings?.get('Comfy.Locale')
|
||||
|| app.ui?.settings?.getSettingValue?.('Comfy.Locale')
|
||||
|| app.ui?.settings?.getSettingValue?.('Comfy.Locale.Language');
|
||||
return normalizeLocale(locale) || defaultLang;
|
||||
} catch (_) {
|
||||
return defaultLang;
|
||||
}
|
||||
}
|
||||
|
||||
function getSettingTranslationPatches() {
|
||||
const category = t('mainInterfaceCategory');
|
||||
return {
|
||||
[LANGUAGE_SETTING_ID]: {
|
||||
name: t('mainLanguageSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'language'],
|
||||
tooltip: t('mainLanguageTooltip'),
|
||||
options: [
|
||||
{ value: 'auto', text: t('mainLanguageAuto') },
|
||||
{ value: 'zh', text: t('mainLanguageChinese') },
|
||||
{ value: 'en', text: t('mainLanguageEnglish') }
|
||||
]
|
||||
},
|
||||
[ABYSSAL_SCARLET_SETTING_ID]: {
|
||||
name: t('mainAbyssalScarletThemeSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'theme'],
|
||||
tooltip: t('mainAbyssalScarletThemeTooltip')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRegisteredSettings() {
|
||||
const settingsApi = app.extensionManager?.setting;
|
||||
const registry = settingsApi?.settings?.value || settingsApi?.settings;
|
||||
if (!registry || typeof registry !== 'object') return;
|
||||
for (const [id, patch] of Object.entries(getSettingTranslationPatches())) {
|
||||
if (registry[id]) registry[id] = { ...registry[id], ...patch };
|
||||
}
|
||||
}
|
||||
|
||||
function applyLanguagePreference(value) {
|
||||
const preference = normalizeLanguagePreference(value);
|
||||
if (preference === 'auto') localStorage.removeItem('anomalous_lang');
|
||||
else localStorage.setItem('anomalous_lang', preference);
|
||||
|
||||
const nextLanguage = preference === 'auto' ? resolveComfyLanguage() : preference;
|
||||
const changed = nextLanguage !== window.anomalous_browser_lang;
|
||||
currentLang = nextLanguage;
|
||||
window.anomalous_browser_lang = nextLanguage;
|
||||
refreshRegisteredSettings();
|
||||
if (changed) {
|
||||
window.dispatchEvent(new CustomEvent('anomalous-language-change', {
|
||||
detail: { language: nextLanguage, preference }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (!localStorage.getItem('anomalous_lang')) {
|
||||
currentLang = resolveComfyLanguage();
|
||||
window.anomalous_browser_lang = currentLang;
|
||||
}
|
||||
|
||||
function showThemeNoticeToast(isEnabled) {
|
||||
document.getElementById('anomalous-theme-toast')?.remove();
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'anomalous-theme-toast';
|
||||
toast.className = 'anomalous-theme-toast' + (isEnabled ? ' is-abyssal' : '');
|
||||
toast.textContent = isEnabled ? t('themeDomainActivated') : t('themeDomainDeactivated');
|
||||
document.body.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add('is-show'));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('is-show');
|
||||
setTimeout(() => toast.remove(), 400);
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
export function setAbyssalScarletTheme(enabled, notify = false) {
|
||||
const isEnabled = Boolean(enabled);
|
||||
localStorage.setItem('anomalous_theme_abyssal_scarlet', isEnabled ? 'true' : 'false');
|
||||
document.documentElement.classList.toggle('theme-abyssal-scarlet', isEnabled);
|
||||
document.getElementById('anomalous-modal')?.classList.toggle('theme-abyssal-scarlet', isEnabled);
|
||||
document.getElementById('anomalous-container')?.classList.toggle('theme-abyssal-scarlet', isEnabled);
|
||||
|
||||
try {
|
||||
const settings = app.extensionManager?.setting;
|
||||
if (settings && typeof settings.set === 'function' && settings.get(ABYSSAL_SCARLET_SETTING_ID) !== isEnabled) {
|
||||
settings.set(ABYSSAL_SCARLET_SETTING_ID, isEnabled);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (notify) showThemeNoticeToast(isEnabled);
|
||||
window.dispatchEvent(new CustomEvent('anomalous-theme-change', {
|
||||
detail: { theme: isEnabled ? 'abyssal-scarlet' : 'default', enabled: isEnabled }
|
||||
}));
|
||||
}
|
||||
|
||||
window.setAbyssalScarletTheme = setAbyssalScarletTheme;
|
||||
setAbyssalScarletTheme(localStorage.getItem('anomalous_theme_abyssal_scarlet') === 'true', false);
|
||||
|
||||
export function createInterfaceSettings() {
|
||||
const translations = getSettingTranslationPatches();
|
||||
return [
|
||||
{
|
||||
id: LANGUAGE_SETTING_ID,
|
||||
...translations[LANGUAGE_SETTING_ID],
|
||||
type: 'combo',
|
||||
defaultValue: () => normalizeLanguagePreference(localStorage.getItem('anomalous_lang')),
|
||||
onChange: applyLanguagePreference
|
||||
},
|
||||
{
|
||||
id: ABYSSAL_SCARLET_SETTING_ID,
|
||||
...translations[ABYSSAL_SCARLET_SETTING_ID],
|
||||
type: 'boolean',
|
||||
defaultValue: () => localStorage.getItem('anomalous_theme_abyssal_scarlet') === 'true',
|
||||
onChange(value) {
|
||||
setAbyssalScarletTheme(Boolean(value), true);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate as t } from './locales.js';
|
||||
import { text } from './ui_dom.js';
|
||||
import { materialNodeHeading } from './material_inspector.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
|
||||
let activeDrag = null;
|
||||
|
||||
export function getCanvasPosition(event, canvas) {
|
||||
if (!canvas) return null;
|
||||
let position = null;
|
||||
if (canvas.convertEventToCanvasOffset) position = canvas.convertEventToCanvasOffset(event);
|
||||
else if (canvas.adjustMouseEvent) { canvas.adjustMouseEvent(event); position = [event.canvasX, event.canvasY]; }
|
||||
return position && position.every(Number.isFinite) ? position : null;
|
||||
}
|
||||
|
||||
export function materialDropNode(event, canvas, graph) {
|
||||
const surface = canvas?.canvas;
|
||||
if (!surface || !graph || canvas.graph !== graph) return null;
|
||||
const rect = surface.getBoundingClientRect();
|
||||
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) return null;
|
||||
// DOM text widgets overlay the canvas, so accept their drop surface too.
|
||||
if (event.target !== surface && !event.target?.closest?.('.dom-widget')) return null;
|
||||
const position = getCanvasPosition(event, canvas);
|
||||
if (!position) return null;
|
||||
const node = graph.getNodeOnPos?.(position[0], position[1]);
|
||||
return node && graph.getNodeById(node.id) === node ? node : null;
|
||||
}
|
||||
|
||||
/** Only this page's active drag can mutate a node; transfer data is never trusted. */
|
||||
export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropOnCanvas }) {
|
||||
element.draggable = true;
|
||||
element.addEventListener('dragstart', event => {
|
||||
if (event.target !== element && event.target?.closest?.('button, input, textarea, select')) { event.preventDefault(); return; }
|
||||
const data = payload();
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!data || !event.dataTransfer || !canvas?.canvas) { event.preventDefault(); return; }
|
||||
activeDrag?.();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.setData('application/x-anomalous-material', 'local-drag');
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
const defaultHint = data.dragHint || t('materialDropHint');
|
||||
const hint = text(document.body, 'div', defaultHint, 'anomalous-material-drag-hint');
|
||||
hint.setAttribute('role', 'status');
|
||||
const cleanup = () => {
|
||||
clearTimeout(reveal);
|
||||
owner.modal?.classList.remove('anomalous-material-dragging');
|
||||
hint.remove();
|
||||
for (const [name, fn] of listeners) window.removeEventListener(name, fn, true);
|
||||
if (activeDrag === cleanup) activeDrag = null;
|
||||
};
|
||||
const isOverCanvasSurface = event => {
|
||||
const surface = canvas?.canvas;
|
||||
if (!surface || app.graph !== graph || app.canvas !== canvas || canvas.graph !== graph) return false;
|
||||
if (event.target !== surface && !event.target?.closest?.('.dom-widget')) return false;
|
||||
const rect = surface.getBoundingClientRect();
|
||||
return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
|
||||
};
|
||||
const target = event => app.graph === graph && app.canvas === canvas ? materialDropNode(event, canvas, graph) : null;
|
||||
const over = event => {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
const node = target(event);
|
||||
const validNode = !!node && accepts?.(node, data);
|
||||
if (validNode) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
hint.classList.add('is-target-valid');
|
||||
hint.textContent = t('materialDropTarget', { name: materialNodeHeading(node) });
|
||||
} else if (dropOnCanvas && isOverCanvasSurface(event)) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
hint.classList.add('is-target-valid');
|
||||
hint.textContent = data.dragTargetHint || defaultHint;
|
||||
} else {
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
hint.classList.remove('is-target-valid');
|
||||
hint.textContent = defaultHint;
|
||||
}
|
||||
hint.style.left = `${Math.max(8, Math.min(event.clientX + 16, window.innerWidth - 250))}px`;
|
||||
hint.style.top = `${Math.max(8, event.clientY - 48)}px`;
|
||||
};
|
||||
const finish = async event => {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
const node = target(event);
|
||||
const validNode = !!node && accepts?.(node, data);
|
||||
const overCanvas = isOverCanvasSurface(event);
|
||||
cleanup();
|
||||
if (validNode) {
|
||||
try { await drop(node, data, graph); }
|
||||
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); }
|
||||
return;
|
||||
}
|
||||
if (dropOnCanvas && overCanvas) {
|
||||
const pos = getCanvasPosition(event, canvas);
|
||||
try { await dropOnCanvas(event, data, graph, pos); }
|
||||
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('recipeOpenError') : t(error.message)); }
|
||||
return;
|
||||
}
|
||||
};
|
||||
const escape = event => { if (event.key === 'Escape') cleanup(); };
|
||||
const listeners = [['dragover', over], ['drop', finish], ['dragend', cleanup], ['keydown', escape], ['blur', cleanup]];
|
||||
// Let the browser capture the card's drag image before revealing the canvas.
|
||||
const reveal = setTimeout(() => owner.modal?.classList.add('anomalous-material-dragging'), 0);
|
||||
activeDrag = cleanup;
|
||||
for (const [name, fn] of listeners) window.addEventListener(name, fn, true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { translate as t } from './locales.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
|
||||
/** Shared save receipt; navigation is owned by the browser instance. */
|
||||
export function showMaterialSaved(owner, material, beforeOpen) {
|
||||
document.querySelector('.anomalous-material-receipt')?.remove();
|
||||
const receipt = document.createElement('div');
|
||||
receipt.className = 'anomalous-material-receipt';
|
||||
receipt.setAttribute('role', 'status');
|
||||
const message = document.createElement('span');
|
||||
message.textContent = t('materialSavedToLibrary');
|
||||
receipt.appendChild(message);
|
||||
if (typeof owner?.openSavedMaterial === 'function' && material?.filename) {
|
||||
const view = document.createElement('button');
|
||||
view.type = 'button';
|
||||
view.textContent = t('materialViewSaved');
|
||||
view.onclick = async () => {
|
||||
view.disabled = true;
|
||||
try {
|
||||
beforeOpen?.();
|
||||
await owner.openSavedMaterial(material);
|
||||
receipt.remove();
|
||||
} catch (error) {
|
||||
await anomalousAlert(t('materialDetailLoadError'));
|
||||
view.disabled = false;
|
||||
}
|
||||
};
|
||||
receipt.appendChild(view);
|
||||
}
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.textContent = '×';
|
||||
close.setAttribute('aria-label', t('materialDismissReceipt'));
|
||||
close.onclick = () => receipt.remove();
|
||||
receipt.appendChild(close);
|
||||
document.body.appendChild(receipt);
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import { text, jsonResponse } from './ui_dom.js';
|
||||
/** Shared image metadata and node-parameter inspection for the library and workbench. */
|
||||
import { translate } from './locales.js';
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
export function sectionLabel(parent, value) {
|
||||
return text(parent, 'div', value, 'anomalous-material-section-label');
|
||||
}
|
||||
|
||||
export function fileBaseName(value) {
|
||||
return String(value || '').replace(/\\/g, '/').split('/').pop();
|
||||
}
|
||||
|
||||
function previewIsVideo(url) {
|
||||
return /\.(?:mp4|webm)(?:$|\?|&|#)/i.test(url || '');
|
||||
}
|
||||
|
||||
export function modelCustomNotes(model) {
|
||||
return String(model?.metadata?.custom_notes || '').trim();
|
||||
}
|
||||
|
||||
function bindHoverPreviewVideo(video) {
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.onpointerenter = () => video.play().catch(() => {});
|
||||
video.onpointerleave = () => {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
};
|
||||
}
|
||||
|
||||
export function appendLocalPreview(parent, url, className) {
|
||||
if (!url) return null;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = className;
|
||||
if (previewIsVideo(url)) {
|
||||
const video = document.createElement('video');
|
||||
video.src = url;
|
||||
bindHoverPreviewVideo(video);
|
||||
wrap.appendChild(video);
|
||||
} else {
|
||||
const image = document.createElement('img');
|
||||
image.src = url;
|
||||
image.alt = '';
|
||||
image.loading = 'lazy';
|
||||
wrap.appendChild(image);
|
||||
}
|
||||
parent.appendChild(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export async function resolveLocalModels(paths) {
|
||||
const unique = [...new Set((paths || []).filter(Boolean))];
|
||||
if (!unique.length) return {};
|
||||
const response = await fetch('/anomalous/resolve_paths_to_previews', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: unique, exact_only: false }),
|
||||
});
|
||||
const payload = await jsonResponse(response, 'preview resolve failed');
|
||||
return payload.models || {};
|
||||
}
|
||||
|
||||
export function lookupLocalModel(localModels, path) {
|
||||
if (!path || !localModels) return null;
|
||||
return localModels[path] || localModels[fileBaseName(path)] || null;
|
||||
}
|
||||
|
||||
export async function parsePngMetadataFromUrl(url, options = {}) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const response = await fetch(url, { cache: 'force-cache', signal: options.signal });
|
||||
if (!response.ok) return null;
|
||||
const buffer = await response.arrayBuffer();
|
||||
const view = new DataView(buffer);
|
||||
if (view.byteLength < 32) return null;
|
||||
// PNG magic bytes: 137, 80, 78, 71, 13, 10, 26, 10
|
||||
if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) return null;
|
||||
|
||||
let offset = 8;
|
||||
const utf8 = new TextDecoder('utf-8');
|
||||
const latin1 = new TextDecoder('iso-8859-1');
|
||||
const result = {};
|
||||
|
||||
while (offset + 8 <= buffer.byteLength) {
|
||||
const length = view.getUint32(offset);
|
||||
const type = String.fromCharCode(
|
||||
view.getUint8(offset + 4),
|
||||
view.getUint8(offset + 5),
|
||||
view.getUint8(offset + 6),
|
||||
view.getUint8(offset + 7),
|
||||
);
|
||||
const dataOffset = offset + 8;
|
||||
if (type === 'IDAT') break; // Reached image raster data, text metadata is prior
|
||||
|
||||
if (dataOffset + length > buffer.byteLength) break;
|
||||
|
||||
if (type === 'tEXt') {
|
||||
const bytes = new Uint8Array(buffer, dataOffset, length);
|
||||
const nullIdx = bytes.indexOf(0);
|
||||
if (nullIdx > -1) {
|
||||
const key = latin1.decode(bytes.subarray(0, nullIdx));
|
||||
const val = latin1.decode(bytes.subarray(nullIdx + 1));
|
||||
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
|
||||
}
|
||||
} else if (type === 'iTXt') {
|
||||
const bytes = new Uint8Array(buffer, dataOffset, length);
|
||||
const nullIdx = bytes.indexOf(0);
|
||||
if (nullIdx > -1) {
|
||||
const key = latin1.decode(bytes.subarray(0, nullIdx));
|
||||
let ptr = nullIdx + 1;
|
||||
const compFlag = bytes[ptr++];
|
||||
const compMethod = bytes[ptr++];
|
||||
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
|
||||
ptr++;
|
||||
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
|
||||
ptr++;
|
||||
if (compFlag === 0 && ptr <= bytes.length) {
|
||||
const val = utf8.decode(bytes.subarray(ptr));
|
||||
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += 12 + length;
|
||||
}
|
||||
return result.workflow || result.prompt || null;
|
||||
} catch (e) {
|
||||
if (e?.name === 'AbortError') return null;
|
||||
console.warn('Client-side PNG metadata read skipped:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract generation parameters, full prompts, and categorized model references with details (e.g. LoRA strengths)
|
||||
* directly from the workflow.
|
||||
*/
|
||||
export function extractWorkflowDetails(workflow) {
|
||||
const params = {};
|
||||
const positivePrompts = [];
|
||||
const negativePrompts = [];
|
||||
const allPrompts = [];
|
||||
const loraDetailsMap = new Map();
|
||||
const discoveredModels = {
|
||||
checkpoints: [],
|
||||
loras: [],
|
||||
textEncoders: [],
|
||||
vaes: [],
|
||||
others: [],
|
||||
};
|
||||
|
||||
if (!workflow || typeof workflow !== 'object') {
|
||||
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
|
||||
}
|
||||
|
||||
const nodes = Array.isArray(workflow.nodes) ? workflow.nodes : [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== 'object') continue;
|
||||
const ntype = String(node.type || '').trim();
|
||||
const ntypeLower = ntype.toLowerCase();
|
||||
const widgets = Array.isArray(node.widgets_values) ? node.widgets_values : [];
|
||||
|
||||
// 1. Sampling parameters
|
||||
if (ntypeLower === 'ksampler' || ntypeLower === 'ksampleradvanced') {
|
||||
const isAdv = ntypeLower === 'ksampleradvanced';
|
||||
const offset = isAdv ? 1 : 0;
|
||||
if (widgets[offset] != null && params.seed == null) params.seed = widgets[offset];
|
||||
if (widgets[2 + offset] != null && params.steps == null) params.steps = widgets[2 + offset];
|
||||
if (widgets[3 + offset] != null && params.cfg == null) params.cfg = widgets[3 + offset];
|
||||
if (widgets[4 + offset] != null && params.sampler_name == null) params.sampler_name = widgets[4 + offset];
|
||||
if (widgets[5 + offset] != null && params.scheduler == null) params.scheduler = widgets[5 + offset];
|
||||
if (widgets[6 + offset] != null && params.denoise == null) params.denoise = widgets[6 + offset];
|
||||
} else if (ntypeLower === 'emptylatentimage') {
|
||||
if (widgets[0] && widgets[1] && params.resolution == null) {
|
||||
params.resolution = `${widgets[0]} × ${widgets[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Full un-truncated prompts
|
||||
if (ntypeLower.includes('cliptextencode') || ntypeLower.includes('prompt')) {
|
||||
const nodeTitle = String(node.title || ntype).toLowerCase();
|
||||
for (const val of widgets) {
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
const textVal = val.trim();
|
||||
if (!allPrompts.includes(textVal)) allPrompts.push(textVal);
|
||||
if (nodeTitle.includes('neg') || nodeTitle.includes('负向')) {
|
||||
if (!negativePrompts.includes(textVal)) negativePrompts.push(textVal);
|
||||
} else {
|
||||
if (!positivePrompts.includes(textVal)) positivePrompts.push(textVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Categorized model references & LoRA weights
|
||||
if (ntypeLower.includes('checkpoint') || ntypeLower === 'unetloader' || ntypeLower.includes('diffusionmodel')) {
|
||||
if (widgets[0] && typeof widgets[0] === 'string') {
|
||||
const name = widgets[0];
|
||||
if (!discoveredModels.checkpoints.some(c => c.name === name)) {
|
||||
discoveredModels.checkpoints.push({
|
||||
name,
|
||||
category: ntypeLower.includes('unet') ? 'unet' : 'checkpoint',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (ntypeLower.includes('lora')) {
|
||||
if (widgets[0] && typeof widgets[0] === 'string') {
|
||||
const name = widgets[0];
|
||||
const strengthModel = widgets[1] != null ? Number(widgets[1]) : 1.0;
|
||||
const strengthClip = widgets[2] != null ? Number(widgets[2]) : strengthModel;
|
||||
loraDetailsMap.set(name, { strengthModel, strengthClip });
|
||||
const baseName = name.replace(/\\/g, '/').split('/').pop();
|
||||
loraDetailsMap.set(baseName, { strengthModel, strengthClip });
|
||||
if (!discoveredModels.loras.some(l => l.name === name)) {
|
||||
discoveredModels.loras.push({ name, strengthModel, strengthClip, category: 'lora' });
|
||||
}
|
||||
}
|
||||
} else if (ntypeLower.includes('vaeloader') || (ntypeLower.includes('vae') && !ntypeLower.includes('encode') && !ntypeLower.includes('decode'))) {
|
||||
if (widgets[0] && typeof widgets[0] === 'string') {
|
||||
const name = widgets[0];
|
||||
if (!discoveredModels.vaes.some(v => v.name === name)) {
|
||||
discoveredModels.vaes.push({ name, category: 'vae' });
|
||||
}
|
||||
}
|
||||
} else if (ntypeLower.includes('cliploader') || ntypeLower.includes('dualcliploader') || ntypeLower.includes('textencoder')) {
|
||||
for (const w of widgets) {
|
||||
if (typeof w === 'string' && (w.endsWith('.safetensors') || w.endsWith('.bin') || w.endsWith('.pt') || w.endsWith('.sft'))) {
|
||||
if (!discoveredModels.textEncoders.some(t => t.name === w)) {
|
||||
discoveredModels.textEncoders.push({ name: w, category: 'text_encoder' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
|
||||
}
|
||||
|
||||
export function detailedBlocksFromWorkflow(workflow, serverBlocks = []) {
|
||||
const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
|
||||
if (!nodes.length) return Array.isArray(serverBlocks) ? serverBlocks : [];
|
||||
const serverById = new Map((serverBlocks || []).map(block => [String(block?.node_id), block]));
|
||||
const occurrences = {};
|
||||
return nodes.filter(node => node && typeof node === 'object' && node.type).map(node => {
|
||||
const nodeType = String(node.type);
|
||||
occurrences[nodeType] = (occurrences[nodeType] || 0) + 1;
|
||||
const server = serverById.get(String(node.id)) || {};
|
||||
const lower = nodeType.toLowerCase();
|
||||
const volatile = lower === 'ksampler' ? [0] : lower === 'ksampleradvanced' ? [1] : [];
|
||||
return {
|
||||
node_id: node.id,
|
||||
type: nodeType,
|
||||
title: node.title || server.title || nodeType,
|
||||
occurrence: occurrences[nodeType],
|
||||
widget_count: Array.isArray(node.widgets_values) ? node.widgets_values.length : 0,
|
||||
widgets_values: Array.isArray(node.widgets_values) ? node.widgets_values : [],
|
||||
volatile_widget_indexes: Array.isArray(server.volatile_widget_indexes)
|
||||
? server.volatile_widget_indexes
|
||||
: volatile,
|
||||
properties: node.properties && typeof node.properties === 'object' ? node.properties : {},
|
||||
mode: node.mode,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function materialWidgetLabels(nodeType) {
|
||||
const type = String(nodeType || '').toLowerCase();
|
||||
if (type === 'ksampler') return [
|
||||
t('materialParamSeed'), t('materialParamSeedControl'), t('recipeCardSpecsSteps'),
|
||||
'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'), t('materialDenoise'),
|
||||
];
|
||||
if (type === 'ksampleradvanced') return [
|
||||
t('materialParamAddNoise'), t('materialParamSeed'), t('materialParamSeedControl'),
|
||||
t('recipeCardSpecsSteps'), 'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'),
|
||||
t('materialParamStartStep'), t('materialParamEndStep'), t('materialParamLeftoverNoise'),
|
||||
];
|
||||
if (type === 'emptylatentimage') return [t('materialParamWidth'), t('materialParamHeight'), t('materialParamBatchSize')];
|
||||
if (/checkpointloader(simple)?$/.test(type)) return [t('materialParamCheckpoint')];
|
||||
if (type.endsWith('unetloader')) return [t('materialParamUnet'), t('materialParamWeightDtype')];
|
||||
if (type.includes('loraloader')) return [t('materialParamLora'), t('materialParamModelStrength'), t('materialParamClipStrength')];
|
||||
if (type.endsWith('vaeloader')) return [t('materialParamVae')];
|
||||
if (type.includes('cliptextencode')) return [t('materialPromptText')];
|
||||
if (type.endsWith('clipvisionloader')) return [t('materialParamClipVision')];
|
||||
if (type.endsWith('controlnetloader')) return [t('materialParamControlNet')];
|
||||
if (type.endsWith('dualcliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipType')];
|
||||
if (type.endsWith('triplecliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipThree')];
|
||||
if (type.endsWith('cliploader')) return [t('materialParamClipOne'), t('materialParamClipType')];
|
||||
if (type === 'saveimage') return [t('materialParamFilenamePrefix')];
|
||||
return [];
|
||||
}
|
||||
|
||||
const comfyWidgetLabelCache = new Map();
|
||||
|
||||
function comfyNodeTitle(nodeType) {
|
||||
const ctor = globalThis.LiteGraph?.registered_node_types?.[nodeType];
|
||||
return (ctor && ctor.title) || nodeType;
|
||||
}
|
||||
|
||||
function comfyWidgetLabels(nodeType) {
|
||||
if (comfyWidgetLabelCache.has(nodeType)) return comfyWidgetLabelCache.get(nodeType);
|
||||
let labels = [];
|
||||
try {
|
||||
const node = globalThis.LiteGraph?.createNode?.(nodeType);
|
||||
if (node?.widgets?.length) labels = node.widgets.map(widget => widget.label || widget.name || '');
|
||||
} catch (error) { /* some node types cannot be instantiated off-canvas */ }
|
||||
if (!labels.length) labels = materialWidgetLabels(nodeType);
|
||||
comfyWidgetLabelCache.set(nodeType, labels);
|
||||
return labels;
|
||||
}
|
||||
|
||||
const NODE_TITLE_SUFFIX = /\s*(\((?:negative|positive|neg|pos)\)|((?:负向|正向|反向))|#\d+)\s*$/i;
|
||||
|
||||
function compactNodeToken(value) {
|
||||
return String(value || '').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '');
|
||||
}
|
||||
|
||||
function comfyNodeDefaultTitles(nodeType) {
|
||||
const ctor = globalThis.LiteGraph?.registered_node_types?.[nodeType];
|
||||
const data = ctor?.nodeData;
|
||||
return [nodeType, ctor?.title, ctor?.comfyClass, data?.name, data?.display_name].filter(Boolean);
|
||||
}
|
||||
|
||||
export function materialNodeHeading(block) {
|
||||
const type = String(block?.type || '');
|
||||
const saved = String(block?.title || '').trim();
|
||||
const localized = comfyNodeTitle(type) || type;
|
||||
const suffixMatch = saved.match(NODE_TITLE_SUFFIX);
|
||||
const suffix = suffixMatch ? ` ${suffixMatch[1]}` : '';
|
||||
if (!saved || saved === type) return localized + suffix;
|
||||
|
||||
const core = saved.replace(NODE_TITLE_SUFFIX, '').trim();
|
||||
const compactCore = compactNodeToken(core);
|
||||
const isDefault = [type, localized, ...comfyNodeDefaultTitles(type)].some(name => {
|
||||
const compactName = compactNodeToken(name);
|
||||
return core === name || compactCore === compactName || (compactName && compactCore.startsWith(compactName));
|
||||
});
|
||||
return isDefault ? localized + suffix : saved;
|
||||
}
|
||||
|
||||
function formatMaterialParameterValue(value) {
|
||||
if (value === null) return 'null';
|
||||
if (value === undefined) return 'undefined';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try { return JSON.stringify(value, null, 2); } catch (error) { return String(value); }
|
||||
}
|
||||
|
||||
function isPromptBlock(block) {
|
||||
const type = String(block?.type || '').toLowerCase();
|
||||
return (type.includes('cliptextencode') || type.includes('prompt')) &&
|
||||
Array.isArray(block.widgets_values) &&
|
||||
block.widgets_values.length > 0 &&
|
||||
typeof block.widgets_values[0] === 'string';
|
||||
}
|
||||
|
||||
function promptRoleLabel(role) {
|
||||
return t({
|
||||
positive: 'recipePromptRolePositive',
|
||||
negative: 'recipePromptRoleNegative',
|
||||
both: 'recipePromptRoleBoth',
|
||||
ignored: 'recipePromptRoleIgnored',
|
||||
unknown: 'recipePromptRoleUnknown',
|
||||
}[role] || 'recipePromptRoleUnknown');
|
||||
}
|
||||
|
||||
export function applyPromptRolesToBlocks(blocks, promptRoles) {
|
||||
if (!Array.isArray(blocks) || !promptRoles || typeof promptRoles !== 'object') return blocks;
|
||||
for (const block of blocks) {
|
||||
const info = promptRoles[String(block?.node_id)];
|
||||
if (!info || typeof info !== 'object') continue;
|
||||
block.promptRole = info.role;
|
||||
block.promptRoleSource = info.source;
|
||||
block.promptRoleManual = info.source === 'manual';
|
||||
block.promptRoleAutomatic = info.automatic_role || (info.source === 'manual' ? 'unknown' : info.role);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function mergePromptRoleOverrides(promptRoles, overrides) {
|
||||
const roles = { ...(promptRoles || {}) };
|
||||
if (!overrides || typeof overrides !== 'object') return roles;
|
||||
for (const [nodeId, entry] of Object.entries(overrides)) {
|
||||
if (entry?.role) {
|
||||
const automatic = roles[String(nodeId)];
|
||||
roles[String(nodeId)] = {
|
||||
role: entry.role,
|
||||
source: 'manual',
|
||||
automatic_role: automatic?.automatic_role || automatic?.role || 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
export function promptGroupsFromBlocks(blocks) {
|
||||
const positive = [];
|
||||
const negative = [];
|
||||
for (const block of Array.isArray(blocks) ? blocks : []) {
|
||||
const value = (block.widgets_values || []).find(item => typeof item === 'string' && item.trim());
|
||||
if (!value) continue;
|
||||
const textVal = value.trim();
|
||||
if ((block.promptRole === 'positive' || block.promptRole === 'both') && !positive.includes(textVal)) positive.push(textVal);
|
||||
if ((block.promptRole === 'negative' || block.promptRole === 'both') && !negative.includes(textVal)) negative.push(textVal);
|
||||
}
|
||||
return { positive, negative };
|
||||
}
|
||||
|
||||
export function renderMaterialPromptGroups(parent, groups, options = {}) {
|
||||
const positive = Array.isArray(groups?.positive) ? groups.positive.filter(Boolean) : [];
|
||||
const negative = Array.isArray(groups?.negative) ? groups.negative.filter(Boolean) : [];
|
||||
if (!positive.length && !negative.length) return false;
|
||||
|
||||
const renderCard = (titleText, promptStr, tone) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = tone ? `anomalous-material-prompt-card is-${tone}` : 'anomalous-material-prompt-card';
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'anomalous-material-prompt-bar';
|
||||
text(bar, 'span', titleText, 'anomalous-material-prompt-bar-label');
|
||||
const copyBtn = text(bar, 'button', t('materialCopyPrompt'), 'anomalous-material-mini-btn');
|
||||
copyBtn.type = 'button';
|
||||
bindCopyButton(copyBtn, () => promptStr, 'materialCopied');
|
||||
card.appendChild(bar);
|
||||
text(card, 'div', promptStr, 'anomalous-material-prompt-content is-expanded');
|
||||
parent.appendChild(card);
|
||||
};
|
||||
|
||||
if (positive.length) renderCard(t('materialPositivePrompt'), positive.join('\n\n'), 'positive');
|
||||
if (negative.length) renderCard(t('materialNegativePrompt'), negative.join('\n\n'), 'negative');
|
||||
if (options.manual) text(parent, 'p', t('materialPromptRoleFromRecipe'), 'anomalous-material-muted');
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindCopyButton(button, value, successKey) {
|
||||
const label = button.textContent;
|
||||
button.onclick = async event => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(value());
|
||||
button.textContent = t(successKey);
|
||||
} catch (error) {
|
||||
button.textContent = t('materialCopyError');
|
||||
}
|
||||
setTimeout(() => { if (button.isConnected) button.textContent = label; }, 1200);
|
||||
};
|
||||
}
|
||||
|
||||
function renderPromptBlock(content, block, widgetValues) {
|
||||
const promptWrap = document.createElement('div');
|
||||
promptWrap.className = 'anomalous-material-prompt-station';
|
||||
|
||||
const promptText = String(widgetValues[0] ?? '');
|
||||
const charCount = promptText.length;
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'anomalous-material-prompt-bar';
|
||||
|
||||
const titleSec = document.createElement('div');
|
||||
titleSec.className = 'anomalous-material-prompt-title-sec';
|
||||
text(titleSec, 'span', `💬 ${t('materialPromptNode') || '提示词'}`, 'anomalous-material-prompt-title');
|
||||
text(titleSec, 'span', t('materialChars', { count: charCount }), 'anomalous-material-prompt-count');
|
||||
bar.appendChild(titleSec);
|
||||
|
||||
const copyPromptBtn = text(bar, 'button', t('materialCopyPromptText'), 'anomalous-material-copy-prompt-btn');
|
||||
copyPromptBtn.type = 'button';
|
||||
bindCopyButton(copyPromptBtn, () => promptText, 'materialPromptCopied');
|
||||
promptWrap.appendChild(bar);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'anomalous-material-prompt-body';
|
||||
body.textContent = promptText || t('materialNoWidgetParameters');
|
||||
promptWrap.appendChild(body);
|
||||
|
||||
content.appendChild(promptWrap);
|
||||
}
|
||||
|
||||
function renderNodeParameterRows(content, block, widgetValues, offset = 0) {
|
||||
const labels = comfyWidgetLabels(block.type);
|
||||
const volatileIndexes = new Set(Array.isArray(block.volatile_widget_indexes) ? block.volatile_widget_indexes : []);
|
||||
if (!widgetValues.length) {
|
||||
text(content, 'p', t('materialNoWidgetParameters'), 'anomalous-material-muted');
|
||||
return;
|
||||
}
|
||||
widgetValues.forEach((value, localIndex) => {
|
||||
const index = localIndex + offset;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'anomalous-material-parameter-row';
|
||||
const labelWrap = document.createElement('div');
|
||||
labelWrap.className = 'anomalous-material-parameter-label';
|
||||
text(labelWrap, 'span', labels[index] || t('materialWidgetIndex', { index: index + 1 }));
|
||||
text(labelWrap, 'code', `#${index}`);
|
||||
if (volatileIndexes.has(index)) {
|
||||
text(labelWrap, 'span', t('materialVolatileParameter'), 'anomalous-material-volatile-badge');
|
||||
}
|
||||
const valueText = text(row, 'pre', formatMaterialParameterValue(value), 'anomalous-material-parameter-value');
|
||||
valueText.title = t('materialParameterFullValue');
|
||||
row.prepend(labelWrap);
|
||||
content.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderNodeProperties(content, properties) {
|
||||
const props = properties && typeof properties === 'object' ? properties : {};
|
||||
if (!Object.keys(props).length) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'anomalous-material-parameter-row';
|
||||
const labelWrap = document.createElement('div');
|
||||
labelWrap.className = 'anomalous-material-parameter-label';
|
||||
text(labelWrap, 'span', t('materialNodeProperties'));
|
||||
const propertyValue = document.createElement('pre');
|
||||
propertyValue.className = 'anomalous-material-parameter-value';
|
||||
propertyValue.textContent = formatMaterialParameterValue(props);
|
||||
row.append(labelWrap, propertyValue);
|
||||
content.appendChild(row);
|
||||
}
|
||||
|
||||
function renderNodeCardContent(node, block, widgetValues) {
|
||||
const content = document.createElement('div');
|
||||
content.className = 'anomalous-material-node-parameter-content';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'anomalous-material-node-meta';
|
||||
text(meta, 'span', `${t('materialNodeId')}: ${block.node_id ?? '—'}`);
|
||||
if (block.mode != null) text(meta, 'span', `${t('materialNodeMode')}: ${block.mode}`);
|
||||
|
||||
const copy = text(meta, 'button', t('materialCopyNodeParameters'), 'anomalous-material-mini-btn');
|
||||
copy.type = 'button';
|
||||
bindCopyButton(copy, () => JSON.stringify({
|
||||
node_id: block.node_id, type: block.type, title: block.title,
|
||||
widgets_values: widgetValues, properties: block.properties || {}, mode: block.mode,
|
||||
}, null, 2), 'materialCopied');
|
||||
const technical = document.createElement('details');
|
||||
technical.className = 'anomalous-notebook-fold';
|
||||
text(technical, 'summary', t('recipeAdvancedInfo'));
|
||||
technical.appendChild(meta);
|
||||
|
||||
if (isPromptBlock(block)) {
|
||||
renderPromptBlock(content, block, widgetValues);
|
||||
if (widgetValues.length > 1) {
|
||||
renderNodeParameterRows(content, block, widgetValues.slice(1), 1);
|
||||
}
|
||||
} else {
|
||||
renderNodeParameterRows(content, block, widgetValues);
|
||||
}
|
||||
|
||||
renderNodeProperties(technical, block.properties);
|
||||
content.appendChild(technical);
|
||||
node.appendChild(content);
|
||||
}
|
||||
|
||||
function renderSingleNodeCard(block, options = {}) {
|
||||
const node = document.createElement('details');
|
||||
node.className = 'anomalous-material-node-detail';
|
||||
const widgetValues = Array.isArray(block.widgets_values) ? block.widgets_values : [];
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
if (options.selectable) {
|
||||
const select = document.createElement('input');
|
||||
select.type = 'checkbox';
|
||||
select.className = 'anomalous-material-node-select';
|
||||
select.dataset.nodeId = String(block.node_id);
|
||||
select.checked = options.selectedIds?.has(String(block.node_id)) || false;
|
||||
select.title = t('materialSelectNodeForSaving');
|
||||
select.setAttribute('aria-label', t('materialSelectNodeForSaving'));
|
||||
select.addEventListener('click', event => event.stopPropagation());
|
||||
select.addEventListener('change', () => {
|
||||
options.onSelectionChange?.(block, select.checked);
|
||||
node.classList.toggle('is-selected', select.checked);
|
||||
});
|
||||
node.classList.toggle('is-selected', select.checked);
|
||||
summary.appendChild(select);
|
||||
}
|
||||
|
||||
const heading = document.createElement('span');
|
||||
heading.className = 'anomalous-material-node-heading';
|
||||
text(heading, 'strong', materialNodeHeading(block));
|
||||
const renderRoleBadge = () => {
|
||||
heading.querySelector('.anomalous-recipe-prompt-role-badge')?.remove();
|
||||
if (!block.promptRole) return;
|
||||
const badge = text(heading, 'span', promptRoleLabel(block.promptRole), `anomalous-recipe-prompt-role-badge is-${block.promptRole}`);
|
||||
badge.title = block.promptRoleManual ? t('recipePromptRoleManual') : t('recipePromptRoleAutomatic');
|
||||
};
|
||||
renderRoleBadge();
|
||||
summary.appendChild(heading);
|
||||
|
||||
text(summary, 'span', t('materialParameterCount', { count: widgetValues.length }), 'anomalous-material-node-count');
|
||||
|
||||
if (isPromptBlock(block) && typeof options.onPromptRoleChange === 'function') {
|
||||
const roleSelect = document.createElement('select');
|
||||
roleSelect.className = 'anomalous-recipe-prompt-role-select anomalous-material-prompt-role-select';
|
||||
roleSelect.setAttribute('aria-label', t('recipePromptRoleChoose'));
|
||||
const automaticRole = block.promptRoleAutomatic || (block.promptRoleManual ? 'unknown' : block.promptRole) || 'unknown';
|
||||
const choices = [
|
||||
['auto', `${t('recipePromptRoleAutomatic')} · ${promptRoleLabel(automaticRole)}`],
|
||||
['positive', t('recipePromptRolePositive')],
|
||||
['negative', t('recipePromptRoleNegative')],
|
||||
['both', t('recipePromptRoleBoth')],
|
||||
['unknown', t('recipePromptRoleUnknown')],
|
||||
['ignored', t('recipePromptRoleIgnored')],
|
||||
];
|
||||
for (const [value, label] of choices) {
|
||||
const option = text(roleSelect, 'option', label);
|
||||
option.value = value;
|
||||
}
|
||||
roleSelect.value = block.promptRoleManual ? block.promptRole : 'auto';
|
||||
roleSelect.addEventListener('click', event => event.stopPropagation());
|
||||
roleSelect.addEventListener('change', async event => {
|
||||
event.stopPropagation();
|
||||
const previous = block.promptRoleManual ? block.promptRole : 'auto';
|
||||
roleSelect.disabled = true;
|
||||
try {
|
||||
await options.onPromptRoleChange(block, roleSelect.value, roleSelect);
|
||||
renderRoleBadge();
|
||||
} catch (error) {
|
||||
roleSelect.value = previous;
|
||||
console.error('Could not update prompt role:', error);
|
||||
} finally {
|
||||
if (roleSelect.isConnected) roleSelect.disabled = false;
|
||||
}
|
||||
});
|
||||
roleSelect.hidden = true;
|
||||
const adjust = text(summary, 'button', t('promptAdjustRole'), 'anomalous-btn-ghost');
|
||||
adjust.type = 'button';
|
||||
adjust.setAttribute('aria-expanded', 'false');
|
||||
adjust.onclick = event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
roleSelect.hidden = !roleSelect.hidden;
|
||||
adjust.setAttribute('aria-expanded', String(!roleSelect.hidden));
|
||||
if (!roleSelect.hidden) roleSelect.focus();
|
||||
};
|
||||
summary.appendChild(roleSelect);
|
||||
}
|
||||
|
||||
if (typeof options.onSaveBlock === 'function') {
|
||||
const save = text(summary, 'button', t(isPromptBlock(block) ? 'materialSavePromptAction' : 'materialSaveNode'), 'anomalous-material-node-save');
|
||||
save.type = 'button';
|
||||
save.title = t('materialSaveNodeHint');
|
||||
save.addEventListener('click', event => event.stopPropagation());
|
||||
save.addEventListener('click', () => options.onSaveBlock(block, save));
|
||||
}
|
||||
node.appendChild(summary);
|
||||
|
||||
const shouldOpen = !options.selectable && isPromptBlock(block);
|
||||
let rendered = false;
|
||||
|
||||
const renderContentIfNeeded = () => {
|
||||
if (rendered) return;
|
||||
rendered = true;
|
||||
renderNodeCardContent(node, block, widgetValues);
|
||||
};
|
||||
|
||||
if (shouldOpen) {
|
||||
node.open = true;
|
||||
renderContentIfNeeded();
|
||||
}
|
||||
|
||||
node.addEventListener('toggle', () => {
|
||||
if (node.open) renderContentIfNeeded();
|
||||
});
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export function renderDetailedNodeCards(parent, blocks, options = {}) {
|
||||
const list = document.createElement('div');
|
||||
list.className = 'anomalous-material-node-list';
|
||||
blocks.forEach(block => {
|
||||
list.appendChild(renderSingleNodeCard(block, options));
|
||||
});
|
||||
parent.appendChild(list);
|
||||
return list;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { composePromptPlan } from './prompt_composition.js';
|
||||
import { jsonResponse } from './ui_dom.js';
|
||||
|
||||
|
||||
export function materialPromptText(payload) {
|
||||
const data = payload.data || {};
|
||||
if (data.kind === 'prompt_plan') return composePromptPlan(data.plan || {});
|
||||
if (['prompt_text', 'prompt_note_bundle'].includes(data.kind)) {
|
||||
return { positive: data.note?.promptEn || '', negative: '' };
|
||||
}
|
||||
const groups = payload.prompt_groups || {};
|
||||
return Object.fromEntries(['positive', 'negative'].map(role => [role,
|
||||
(Array.isArray(groups[role]) ? groups[role] : []).filter(value => typeof value === 'string' && value.trim()).join('\n'),
|
||||
]));
|
||||
}
|
||||
|
||||
export async function loadMaterialPrompts(filename, signal) {
|
||||
const response = await fetch(`/anomalous/material_full?include_workflow=0&filename=${encodeURIComponent(filename)}`, { signal });
|
||||
const payload = await jsonResponse(response, 'material load failed');
|
||||
if (payload.status !== 'success') throw new Error('materialDetailLoadError');
|
||||
return materialPromptText(payload);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
const PHYSICAL_RENAME_PROTECTED_TYPES = new Set([
|
||||
'vae',
|
||||
'vae_approx',
|
||||
'clip',
|
||||
'text_encoders',
|
||||
'clip_vision',
|
||||
]);
|
||||
|
||||
// Scanner coverage is separate. These categories may be scanned, but automatic
|
||||
// Model Doctor redirection requires workflow-carried cryptographic identity.
|
||||
const HASH_ONLY_RECOVERY_TYPES = new Set([
|
||||
'vae',
|
||||
'vae_approx',
|
||||
'clip',
|
||||
'text_encoders',
|
||||
'clip_vision',
|
||||
]);
|
||||
|
||||
export function isPhysicalRenameProtectedType(folderType) {
|
||||
return PHYSICAL_RENAME_PROTECTED_TYPES.has(String(folderType || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function inferModelFolderTypes(node, widget) {
|
||||
const widgetName = String(widget?.name || '').toLowerCase();
|
||||
const nodeType = String(node?.type || '').toLowerCase();
|
||||
const key = `${widgetName} ${nodeType}`;
|
||||
|
||||
if (key.includes('clip_vision') || key.includes('clip vision') || nodeType.includes('clipvision')) return ['clip_vision'];
|
||||
if (key.includes('vae_approx') || key.includes('vae approx')) return ['vae_approx'];
|
||||
if (widgetName.includes('vae') || nodeType === 'vaeloader' || key.includes('vae loader')) return ['vae'];
|
||||
if (widgetName.includes('lora') || key.includes('lora')) return ['loras'];
|
||||
if (widgetName.includes('control_net') || widgetName.includes('controlnet') || key.includes('controlnet')) return ['controlnet'];
|
||||
if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) return ['checkpoints'];
|
||||
if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) return ['diffusion_models', 'unet'];
|
||||
if (widgetName.includes('clip_name') || widgetName.includes('text_encoder') || nodeType.includes('cliploader')) return ['clip', 'text_encoders'];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function requiresHashForModelRecovery(node, widget) {
|
||||
const types = inferModelFolderTypes(node, widget);
|
||||
return types.length > 0 && types.every(type => HASH_ONLY_RECOVERY_TYPES.has(type));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { isPhysicalRenameProtectedType } from './model_policies.js';
|
||||
|
||||
export function usableSourceUrl(value) {
|
||||
const url = typeof value === 'string' ? value.trim() : '';
|
||||
if (!url) return '';
|
||||
const civitaiModel = url.match(/^https?:\/\/(?:www\.)?civitai\.(?:com|red)\/models\/(-?\d+)(?:[/?#]|$)/i);
|
||||
if (civitaiModel && Number(civitaiModel[1]) <= 0) return '';
|
||||
return url;
|
||||
}
|
||||
|
||||
export function foundationModelType(model) {
|
||||
const candidates = Array.isArray(model?.folderTypes) ? [...model.folderTypes] : [];
|
||||
if (model?.type) candidates.push(model.type);
|
||||
return candidates.find(isPhysicalRenameProtectedType) || '';
|
||||
}
|
||||
|
||||
export function shapeLibrarySourceModels(rawList, detectPlatform) {
|
||||
return (Array.isArray(rawList) ? rawList : []).map(m => {
|
||||
const meta = m.metadata || {};
|
||||
const sourceUrl = usableSourceUrl(meta.source_url);
|
||||
const civitaiUrl = usableSourceUrl(meta.civitai_url);
|
||||
const url = sourceUrl || civitaiUrl;
|
||||
const relPath = m.subfolder ? `${m.subfolder}/${m.filename}` : m.filename;
|
||||
return {
|
||||
key: `lib_${m.type}_${m.path_idx}_${relPath}`,
|
||||
type: m.type,
|
||||
path_idx: m.path_idx,
|
||||
subfolder: m.subfolder || '',
|
||||
filename: m.filename,
|
||||
basename: m.filename,
|
||||
relPath,
|
||||
size_mb: m.size_mb || 0,
|
||||
hash: meta.hash || '',
|
||||
civitai_url: civitaiUrl,
|
||||
source_url: sourceUrl,
|
||||
url,
|
||||
initialUrl: url,
|
||||
platform: detectPlatform(url),
|
||||
hasResolved: Boolean(url.trim()),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function matchesSearch(model, keyword) {
|
||||
if (!keyword) return true;
|
||||
const haystack = `${model.nodeTitle || ''} ${model.nodeType || ''} ${model.type || ''} ${model.filename || ''} ${model.basename || ''}`.toLowerCase();
|
||||
return haystack.includes(keyword);
|
||||
}
|
||||
|
||||
export function partitionSourceModels(models, filter = 'all', searchKeyword = '') {
|
||||
const allModels = Array.isArray(models) ? models : [];
|
||||
const allComponents = allModels.filter(model => Boolean(foundationModelType(model)));
|
||||
const allMain = allModels.filter(model => !foundationModelType(model));
|
||||
const matchesFilter = model => {
|
||||
if (filter === 'resolved' && !model.url) return false;
|
||||
if (filter === 'unresolved' && model.url) return false;
|
||||
return matchesSearch(model, searchKeyword);
|
||||
};
|
||||
const mainModels = allMain.filter(matchesFilter);
|
||||
const componentModels = allComponents.filter(matchesFilter);
|
||||
return {
|
||||
mainModels,
|
||||
componentModels,
|
||||
allComponentCount: componentModels.length,
|
||||
componentMissingCount: componentModels.filter(model => model.isMissing === true).length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Shared by Node Assistant and Material Library. No node creation or link edits.
|
||||
const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
export function selectedMaterialNode(app) {
|
||||
const nodes = Object.values(app.canvas?.selected_nodes || {});
|
||||
if (nodes.length !== 1 || !nodes[0]) return null;
|
||||
const node = nodes[0];
|
||||
const graph = app.graph?.getNodeById(node.id) === node ? app.graph : (node.graph || app.canvas?.graph || app.graph);
|
||||
return graph?.getNodeById(node.id) === node ? node : null;
|
||||
}
|
||||
|
||||
function volatile(node, widget, index) {
|
||||
return /(^|[_\s-])(seed|noise_seed|random_seed|variation_seed|last_seed)([_\s-]|$)/i.test(widget?.name || '')
|
||||
|| (node.type === 'KSampler' && index === 0) || (node.type === 'KSamplerAdvanced' && index === 1);
|
||||
}
|
||||
|
||||
function hashesFor(graph, id) {
|
||||
return Object.fromEntries(Object.entries(graph.extra?.anomalous_hashes || {}).filter(([key]) => key.startsWith(`${id}_`)));
|
||||
}
|
||||
|
||||
function replaceHashes(graph, id, records) {
|
||||
if (!Object.keys(records).length && !graph.extra?.anomalous_hashes) return;
|
||||
graph.extra ||= {};
|
||||
graph.extra.anomalous_hashes ||= {};
|
||||
for (const key of Object.keys(graph.extra.anomalous_hashes)) if (key.startsWith(`${id}_`)) delete graph.extra.anomalous_hashes[key];
|
||||
Object.assign(graph.extra.anomalous_hashes, clone(records));
|
||||
}
|
||||
|
||||
export function applyNodeMaterialValues(app, node, entries, options = {}) {
|
||||
const graph = app.graph?.getNodeById(node?.id) === node ? app.graph : (node?.graph || app.canvas?.graph || app.graph);
|
||||
if (!node || graph?.getNodeById(node.id) !== node || !Array.isArray(node.widgets)) throw new Error('materialTargetChanged');
|
||||
const changes = entries.filter(({ index }) => !volatile(node, node.widgets[index], index));
|
||||
if (!changes.length) throw new Error('materialNoCompatibleValues');
|
||||
for (const { index, value } of changes) {
|
||||
const widget = node.widgets[index];
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) throw new Error('materialNoCompatibleValues');
|
||||
if (!Number.isInteger(index) || !widget || value === undefined) throw new Error('materialNoCompatibleValues');
|
||||
const choices = typeof widget.options?.values === 'function' ? widget.options.values() : widget.options?.values;
|
||||
if (Array.isArray(choices) && !choices.includes(value)) throw new Error('materialValueUnavailable');
|
||||
if (widget.value != null && (typeof widget.value !== typeof value || Array.isArray(widget.value) !== Array.isArray(value))) throw new Error('materialNoCompatibleValues');
|
||||
}
|
||||
const previous = node.widgets.map(widget => clone(widget.value));
|
||||
const serialized = clone(node.widgets_values);
|
||||
const hadExtra = Object.hasOwn(graph, 'extra');
|
||||
const hadHashes = !!graph.extra && Object.hasOwn(graph.extra, 'anomalous_hashes');
|
||||
const oldHashes = clone(hashesFor(graph, node.id));
|
||||
const transportsHashes = options.sourceNodeId !== undefined;
|
||||
const mapped = {};
|
||||
if (transportsHashes) {
|
||||
const prefix = `${options.sourceNodeId}_`;
|
||||
for (const [key, value] of Object.entries(options.workflowHashes || {})) {
|
||||
if (key.startsWith(prefix)) mapped[`${node.id}_${key.slice(prefix.length)}`] = clone(value);
|
||||
}
|
||||
}
|
||||
const notify = () => {
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
if (typeof globalThis.CustomEvent === 'function') {
|
||||
globalThis.window?.dispatchEvent(new CustomEvent('graphChanged'));
|
||||
}
|
||||
};
|
||||
const restore = () => {
|
||||
node.widgets.forEach((widget, index) => { widget.value = clone(previous[index]); });
|
||||
if (serialized === undefined) delete node.widgets_values;
|
||||
else node.widgets_values = clone(serialized);
|
||||
if (transportsHashes) {
|
||||
replaceHashes(graph, node.id, oldHashes);
|
||||
if (!hadHashes && graph.extra?.anomalous_hashes && !Object.keys(graph.extra.anomalous_hashes).length) delete graph.extra.anomalous_hashes;
|
||||
if (!hadExtra && graph.extra && !Object.keys(graph.extra).length) delete graph.extra;
|
||||
}
|
||||
};
|
||||
graph.beforeChange?.();
|
||||
try {
|
||||
for (const { index, value } of changes) {
|
||||
const widget = node.widgets[index];
|
||||
widget.value = clone(value);
|
||||
if (Array.isArray(node.widgets_values)) node.widgets_values[index] = clone(value);
|
||||
widget.callback?.call(widget, widget.value, app.canvas, node);
|
||||
node.onWidgetChanged?.(index, widget.value, previous[index], widget);
|
||||
}
|
||||
if (transportsHashes) replaceHashes(graph, node.id, mapped);
|
||||
notify();
|
||||
} catch (error) {
|
||||
restore();
|
||||
notify();
|
||||
throw error;
|
||||
} finally { graph.afterChange?.(); }
|
||||
const appliedSerialized = clone(node.widgets_values);
|
||||
const applied = node.widgets.map(widget => clone(widget.value));
|
||||
const appliedHashes = clone(hashesFor(graph, node.id));
|
||||
let undone = false;
|
||||
return {
|
||||
widgets: changes.length,
|
||||
undo() {
|
||||
if (undone || app.graph !== graph || graph.getNodeById(node.id) !== node
|
||||
|| !same(node.widgets.map(widget => widget.value), applied)
|
||||
|| !same(node.widgets_values, appliedSerialized)
|
||||
|| !same(hashesFor(graph, node.id), appliedHashes)) throw new Error('materialUndoChanged');
|
||||
graph.beforeChange?.();
|
||||
try { restore(); notify(); undone = true; }
|
||||
finally { graph.afterChange?.(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMaterialBlock(app, node, block, workflowHashes) {
|
||||
if (node?.type !== block?.type || !Array.isArray(block.widgets_values)
|
||||
|| block.widgets_values.length > (node.widgets?.length || 0)) throw new Error('materialNoCompatibleValues');
|
||||
return applyNodeMaterialValues(app, node, block.widgets_values.map((value, index) => ({ index, value })),
|
||||
{ sourceNodeId: block.node_id, workflowHashes });
|
||||
}
|
||||
|
||||
export function promptWidgetTargets(node) {
|
||||
const promptNameRegex = /^(text|text_g|text_l|prompt|positive|negative|caption|string|value|文本|提示词|正面|负面|正向|反向|正面提示词|负面提示词|正向提示词|反向提示词|描述|内容)$/i;
|
||||
return (node?.widgets || []).flatMap((widget, index) => {
|
||||
if (!widget) return [];
|
||||
const name = String(widget.name || '');
|
||||
const label = String(widget.label || '');
|
||||
const isNotCombo = !widget.options?.values || !Array.isArray(widget.options.values);
|
||||
const matchesName = promptNameRegex.test(name) || promptNameRegex.test(label) || widget.type === 'customtext' || widget.type === 'text' || !!widget.options?.multiline;
|
||||
return typeof widget.value === 'string' && matchesName && isNotCombo ? [{ index, name: name || label || 'text' }] : [];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Create a movable LiteGraph group from the active Prompt Note. */
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate } from './locales.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
/** Base-model value the backend uses for models whose metadata names no base model. */
|
||||
export const UNLABELED_BASE_MODEL = '__unlabeled__';
|
||||
|
||||
/** UNet / diffusion models carry no text encoder, so their LoRAs patch the model only. */
|
||||
export function isUnetModel(model) {
|
||||
return model?.type === 'unet' || model?.type === 'diffusion_models';
|
||||
}
|
||||
|
||||
export function sendNotebookToCanvas() {
|
||||
if (!this.currentNotebook) return;
|
||||
const data = this.currentNotebook.data || {};
|
||||
if (!data.mainModel) {
|
||||
alert(t('notebookSelectMain'));
|
||||
return;
|
||||
}
|
||||
|
||||
const groupNodes = [];
|
||||
const isUnet = isUnetModel(data.mainModel);
|
||||
|
||||
const ckptNode = LiteGraph.createNode(isUnet ? "UNETLoader" : "CheckpointLoaderSimple");
|
||||
app.graph.add(ckptNode);
|
||||
groupNodes.push({ node: ckptNode, relX: 0, relY: 0 });
|
||||
|
||||
const sub = data.mainModel.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const relPath = sub ? `${sub}/${data.mainModel.filename}` : data.mainModel.filename;
|
||||
this.setWidgetValuePath(ckptNode, relPath);
|
||||
|
||||
let lastNode = ckptNode;
|
||||
let lastModelSlot = 0;
|
||||
let lastClipSlot = isUnet ? null : 1;
|
||||
|
||||
let relX = 350;
|
||||
let relY = 0;
|
||||
|
||||
data.loras.forEach((lora, idx) => {
|
||||
const loraNode = LiteGraph.createNode(isUnet ? "LoraLoaderModelOnly" : "LoraLoader");
|
||||
app.graph.add(loraNode);
|
||||
groupNodes.push({ node: loraNode, relX: relX, relY: relY });
|
||||
|
||||
const lsub = lora.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const lrelPath = lsub ? `${lsub}/${lora.filename}` : lora.filename;
|
||||
this.setWidgetValuePath(loraNode, lrelPath);
|
||||
|
||||
lastNode.connect(lastModelSlot, loraNode, 0);
|
||||
if (lastClipSlot !== null) lastNode.connect(lastClipSlot, loraNode, 1);
|
||||
|
||||
lastNode = loraNode;
|
||||
lastModelSlot = 0;
|
||||
if (!isUnet) lastClipSlot = 1;
|
||||
relX += 350;
|
||||
});
|
||||
|
||||
if (data.promptEn) {
|
||||
const posNode = LiteGraph.createNode("CLIPTextEncode");
|
||||
posNode.title = "CLIP Text Encode (Positive)";
|
||||
app.graph.add(posNode);
|
||||
groupNodes.push({ node: posNode, relX: relX, relY: 0 });
|
||||
|
||||
if (posNode.widgets && posNode.widgets.length > 0) {
|
||||
const tw = posNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
|
||||
if (tw) tw.value = data.promptEn;
|
||||
}
|
||||
if (lastClipSlot !== null) {
|
||||
lastNode.connect(lastClipSlot, posNode, 0);
|
||||
}
|
||||
|
||||
const negNode = LiteGraph.createNode("CLIPTextEncode");
|
||||
negNode.title = "CLIP Text Encode (Negative)";
|
||||
app.graph.add(negNode);
|
||||
groupNodes.push({ node: negNode, relX: relX, relY: 250 });
|
||||
|
||||
if (negNode.widgets && negNode.widgets.length > 0) {
|
||||
const tw = negNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
|
||||
if (tw) tw.value = "text, watermark, ugly, bad anatomy";
|
||||
}
|
||||
if (lastClipSlot !== null) {
|
||||
lastNode.connect(lastClipSlot, negNode, 0);
|
||||
}
|
||||
}
|
||||
|
||||
this.nbPanel.style.display = 'none';
|
||||
this.close();
|
||||
|
||||
// Magnetic Sticking Logic
|
||||
let isSticking = true;
|
||||
const stickHandler = (e) => {
|
||||
if (!isSticking || !app.canvas) return;
|
||||
const canvas = app.canvas;
|
||||
|
||||
let canvasX, canvasY;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
canvasX = pos[0];
|
||||
canvasY = pos[1];
|
||||
} else {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale;
|
||||
canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale;
|
||||
}
|
||||
|
||||
groupNodes.forEach(item => {
|
||||
const w = (item.node.size && item.node.size[0]) ? item.node.size[0] : 200;
|
||||
item.node.pos = [canvasX - (w / 2) + item.relX, canvasY - 20 + item.relY];
|
||||
});
|
||||
canvas.setDirty(true, true);
|
||||
};
|
||||
|
||||
const dropHandler = (e) => {
|
||||
if (!isSticking) return;
|
||||
isSticking = false;
|
||||
window.removeEventListener('mousemove', stickHandler, true);
|
||||
window.removeEventListener('pointerdown', dropHandler, true);
|
||||
window.removeEventListener('mousedown', dropHandler, true);
|
||||
window.removeEventListener('click', dropHandler, true);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', stickHandler, true);
|
||||
setTimeout(() => {
|
||||
window.addEventListener('pointerdown', dropHandler, true);
|
||||
window.addEventListener('mousedown', dropHandler, true);
|
||||
window.addEventListener('click', dropHandler, true);
|
||||
}, 100);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/** Preserve fragment contents and order, including weights and commas. */
|
||||
export function joinPromptText(existing, incoming, position) {
|
||||
if (!['before', 'after'].includes(position)) throw new Error('materialNoCompatibleValues');
|
||||
return (position === 'before' ? [incoming, existing] : [existing, incoming])
|
||||
.filter(value => value && value.trim()).join('\n');
|
||||
}
|
||||
|
||||
export function composePromptPlan(plan) {
|
||||
if (!plan) return { positive: '', negative: '' };
|
||||
const parts = Array.isArray(plan.parts) ? plan.parts : [];
|
||||
const active = parts.filter(part => part && part.enabled !== false);
|
||||
|
||||
return Object.fromEntries(['positive', 'negative'].map(role => {
|
||||
const activeParts = active.filter(p => (p.track || p.role) === role || (!p.track && !p.role && (role === 'positive' ? p.positive : p.negative)));
|
||||
const activeAssembled = assemblePromptBlocks(activeParts, role);
|
||||
const rawFieldText = String(plan[role] || '').trim();
|
||||
|
||||
if (plan.version >= 2) {
|
||||
return [role, rawFieldText || activeAssembled];
|
||||
}
|
||||
|
||||
if (activeParts.length > 0 && rawFieldText) {
|
||||
const activeJoinedNewline = activeParts.map(p => String(p.content ?? p[role] ?? '').trim()).filter(Boolean).join('\n');
|
||||
if (rawFieldText === activeAssembled || rawFieldText === activeJoinedNewline) {
|
||||
return [role, rawFieldText];
|
||||
}
|
||||
}
|
||||
|
||||
const partsText = activeParts.map(part => part[role] || part.content || '').filter(Boolean);
|
||||
return [role, [...partsText, rawFieldText].filter(Boolean).join('\n')];
|
||||
}));
|
||||
}
|
||||
|
||||
const BASE_QUALITY_KEYWORDS = [
|
||||
'masterpiece', 'best quality', 'highly detailed', 'ultra-detailed', '8k', 'hdr',
|
||||
'absurdres', 'highres', 'high resolution', 'perfect anatomy', 'clean background',
|
||||
'worst quality', 'low quality', 'normal quality', 'lowres', 'bad anatomy',
|
||||
'bad hands', 'missing fingers', 'extra digits', 'fewer digits', 'cropped',
|
||||
'jpeg artifacts', 'blurry', 'watermark', 'signature', 'artist name'
|
||||
];
|
||||
|
||||
const TRIGGER_KEYWORDS = [
|
||||
'<lora:', 'trigger:', 'lora:', 'custom dress', 'specific', 'costume', 'outfit'
|
||||
];
|
||||
|
||||
const STYLE_KEYWORDS = [
|
||||
'style', 'lighting', 'cinematic', 'illustration', 'oil painting', 'concept art',
|
||||
'unreal engine', 'octane render', 'vray', 'ray tracing', 'anime', 'photorealistic',
|
||||
'pastel', 'watercolor', 'cyberpunk', 'steampunk', 'glow', 'volumetric'
|
||||
];
|
||||
|
||||
/** Categorize a prompt block based on its keywords and content */
|
||||
export function categorizePromptSnippet(text = '') {
|
||||
const lower = String(text).toLowerCase();
|
||||
if (BASE_QUALITY_KEYWORDS.some(k => lower.includes(k))) return 'base';
|
||||
if (TRIGGER_KEYWORDS.some(k => lower.includes(k))) return 'trigger';
|
||||
if (STYLE_KEYWORDS.some(k => lower.includes(k))) return 'style';
|
||||
return 'subject';
|
||||
}
|
||||
|
||||
/** Smart sort prompt blocks: Same-role blocks first, cross-role blocks at the tail */
|
||||
export function smartSortPromptBlocks(blocks = [], trackRole = 'positive') {
|
||||
const priority = {
|
||||
base: 0,
|
||||
style: 1,
|
||||
subject: 2,
|
||||
trigger: 3,
|
||||
};
|
||||
return [...blocks].sort((a, b) => {
|
||||
const aCross = (a.role && a.role !== trackRole) ? 1 : 0;
|
||||
const bCross = (b.role && b.role !== trackRole) ? 1 : 0;
|
||||
if (aCross !== bCross) {
|
||||
return aCross - bCross;
|
||||
}
|
||||
|
||||
const catA = a.category || categorizePromptSnippet(a.content || a[trackRole] || '');
|
||||
const catB = b.category || categorizePromptSnippet(b.content || b[trackRole] || '');
|
||||
const scoreA = priority[catA] ?? 2;
|
||||
const scoreB = priority[catB] ?? 2;
|
||||
return scoreA - scoreB;
|
||||
});
|
||||
}
|
||||
|
||||
/** Cleanly join prompt blocks with commas and proper spacing */
|
||||
export function assemblePromptBlocks(blocks = [], role = 'positive') {
|
||||
return blocks
|
||||
.filter(b => b && b.enabled !== false)
|
||||
.map(b => {
|
||||
const raw = String(b.content ?? b[role] ?? '').trim();
|
||||
// remove trailing/leading commas from snippet
|
||||
return raw.replace(/^[,,\s]+|[,,\s]+$/g, '');
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(',\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Losslessly converts any prompt plan (legacy v1 with dual-role blocks or loose text, or v2)
|
||||
* into the standardized workbench draft representation with role isolation.
|
||||
*/
|
||||
export function planToWorkbenchDraft(planData = {}, defaultName = '') {
|
||||
const rawParts = Array.isArray(planData.parts) ? planData.parts : [];
|
||||
const convertedBlocks = [];
|
||||
const makeId = (prefix, idx) => `${prefix}_${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
rawParts.forEach((part, idx) => {
|
||||
if (!part) return;
|
||||
const name = String(part.name || part.title || '').trim();
|
||||
const enabled = part.enabled !== false;
|
||||
const category = part.category || 'general';
|
||||
|
||||
const hasPos = typeof part.positive === 'string' && part.positive.trim().length > 0;
|
||||
const hasNeg = typeof part.negative === 'string' && part.negative.trim().length > 0;
|
||||
|
||||
if (hasPos && hasNeg) {
|
||||
convertedBlocks.push({
|
||||
id: part.id ? `${part.id}_pos` : makeId('blk_pos', idx),
|
||||
title: name ? `${name} (Pos)` : 'Positive',
|
||||
content: part.positive.trim(),
|
||||
role: 'positive',
|
||||
category: category === 'general' ? categorizePromptSnippet(part.positive) : category,
|
||||
enabled,
|
||||
});
|
||||
convertedBlocks.push({
|
||||
id: part.id ? `${part.id}_neg` : makeId('blk_neg', idx),
|
||||
title: name ? `${name} (Neg)` : 'Negative',
|
||||
content: part.negative.trim(),
|
||||
role: 'negative',
|
||||
category: category === 'general' ? 'base' : category,
|
||||
enabled,
|
||||
});
|
||||
} else if (hasNeg || part.role === 'negative') {
|
||||
const content = String(part.content ?? part.negative ?? '').trim();
|
||||
if (content) {
|
||||
convertedBlocks.push({
|
||||
id: part.id || makeId('blk', idx),
|
||||
title: name || 'Negative',
|
||||
content,
|
||||
role: 'negative',
|
||||
category: category === 'general' ? 'base' : category,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const content = String(part.content ?? part.positive ?? '').trim();
|
||||
if (content) {
|
||||
convertedBlocks.push({
|
||||
id: part.id || makeId('blk', idx),
|
||||
title: name || 'Positive',
|
||||
content,
|
||||
role: 'positive',
|
||||
category: category === 'general' ? categorizePromptSnippet(content) : category,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check for legacy trailing unassigned text (for legacy v1 plans)
|
||||
if (!planData.version || planData.version < 2) {
|
||||
['positive', 'negative'].forEach(role => {
|
||||
const rawText = String(planData[role] || '').trim();
|
||||
if (!rawText) return;
|
||||
|
||||
const existingRoleBlocks = convertedBlocks.filter(b => b.role === role);
|
||||
const assembledRoleText = assemblePromptBlocks(existingRoleBlocks, role);
|
||||
const joinedRoleText = existingRoleBlocks.map(b => b.content).join('\n');
|
||||
|
||||
if (rawText !== assembledRoleText && rawText !== joinedRoleText) {
|
||||
if (existingRoleBlocks.length === 0) {
|
||||
convertedBlocks.push({
|
||||
id: makeId(`blk_${role}_init`, convertedBlocks.length),
|
||||
title: role === 'positive' ? '正向提示词' : '负向提示词',
|
||||
content: rawText,
|
||||
role,
|
||||
category: role === 'positive' ? categorizePromptSnippet(rawText) : 'base',
|
||||
enabled: true,
|
||||
});
|
||||
} else {
|
||||
let trailing = rawText;
|
||||
if (trailing.startsWith(assembledRoleText)) {
|
||||
trailing = trailing.slice(assembledRoleText.length).replace(/^[,,\n\s]+/, '').trim();
|
||||
} else if (trailing.startsWith(joinedRoleText)) {
|
||||
trailing = trailing.slice(joinedRoleText.length).replace(/^[,,\n\s]+/, '').trim();
|
||||
}
|
||||
if (trailing) {
|
||||
convertedBlocks.push({
|
||||
id: makeId(`blk_${role}_tail`, convertedBlocks.length),
|
||||
title: role === 'positive' ? '附加正向词' : '附加负向词',
|
||||
content: trailing,
|
||||
role,
|
||||
category: role === 'positive' ? categorizePromptSnippet(trailing) : 'base',
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const draft = {
|
||||
name: String(planData.name || defaultName || '').trim(),
|
||||
tags: Array.isArray(planData.tags) ? planData.tags : [],
|
||||
plan: {
|
||||
version: 2,
|
||||
parts: convertedBlocks,
|
||||
positive: assemblePromptBlocks(convertedBlocks.filter(p => p.role === 'positive'), 'positive'),
|
||||
negative: assemblePromptBlocks(convertedBlocks.filter(p => p.role === 'negative'), 'negative'),
|
||||
},
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a workbench draft into the standard backend-storable prompt plan object.
|
||||
*/
|
||||
export function workbenchDraftToSavedPlan(draft) {
|
||||
const parts = Array.isArray(draft?.plan?.parts) ? draft.plan.parts : [];
|
||||
const posParts = parts.filter(p => p.role === 'positive');
|
||||
const negParts = parts.filter(p => p.role === 'negative');
|
||||
|
||||
return {
|
||||
name: String(draft.name || '').trim(),
|
||||
tags: Array.isArray(draft.tags) ? draft.tags : [],
|
||||
plan: {
|
||||
version: 2,
|
||||
parts: parts.map(p => ({
|
||||
name: String(p.title || p.name || '').trim().slice(0, 120),
|
||||
positive: p.role === 'positive' ? String(p.content || '').trim() : '',
|
||||
negative: p.role === 'negative' ? String(p.content || '').trim() : '',
|
||||
category: p.category || (p.role === 'negative' ? 'base' : categorizePromptSnippet(p.content)),
|
||||
enabled: p.enabled !== false,
|
||||
})),
|
||||
positive: assemblePromptBlocks(posParts, 'positive'),
|
||||
negative: assemblePromptBlocks(negParts, 'negative'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { loadMaterialPrompts } from './material_prompt_data.js';
|
||||
import { categorizePromptSnippet } from './prompt_composition.js';
|
||||
import { jsonResponse } from './ui_dom.js';
|
||||
|
||||
// A complete snapshot is required before replacing library-owned cards.
|
||||
export async function loadPromptSourceCards(signal) {
|
||||
const cards = [];
|
||||
const filenames = new Set();
|
||||
let page = 1;
|
||||
let pages = 1;
|
||||
do {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetch(`/anomalous/materials?category=prompts&limit=100&page=${page}`, { signal });
|
||||
const payload = await jsonResponse(response, 'materialLoadError');
|
||||
if (payload.status !== 'success' || !Array.isArray(payload.materials)) throw new Error('materialLoadError');
|
||||
pages = Number(payload.pages) || 1;
|
||||
for (const item of payload.materials) {
|
||||
signal.throwIfAborted();
|
||||
if (filenames.has(item.filename)) continue;
|
||||
filenames.add(item.filename);
|
||||
const prompts = await loadMaterialPrompts(item.filename, signal);
|
||||
for (const role of ['positive', 'negative']) {
|
||||
const content = prompts[role]?.trim();
|
||||
if (!content) continue;
|
||||
cards.push({
|
||||
id: `mat_${role}_${item.filename}`, filename: item.filename,
|
||||
title: item.name || item.filename, content, role,
|
||||
category: role === 'negative' ? 'base' : categorizePromptSnippet(content),
|
||||
persisted: true, sourceKind: 'material',
|
||||
});
|
||||
}
|
||||
}
|
||||
page++;
|
||||
} while (page <= pages);
|
||||
signal.throwIfAborted();
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function mergePromptSourceCards(existing, incoming) {
|
||||
const oldIds = new Set(existing.filter(card => card.sourceKind === 'material').map(card => card.id));
|
||||
const local = existing.filter(card => card.sourceKind !== 'material');
|
||||
const unique = [...new Map(incoming.map(card => [card.id, card])).values()];
|
||||
const added = unique.filter(card => !oldIds.has(card.id)).length;
|
||||
existing.splice(0, existing.length, ...local, ...unique);
|
||||
return added;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { categorizePromptSnippet, assemblePromptBlocks } from './prompt_composition.js';
|
||||
export const newDraft = () => ({
|
||||
name: '',
|
||||
tags: [],
|
||||
plan: {
|
||||
version: 2,
|
||||
parts: [],
|
||||
positive: '',
|
||||
negative: '',
|
||||
},
|
||||
});
|
||||
|
||||
export const CATEGORY_META = {
|
||||
base: { zh: '通用底模', en: 'Base Quality', color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.15)', border: 'rgba(56, 189, 248, 0.4)' },
|
||||
style: { zh: '风格氛围', en: 'Art Style', color: '#c084fc', bg: 'rgba(192, 132, 252, 0.15)', border: 'rgba(192, 132, 252, 0.4)' },
|
||||
subject: { zh: '主体内容', en: 'Subject', color: '#4ade80', bg: 'rgba(74, 222, 128, 0.15)', border: 'rgba(74, 222, 128, 0.4)' },
|
||||
trigger: { zh: 'LoRA/触发', en: 'LoRA / Trigger', color: '#fb923c', bg: 'rgba(251, 146, 60, 0.15)', border: 'rgba(251, 146, 60, 0.4)' },
|
||||
};
|
||||
|
||||
// Built-in starter prompt cards for left source deck
|
||||
export const STARTER_SOURCE_PROMPTS = [
|
||||
{
|
||||
id: 'preset_base_quality',
|
||||
title: '画质底模词 (Quality Base)',
|
||||
content: 'masterpiece, best quality, highly detailed, ultra-detailed, 8k, hdr, absurdres',
|
||||
role: 'positive',
|
||||
category: 'base',
|
||||
},
|
||||
{
|
||||
id: 'preset_real_detail',
|
||||
title: '写实与材质增强 (Realistic Detail)',
|
||||
content: 'highres, realistic skin texture, sharp focus, subsurface scattering, 35mm photograph',
|
||||
role: 'positive',
|
||||
category: 'base',
|
||||
},
|
||||
{
|
||||
id: 'preset_cinematic_light',
|
||||
title: '电影胶片光影 (Cinematic Lighting)',
|
||||
content: 'cinematic lighting, dramatic shadows, soft volumetric glow, ray tracing, atmospheric',
|
||||
role: 'positive',
|
||||
category: 'style',
|
||||
},
|
||||
{
|
||||
id: 'preset_anime_cyber',
|
||||
title: '赛博霓虹风 (Cyberpunk Neon)',
|
||||
content: 'anime style, cyberpunk aesthetic, vibrant neon reflections, futuristic metropolis backdrop',
|
||||
role: 'positive',
|
||||
category: 'style',
|
||||
},
|
||||
{
|
||||
id: 'preset_girl_face',
|
||||
title: '美少女面部特写 (Portrait 1girl)',
|
||||
content: '1girl, beautiful detailed expressive eyes, smile, delicate face, soft wind-blown hair',
|
||||
role: 'positive',
|
||||
category: 'subject',
|
||||
},
|
||||
{
|
||||
id: 'preset_neg_filter',
|
||||
title: '通用负向过滤词 (Negative Base Filter)',
|
||||
content: 'worst quality, low quality, normal quality, lowres, bad anatomy, bad hands, missing fingers, extra digits, cropped, blurry, watermark',
|
||||
role: 'negative',
|
||||
category: 'base',
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeBlock(part, index = 0) {
|
||||
const id = part.id || `blk_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const role = part.role || (part.negative && !part.positive ? 'negative' : 'positive');
|
||||
const track = part.track || role;
|
||||
const content = String(part.content ?? (role === 'positive' ? part.positive : part.negative) ?? '').trim();
|
||||
let category = part.category;
|
||||
if (!CATEGORY_META[category]) {
|
||||
category = categorizePromptSnippet(content);
|
||||
}
|
||||
const meta = CATEGORY_META[category] || CATEGORY_META.subject;
|
||||
const defaultTitle = (window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en) + (index ? ` #${index + 1}` : '');
|
||||
return {
|
||||
id,
|
||||
title: part.name || part.title || defaultTitle,
|
||||
content,
|
||||
role,
|
||||
track,
|
||||
category,
|
||||
enabled: part.enabled !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncDraftSynthesizedText(draft) {
|
||||
if (!draft || !draft.plan) return;
|
||||
draft.plan.parts ||= [];
|
||||
const posParts = draft.plan.parts.filter(p => (p.track || p.role) === 'positive');
|
||||
const negParts = draft.plan.parts.filter(p => (p.track || p.role) === 'negative');
|
||||
draft.plan.positive = assemblePromptBlocks(posParts, 'positive');
|
||||
draft.plan.negative = assemblePromptBlocks(negParts, 'negative');
|
||||
}
|
||||
|
||||
// Full-text edits replace the enabled blocks of one role. Keep disabled blocks
|
||||
// and the other role intact; synthesized fields are always derived from parts.
|
||||
export function replaceDraftRoleText(draft, role, content) {
|
||||
const parts = draft.plan.parts || [];
|
||||
const first = parts.findIndex(part => (part.track || part.role) === role && part.enabled !== false);
|
||||
const replacement = content.trim() ? normalizeBlock({ content, role, track: role }) : null;
|
||||
const next = [];
|
||||
parts.forEach((part, index) => {
|
||||
if (index === first && replacement) next.push(replacement);
|
||||
if ((part.track || part.role) !== role || part.enabled === false) next.push(part);
|
||||
});
|
||||
if (first < 0 && replacement) next.push(replacement);
|
||||
draft.plan.parts = next;
|
||||
syncDraftSynthesizedText(draft);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { mergeRecipeHashRecords } from './recipe_provenance.js';
|
||||
|
||||
function cloneJson(value) {
|
||||
if (value === undefined) return undefined;
|
||||
@@ -272,6 +273,8 @@ export function appendRecipeToCanvas(recipe) {
|
||||
const offsetX = targetBounds ? targetBounds.maxX + 140 - sourceBounds.minX : 40 - sourceBounds.minX;
|
||||
const offsetY = targetBounds ? targetBounds.minY - sourceBounds.minY : 40 - sourceBounds.minY;
|
||||
const insertedGroups = [];
|
||||
const hadHashRecords = Boolean(graph.extra && Object.prototype.hasOwnProperty.call(graph.extra, 'anomalous_hashes'));
|
||||
const previousHashRecords = cloneJson(graph.extra?.anomalous_hashes);
|
||||
|
||||
graph.beforeChange?.();
|
||||
try {
|
||||
@@ -320,15 +323,22 @@ export function appendRecipeToCanvas(recipe) {
|
||||
graph.add(group);
|
||||
insertedGroups.push(group);
|
||||
}
|
||||
const hashRecords = mergeRecipeHashRecords(graph, recipe.workflow, idMap);
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
app.canvas?.selectNodes?.(inserted);
|
||||
app.canvas?.selectItems?.(inserted);
|
||||
return { nodes: inserted.length, links: savedLinks.length, groups: insertedGroups.length };
|
||||
return { nodes: inserted.length, links: savedLinks.length, groups: insertedGroups.length, hashRecords };
|
||||
} catch (error) {
|
||||
for (const group of insertedGroups) graph.remove?.(group);
|
||||
for (const node of inserted) graph.remove?.(node);
|
||||
if (hadHashRecords) {
|
||||
if (!graph.extra || typeof graph.extra !== 'object') graph.extra = {};
|
||||
graph.extra.anomalous_hashes = previousHashRecords;
|
||||
} else if (graph.extra && typeof graph.extra === 'object') {
|
||||
delete graph.extra.anomalous_hashes;
|
||||
}
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
throw error;
|
||||
|
||||
@@ -161,7 +161,13 @@ export function buildRecipeDiff(beforeRecipe, afterRecipe) {
|
||||
before.params?.model_references || [],
|
||||
after.params?.model_references || [],
|
||||
(item, index) => itemKey(item, `model:${index}`),
|
||||
(item) => item?.saved_value,
|
||||
(item) => ({
|
||||
saved_value: item?.saved_value,
|
||||
user_note: item?.user_note || '',
|
||||
identity_status: item?.identity?.status,
|
||||
identity_sha256: item?.identity?.sha256,
|
||||
identity_size: item?.identity?.size,
|
||||
}),
|
||||
(item) => `${item?.category || 'model'} · ${item?.node_title || item?.node_type || 'loader'}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/** Pure helpers for recipe model references and provenance labels. */
|
||||
|
||||
const MODEL_FILE_PATTERN = /\.(?:safetensors|ckpt|pt|bin|sft)$/i;
|
||||
const VERIFIABLE_RECIPE_MODEL_CATEGORIES = new Set([
|
||||
'checkpoint',
|
||||
'lora',
|
||||
'unet',
|
||||
'controlnet',
|
||||
'vae',
|
||||
'text_encoder',
|
||||
'clip_vision',
|
||||
]);
|
||||
|
||||
function nodeType(node) {
|
||||
return String(node?.type || node?.class_type || '').trim();
|
||||
@@ -40,6 +49,34 @@ export function normaliseIdentity(identity) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function workflowIdentity(workflow, nodeId, savedValue) {
|
||||
const hashes = workflow?.extra?.anomalous_hashes;
|
||||
if (!hashes || typeof savedValue !== 'string') return null;
|
||||
const normalized = savedValue.replace(/\\/g, '/');
|
||||
const windowsPath = savedValue.replace(/\//g, '\\');
|
||||
const record = hashes[`${nodeId}_${savedValue}`]
|
||||
|| hashes[`${nodeId}_${normalized}`]
|
||||
|| hashes[`${nodeId}_${windowsPath}`]
|
||||
|| hashes[savedValue]
|
||||
|| hashes[normalized]
|
||||
|| hashes[windowsPath];
|
||||
const sha256 = typeof record === 'string' ? record : record?.hash;
|
||||
if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(sha256)) return null;
|
||||
const identity = {
|
||||
status: 'verified',
|
||||
sha256: sha256.toLowerCase(),
|
||||
provenance: 'workflow snapshot',
|
||||
};
|
||||
if (record?.size !== null && record?.size !== '' && Number.isFinite(Number(record?.size))) {
|
||||
identity.size = Number(record.size);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
export function canVerifyRecipeModelReference(reference) {
|
||||
return VERIFIABLE_RECIPE_MODEL_CATEGORIES.has(String(reference?.category || '').toLowerCase());
|
||||
}
|
||||
|
||||
export function deriveRecipeModelReferences(recipe) {
|
||||
const stored = recipe?.params?.model_references;
|
||||
if (Array.isArray(stored) && stored.length) {
|
||||
@@ -56,6 +93,8 @@ export function deriveRecipeModelReferences(recipe) {
|
||||
for (const [widgetIndex, category, widgetName] of modelSpecs(type)) {
|
||||
const savedValue = values[widgetIndex];
|
||||
if (typeof savedValue !== 'string' || !savedValue.trim()) continue;
|
||||
const identity = workflowIdentity(recipe?.workflow, node?.id, savedValue)
|
||||
|| { status: 'unverified' };
|
||||
references.push({
|
||||
node_id: node?.id ?? null,
|
||||
node_type: type || 'Unknown',
|
||||
@@ -65,7 +104,7 @@ export function deriveRecipeModelReferences(recipe) {
|
||||
saved_value: savedValue,
|
||||
category,
|
||||
base_model: recipe?.params?.baseModel || null,
|
||||
identity: { status: 'unverified' },
|
||||
identity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,17 +465,80 @@ export function captureRecipeDraft(graph) {
|
||||
const links = Array.isArray(workflow?.links)
|
||||
? workflow.links
|
||||
: (workflow?.links && typeof workflow.links === 'object' ? Object.values(workflow.links) : []);
|
||||
const scope = analyseRecipeScope(graph);
|
||||
return {
|
||||
workflow,
|
||||
metadata: extractRecipeMetadata(graph),
|
||||
workflowScope: scope.scope,
|
||||
stats: {
|
||||
nodeCount: nodes.length,
|
||||
linkCount: links.length,
|
||||
groupCount: Array.isArray(workflow?.groups) ? workflow.groups.length : 0,
|
||||
boundaryInputCount: scope.boundaryInputCount,
|
||||
outputNodeCount: scope.outputNodeCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nodeIsActive(node) {
|
||||
return node?.mode !== 2 && node?.mode !== 4;
|
||||
}
|
||||
|
||||
function nodeIsOutput(node) {
|
||||
return node?.constructor?.nodeData?.output_node === true;
|
||||
}
|
||||
|
||||
function inputIsOptional(node, input) {
|
||||
const nodeData = node?.constructor?.nodeData;
|
||||
const name = String(input?.name || '');
|
||||
const modern = Array.isArray(nodeData?.inputs)
|
||||
? nodeData.inputs.find((item) => String(item?.name || '') === name)
|
||||
: null;
|
||||
if (modern) return modern.isOptional === true || modern.optional === true;
|
||||
if (nodeData?.input?.optional && Object.prototype.hasOwnProperty.call(nodeData.input.optional, name)) return true;
|
||||
if (nodeData?.input?.required && Object.prototype.hasOwnProperty.call(nodeData.input.required, name)) return false;
|
||||
const hollow = globalThis.LiteGraph?.HOLLOW_CIRCLE_SHAPE;
|
||||
return hollow !== undefined && input?.shape === hollow;
|
||||
}
|
||||
|
||||
function inputHasWidget(node, input) {
|
||||
if (input?.widget || input?.widgetId) return true;
|
||||
const name = String(input?.name || '');
|
||||
return Boolean(name && (node?.widgets || []).some((widget) => String(widget?.name || '') === name));
|
||||
}
|
||||
|
||||
function recipeGraphNodes(graph) {
|
||||
const nodes = [];
|
||||
const seen = new Set();
|
||||
for (const node of graph?._nodes || []) {
|
||||
for (const item of [node, ...(node?.getInnerNodes ? node.getInnerNodes(new Map()) : [])]) {
|
||||
if (!item || seen.has(item)) continue;
|
||||
seen.add(item);
|
||||
nodes.push(item);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Classify a live graph as an independently runnable workflow or a reusable fragment. */
|
||||
export function analyseRecipeScope(graph) {
|
||||
const nodes = recipeGraphNodes(graph).filter(nodeIsActive);
|
||||
const outputNodeCount = nodes.filter(nodeIsOutput).length;
|
||||
let boundaryInputCount = 0;
|
||||
for (const node of nodes) {
|
||||
for (const input of node.inputs || []) {
|
||||
if (input?.link !== null && input?.link !== undefined) continue;
|
||||
if (inputHasWidget(node, input) || inputIsOptional(node, input)) continue;
|
||||
boundaryInputCount += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
scope: outputNodeCount > 0 && boundaryInputCount === 0 ? 'complete' : 'partial',
|
||||
outputNodeCount,
|
||||
boundaryInputCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Capture a bounded canvas preview; the optional element makes this testable. */
|
||||
export function captureCanvasThumbnail(canvasEl = null) {
|
||||
const source = canvasEl || document.getElementById('comfy-canvas-element') || document.querySelector('canvas');
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/** UI-neutral helpers for Workflow Recipe model provenance records. */
|
||||
|
||||
const MODEL_FILE_PATTERN = /\.(?:safetensors|ckpt|pt|bin|sft)$/i;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
|
||||
|
||||
function cloneJson(value) {
|
||||
if (value === undefined) return undefined;
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function pathVariants(value) {
|
||||
if (typeof value !== 'string') return [];
|
||||
return [...new Set([
|
||||
value,
|
||||
value.replace(/\\/g, '/'),
|
||||
value.replace(/\//g, '\\'),
|
||||
])];
|
||||
}
|
||||
|
||||
function nodeHashKeys(nodeId, value) {
|
||||
return pathVariants(value).map((variant) => `${nodeId}_${variant}`);
|
||||
}
|
||||
|
||||
function provenanceMap(workflow) {
|
||||
const hashes = workflow?.extra?.anomalous_hashes;
|
||||
return hashes && typeof hashes === 'object' && !Array.isArray(hashes) ? hashes : null;
|
||||
}
|
||||
|
||||
export function findWorkflowHashRecord(workflow, nodeId, value) {
|
||||
const hashes = provenanceMap(workflow);
|
||||
if (!hashes) return null;
|
||||
const keys = [
|
||||
...nodeHashKeys(nodeId, value),
|
||||
...pathVariants(value),
|
||||
];
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(hashes, key)) return cloneJson(hashes[key]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeNodeHashRecord(hashes, nodeId, value, record) {
|
||||
for (const key of nodeHashKeys(nodeId, value)) hashes[key] = cloneJson(record);
|
||||
}
|
||||
|
||||
/** Build node-ID-remapped provenance for appending a partial recipe. */
|
||||
export function remapWorkflowHashRecords(workflow, idMap) {
|
||||
const remapped = {};
|
||||
if (!(idMap instanceof Map) || !provenanceMap(workflow)) return remapped;
|
||||
for (const node of workflow?.nodes || []) {
|
||||
const newId = idMap.get(String(node?.id));
|
||||
if (newId === undefined || !Array.isArray(node?.widgets_values)) continue;
|
||||
for (const value of node.widgets_values) {
|
||||
if (typeof value !== 'string' || !MODEL_FILE_PATTERN.test(value)) continue;
|
||||
const record = findWorkflowHashRecord(workflow, node.id, value);
|
||||
if (record !== null) writeNodeHashRecord(remapped, newId, value, record);
|
||||
}
|
||||
}
|
||||
return remapped;
|
||||
}
|
||||
|
||||
/** Merge remapped recipe provenance into the live graph without deleting host data. */
|
||||
export function mergeRecipeHashRecords(graph, workflow, idMap) {
|
||||
const additions = remapWorkflowHashRecords(workflow, idMap);
|
||||
const keys = Object.keys(additions);
|
||||
if (!keys.length) return 0;
|
||||
if (!graph.extra || typeof graph.extra !== 'object') graph.extra = {};
|
||||
if (!graph.extra.anomalous_hashes || typeof graph.extra.anomalous_hashes !== 'object') {
|
||||
graph.extra.anomalous_hashes = {};
|
||||
}
|
||||
Object.assign(graph.extra.anomalous_hashes, additions);
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one model reference's provenance to its new local dropdown value.
|
||||
* A size-only confirmation deliberately writes an empty hash plus size; it
|
||||
* must never inherit an unproven source hash.
|
||||
*/
|
||||
export function replaceWorkflowModelHashRecord(workflow, nodeId, oldValue, newValue, identity = {}) {
|
||||
if (!workflow || typeof workflow !== 'object' || typeof newValue !== 'string' || !newValue) return false;
|
||||
if (!workflow.extra || typeof workflow.extra !== 'object') workflow.extra = {};
|
||||
if (!workflow.extra.anomalous_hashes || typeof workflow.extra.anomalous_hashes !== 'object') {
|
||||
workflow.extra.anomalous_hashes = {};
|
||||
}
|
||||
const hashes = workflow.extra.anomalous_hashes;
|
||||
for (const key of [...nodeHashKeys(nodeId, oldValue), ...nodeHashKeys(nodeId, newValue)]) delete hashes[key];
|
||||
|
||||
const sha256 = typeof identity.sha256 === 'string' && SHA256_PATTERN.test(identity.sha256)
|
||||
? identity.sha256.toLowerCase()
|
||||
: '';
|
||||
const numericSize = Number(identity.size);
|
||||
const hasSize = Number.isFinite(numericSize) && numericSize > 0;
|
||||
if (!sha256 && !hasSize) return false;
|
||||
writeNodeHashRecord(hashes, nodeId, newValue, {
|
||||
hash: sha256,
|
||||
size: hasSize ? numericSize : '',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { translate } from './locales.js';
|
||||
|
||||
|
||||
const PANEL_ID = 'anomalous-scan-progress';
|
||||
let closeTimer = null;
|
||||
|
||||
|
||||
function ensurePanel() {
|
||||
let panel = document.getElementById(PANEL_ID);
|
||||
if (panel) return panel;
|
||||
|
||||
panel = document.createElement('section');
|
||||
panel.id = PANEL_ID;
|
||||
panel.className = 'anomalous-scan-progress';
|
||||
panel.setAttribute('role', 'status');
|
||||
panel.setAttribute('aria-live', 'polite');
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.className = 'anomalous-scan-progress-title';
|
||||
panel.appendChild(title);
|
||||
|
||||
const track = document.createElement('div');
|
||||
track.className = 'anomalous-scan-progress-track';
|
||||
track.setAttribute('role', 'progressbar');
|
||||
track.setAttribute('aria-valuemin', '0');
|
||||
track.setAttribute('aria-valuemax', '100');
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'anomalous-scan-progress-fill';
|
||||
track.appendChild(fill);
|
||||
panel.appendChild(track);
|
||||
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'anomalous-scan-progress-detail';
|
||||
detail.style.whiteSpace = 'normal';
|
||||
detail.style.lineHeight = '1.4';
|
||||
detail.style.wordBreak = 'break-word';
|
||||
panel.appendChild(detail);
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'anomalous-scan-progress-item';
|
||||
panel.appendChild(item);
|
||||
|
||||
document.body.appendChild(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
|
||||
function progressRatio(status) {
|
||||
const total = Number(status.total) || 0;
|
||||
const current = Math.min(Number(status.current) || 0, total);
|
||||
const folderTotal = Number(status.folder_total) || 0;
|
||||
const folderCurrent = Math.min(Number(status.folder_current) || 0, folderTotal);
|
||||
const fileRatio = total > 0 ? current / total : 0;
|
||||
if (total === 0 && (status.phase === 'preparing' || status.phase === 'enumerating')) return null;
|
||||
if (folderTotal > 0 && folderCurrent > 0) {
|
||||
return Math.min(1, ((folderCurrent - 1) + fileRatio) / folderTotal);
|
||||
}
|
||||
return total > 0 ? fileRatio : null;
|
||||
}
|
||||
|
||||
|
||||
export function updateScanProgress(status, titleText = '') {
|
||||
if (!status || (!status.scanning && !status.interrupted)) return;
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
|
||||
const panel = ensurePanel();
|
||||
panel.classList.remove('is-complete', 'is-error');
|
||||
const title = panel.querySelector('.anomalous-scan-progress-title');
|
||||
const fill = panel.querySelector('.anomalous-scan-progress-fill');
|
||||
const track = panel.querySelector('.anomalous-scan-progress-track');
|
||||
const detail = panel.querySelector('.anomalous-scan-progress-detail');
|
||||
const item = panel.querySelector('.anomalous-scan-progress-item');
|
||||
|
||||
title.textContent = titleText || translate('scanProgressTitle');
|
||||
if (status.interrupted && !status.scanning) {
|
||||
failScanProgress(translate('scanProgressInterrupted'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ratio = progressRatio(status);
|
||||
panel.setAttribute('aria-busy', 'true');
|
||||
fill.classList.toggle('is-indeterminate', ratio === null);
|
||||
fill.style.width = ratio === null ? '35%' : `${Math.max(2, Math.round(ratio * 100))}%`;
|
||||
if (ratio === null) track.removeAttribute('aria-valuenow');
|
||||
else track.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
|
||||
|
||||
const folderTotal = Number(status.folder_total) || 0;
|
||||
const folderCurrent = Number(status.folder_current) || 0;
|
||||
if (folderTotal > 0) {
|
||||
detail.textContent = translate('scanProgressFolder', {
|
||||
current: folderCurrent,
|
||||
total: folderTotal,
|
||||
folder: status.folder || '',
|
||||
});
|
||||
} else if (status.phase === 'enumerating' || status.phase === 'preparing') {
|
||||
detail.textContent = translate('scanProgressPreparing');
|
||||
} else {
|
||||
detail.textContent = translate('sidebarScanning');
|
||||
}
|
||||
|
||||
const total = Number(status.total) || 0;
|
||||
item.textContent = total > 0
|
||||
? translate('scanProgressModel', {
|
||||
current: Number(status.current) || 0,
|
||||
total,
|
||||
filename: status.filename || '',
|
||||
})
|
||||
: translate('scanProgressCounting');
|
||||
if (status.recovered && total === 0) item.textContent = translate('scanProgressInterrupted');
|
||||
if (status.error) item.textContent = translate('scanProgressLastError', { error: status.error });
|
||||
}
|
||||
|
||||
|
||||
function closeLater(panel) {
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
closeTimer = setTimeout(() => {
|
||||
if (typeof panel.remove === 'function') panel.remove();
|
||||
else if (panel.parentNode) panel.parentNode.removeChild(panel);
|
||||
closeTimer = null;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
|
||||
export function finishScanProgress(detailText = '') {
|
||||
const panel = ensurePanel();
|
||||
panel.classList.remove('is-error');
|
||||
panel.classList.add('is-complete');
|
||||
panel.setAttribute('aria-busy', 'false');
|
||||
panel.querySelector('.anomalous-scan-progress-title').textContent = translate('sidebarScanDone');
|
||||
const fill = panel.querySelector('.anomalous-scan-progress-fill');
|
||||
fill.classList.remove('is-indeterminate');
|
||||
fill.style.width = '100%';
|
||||
panel.querySelector('.anomalous-scan-progress-track').setAttribute('aria-valuenow', '100');
|
||||
if (detailText) {
|
||||
panel.querySelector('.anomalous-scan-progress-detail').textContent = detailText;
|
||||
} else {
|
||||
const currentDetail = panel.querySelector('.anomalous-scan-progress-detail').textContent;
|
||||
if (!currentDetail || currentDetail === translate('sidebarScanning') || currentDetail === translate('scanProgressPreparing')) {
|
||||
panel.querySelector('.anomalous-scan-progress-detail').textContent = translate('scanProgressFinished');
|
||||
}
|
||||
}
|
||||
panel.querySelector('.anomalous-scan-progress-item').textContent = '';
|
||||
closeLater(panel);
|
||||
}
|
||||
|
||||
|
||||
export function failScanProgress(message) {
|
||||
const panel = ensurePanel();
|
||||
panel.classList.remove('is-complete');
|
||||
panel.classList.add('is-error');
|
||||
panel.setAttribute('aria-busy', 'false');
|
||||
panel.querySelector('.anomalous-scan-progress-title').textContent = translate('scanProgressProblem');
|
||||
const fill = panel.querySelector('.anomalous-scan-progress-fill');
|
||||
fill.classList.remove('is-indeterminate');
|
||||
fill.style.width = '100%';
|
||||
panel.querySelector('.anomalous-scan-progress-track').removeAttribute('aria-valuenow');
|
||||
panel.querySelector('.anomalous-scan-progress-detail').textContent = message || translate('scanProgressInterrupted');
|
||||
panel.querySelector('.anomalous-scan-progress-item').textContent = translate('scanProgressRetryReady');
|
||||
closeLater(panel);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
export const DEFAULT_BROWSER_SHORTCUT = Object.freeze({
|
||||
key: 'm',
|
||||
ctrl: true,
|
||||
shift: true
|
||||
});
|
||||
|
||||
export const DEFAULT_MATERIALS_SHORTCUT = Object.freeze({
|
||||
key: 'l',
|
||||
ctrl: true,
|
||||
shift: true
|
||||
});
|
||||
|
||||
function normalizedShortcutKey(value) {
|
||||
const key = String(value || '').toLowerCase();
|
||||
return key.startsWith('key') && key.length === 4 ? key.slice(3) : key;
|
||||
}
|
||||
|
||||
export function shortcutEventMatches(event, combo) {
|
||||
if (!event || !combo || event.isComposing) return false;
|
||||
return normalizedShortcutKey(event.key || event.code) === normalizedShortcutKey(combo.key)
|
||||
&& !!event.ctrlKey === !!combo.ctrl
|
||||
&& !!event.shiftKey === !!combo.shift
|
||||
&& !!event.altKey === !!combo.alt
|
||||
&& !!event.metaKey === !!combo.meta;
|
||||
}
|
||||
|
||||
export function installDeferredShortcutFallback({ target, getCombo, isHandled, onFallback, onError = console.error }) {
|
||||
const handler = event => {
|
||||
if (event.repeat || !shortcutEventMatches(event, getCombo?.())) return;
|
||||
queueMicrotask(() => {
|
||||
if (isHandled?.()) return;
|
||||
Promise.resolve(onFallback?.()).catch(onError);
|
||||
});
|
||||
};
|
||||
target.addEventListener('keydown', handler, true);
|
||||
return () => target.removeEventListener('keydown', handler, true);
|
||||
}
|
||||
|
||||
export function formatKeyCombo(combo) {
|
||||
if (!combo) return '';
|
||||
if (typeof combo.getKeySequences === 'function') {
|
||||
return combo.getKeySequences().join(' + ');
|
||||
}
|
||||
|
||||
const keys = [];
|
||||
if (combo.ctrl || combo.meta) keys.push('Ctrl');
|
||||
if (combo.alt) keys.push('Alt');
|
||||
if (combo.shift) keys.push('Shift');
|
||||
if (combo.key) {
|
||||
keys.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key);
|
||||
}
|
||||
return keys.join(' + ');
|
||||
}
|
||||
|
||||
function getCommand(app, commandId) {
|
||||
return app.extensionManager?.command?.commands?.find((command) => command.id === commandId);
|
||||
}
|
||||
|
||||
function waitForElement(selector, timeout = 1500) {
|
||||
return new Promise((resolve) => {
|
||||
const existing = document.querySelector(selector);
|
||||
if (existing) {
|
||||
resolve(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return;
|
||||
observer.disconnect();
|
||||
clearTimeout(timer);
|
||||
resolve(element);
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
const timer = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(null);
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
if (setter) setter.call(input, value);
|
||||
else input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
export async function openNativeKeybindingEditor(commandId) {
|
||||
const settingsDialog = document.querySelector('[data-testid="settings-dialog"]');
|
||||
const keybindingNav = settingsDialog?.querySelector('[data-nav-id="keybinding"]');
|
||||
if (!(keybindingNav instanceof HTMLElement)) return false;
|
||||
|
||||
keybindingNav.click();
|
||||
const searchInput = await waitForElement('#keybinding-panel-header input');
|
||||
if (!(searchInput instanceof HTMLInputElement)) return false;
|
||||
|
||||
setInputValue(searchInput, commandId);
|
||||
const commandTitle = await waitForElement(`.keybinding-panel [title="${CSS.escape(commandId)}"]`);
|
||||
const commandRow = commandTitle?.closest('tr');
|
||||
if (!commandRow) return false;
|
||||
|
||||
const actionButtons = commandRow.querySelectorAll('.actions button');
|
||||
const bindingCellText = commandRow.children[1]?.textContent?.trim();
|
||||
const canOpenRecorderDirectly = actionButtons.length >= 4 || bindingCellText === '-';
|
||||
if (canOpenRecorderDirectly && actionButtons[0] instanceof HTMLButtonElement) {
|
||||
actionButtons[0].click();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createShortcutSettingControl({ app, commandId, translate, settingLabelKey = 'mainShortcutSetting' }) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'anomalous-shortcut-setting';
|
||||
|
||||
const current = document.createElement('kbd');
|
||||
current.className = 'anomalous-shortcut-current';
|
||||
const combo = getCommand(app, commandId)?.keybinding?.combo;
|
||||
current.textContent = formatKeyCombo(combo) || translate('mainShortcutUnassigned');
|
||||
|
||||
const customize = document.createElement('button');
|
||||
customize.type = 'button';
|
||||
customize.className = 'anomalous-shortcut-customize';
|
||||
customize.textContent = translate('mainShortcutCustomize');
|
||||
customize.addEventListener('click', async () => {
|
||||
customize.disabled = true;
|
||||
const opened = await openNativeKeybindingEditor(commandId);
|
||||
customize.disabled = false;
|
||||
if (!opened) {
|
||||
app.extensionManager?.toast?.add?.({
|
||||
severity: 'warn',
|
||||
summary: translate(settingLabelKey),
|
||||
detail: translate('mainShortcutOpenFailed'),
|
||||
life: 3500
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
container.append(current, customize);
|
||||
return container;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* shortcut_layout.js - State and Persistence for Customizable Shortcut Bar
|
||||
*
|
||||
* Implements schema validation, capacity limits, pinning, unpinning,
|
||||
* reordering and reset logic with localStorage persistence.
|
||||
*/
|
||||
|
||||
import { CATALOG_TOOLS } from './tool_registry.js';
|
||||
|
||||
export const SHORTCUT_STORAGE_KEY = 'anomalous_shortcut_layout_v1';
|
||||
export const SHORTCUT_SCHEMA_VERSION = 1;
|
||||
export const MAX_PINNED_SHORTCUTS = 4;
|
||||
|
||||
export const DEFAULT_PINNED_SHORTCUTS = Object.freeze([
|
||||
'scan',
|
||||
'doctor',
|
||||
'assistant',
|
||||
'materials',
|
||||
]);
|
||||
|
||||
// In-memory fallback if localStorage fails or is blocked
|
||||
let sessionMemoryLayout = null;
|
||||
|
||||
const VALID_CATALOG_IDS = new Set(CATALOG_TOOLS.map(t => t.id));
|
||||
|
||||
export function isValidCatalogToolId(id) {
|
||||
return VALID_CATALOG_IDS.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an array of tool IDs:
|
||||
* - Drops invalid IDs, anchor IDs and non-strings
|
||||
* - Deduplicates preserving first occurrence
|
||||
* - Truncates to MAX_PINNED_SHORTCUTS
|
||||
*/
|
||||
export function normalizePinnedIds(ids) {
|
||||
if (!Array.isArray(ids)) return [...DEFAULT_PINNED_SHORTCUTS];
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const id of ids) {
|
||||
if (typeof id === 'string' && VALID_CATALOG_IDS.has(id) && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
result.push(id);
|
||||
if (result.length >= MAX_PINNED_SHORTCUTS) break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load shortcut layout from storage.
|
||||
* Explicit empty array is valid and preserved.
|
||||
*/
|
||||
export function loadShortcutLayout(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
if (sessionMemoryLayout !== null && !storage) {
|
||||
return [...sessionMemoryLayout];
|
||||
}
|
||||
|
||||
if (!storage) {
|
||||
return sessionMemoryLayout !== null ? [...sessionMemoryLayout] : [...DEFAULT_PINNED_SHORTCUTS];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = storage.getItem(SHORTCUT_STORAGE_KEY);
|
||||
if (raw === null || raw === undefined) {
|
||||
return [...DEFAULT_PINNED_SHORTCUTS];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || parsed.schemaVersion !== SHORTCUT_SCHEMA_VERSION || !Array.isArray(parsed.pinned)) {
|
||||
return [...DEFAULT_PINNED_SHORTCUTS];
|
||||
}
|
||||
return normalizePinnedIds(parsed.pinned);
|
||||
} catch (err) {
|
||||
console.warn('[AMB] Failed to read shortcut layout from storage, resetting to defaults:', err);
|
||||
sessionMemoryLayout = [...DEFAULT_PINNED_SHORTCUTS];
|
||||
return [...DEFAULT_PINNED_SHORTCUTS];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist shortcut layout to storage and memory.
|
||||
*/
|
||||
export function saveShortcutLayout(pinnedIds, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
const normalized = normalizePinnedIds(pinnedIds);
|
||||
sessionMemoryLayout = [...normalized];
|
||||
|
||||
if (!storage) {
|
||||
return { success: true, layout: normalized, memoryOnly: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
schemaVersion: SHORTCUT_SCHEMA_VERSION,
|
||||
pinned: normalized,
|
||||
});
|
||||
storage.setItem(SHORTCUT_STORAGE_KEY, payload);
|
||||
return { success: true, layout: normalized, memoryOnly: false };
|
||||
} catch (err) {
|
||||
console.warn('[AMB] Failed to write shortcut layout to storage, saved to session memory:', err);
|
||||
return { success: true, layout: normalized, memoryOnly: true, error: err };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a tool to the shortcut bar.
|
||||
* If already pinned, reorder to targetIndex.
|
||||
* If not pinned and at capacity, rejects with 'capacity_full'.
|
||||
*/
|
||||
export function pinTool(toolId, targetIndex = -1, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
if (!isValidCatalogToolId(toolId)) {
|
||||
return { success: false, reason: 'unknown_tool', layout: loadShortcutLayout(storage) };
|
||||
}
|
||||
|
||||
const current = loadShortcutLayout(storage);
|
||||
const existingIndex = current.indexOf(toolId);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Already pinned: reorder to target position
|
||||
current.splice(existingIndex, 1);
|
||||
const insertAt = (targetIndex >= 0 && targetIndex <= current.length) ? targetIndex : current.length;
|
||||
current.splice(insertAt, 0, toolId);
|
||||
saveShortcutLayout(current, storage);
|
||||
return { success: true, reordered: true, layout: current };
|
||||
}
|
||||
|
||||
if (current.length >= MAX_PINNED_SHORTCUTS) {
|
||||
return { success: false, reason: 'capacity_full', layout: current };
|
||||
}
|
||||
|
||||
const insertAt = (targetIndex >= 0 && targetIndex <= current.length) ? targetIndex : current.length;
|
||||
current.splice(insertAt, 0, toolId);
|
||||
saveShortcutLayout(current, storage);
|
||||
return { success: true, reordered: false, layout: current };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpin a tool from the shortcut bar (returns it to toolbox only).
|
||||
*/
|
||||
export function unpinTool(toolId, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
const current = loadShortcutLayout(storage);
|
||||
const index = current.indexOf(toolId);
|
||||
if (index === -1) {
|
||||
return { success: true, changed: false, layout: current };
|
||||
}
|
||||
current.splice(index, 1);
|
||||
saveShortcutLayout(current, storage);
|
||||
return { success: true, changed: true, layout: current };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder an item in the shortcut bar.
|
||||
*/
|
||||
export function reorderShortcut(fromIndex, toIndex, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
const current = loadShortcutLayout(storage);
|
||||
if (fromIndex < 0 || fromIndex >= current.length || toIndex < 0 || toIndex >= current.length || fromIndex === toIndex) {
|
||||
return { success: false, layout: current };
|
||||
}
|
||||
const [item] = current.splice(fromIndex, 1);
|
||||
current.splice(toIndex, 0, item);
|
||||
saveShortcutLayout(current, storage);
|
||||
return { success: true, layout: current };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset shortcut layout to defaults.
|
||||
*/
|
||||
export function resetShortcutLayout(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
saveShortcutLayout(DEFAULT_PINNED_SHORTCUTS, storage);
|
||||
return { success: true, layout: [...DEFAULT_PINNED_SHORTCUTS] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is currently pinned.
|
||||
*/
|
||||
export function isToolPinned(toolId, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
const current = loadShortcutLayout(storage);
|
||||
return current.includes(toolId);
|
||||
}
|
||||