build: remove .agents and tests directories from git tracking and ignore them

This commit is contained in:
DemonGatanjieu
2026-09-19 20:58:23 +08:00
parent 4e2110ad4b
commit dd1bb8639c
35 changed files with 1 additions and 3748 deletions
@@ -1,99 +0,0 @@
# 素材库核心逻辑与验收基线
**更新**:2026-09-09,入口迁移、提示词搭配与节点应用融合。架构契约见 `docs/architecture/material-library.md`。
## 当前能做什么
素材库是人工精选和复用层,保留配方、提示词笔记和节点助手的直接使用入口。
- 从主图库或配方图库的生成 PNG 保存图片与完整工作流,也可保存单个或多个节点参数。
- 按名称、标签、节点类型搜索;按类型和标签筛选;每页 48 条。
- 在详情修改名称和标签;相同图片、素材类型和节点范围再次保存时提示重复,可确认另存。
- 浏览模式点击卡片进入详情;选中单节点后的应用模式点击卡片写入匹配参数,多组候选需选择。
- 完整素材可打开原始工作流;节点助手可按精确 node.type 应用节点参数。
## 真实数据类型
| kind | 内容与能力 |
| --- | --- |
| `image_workflow_snapshot` | 图片、完整 UI workflow、模型引用;可打开工作流或复用节点参数 |
| `image_node_selection` | 图片和完整来源依据保存在本地,只展示和复用已选节点;不能打开隐藏工作流 |
| `recipe_parameter_selection` | 配方或参数方案的选中节点,通过节点助手复用 |
| `prompt_note_bundle` | 提示词、翻译、配套模型;可载入新笔记 |
| `prompt_text` | 提示词与翻译,不含模型;可复制或载入新笔记 |
前两类保留原图,其他类型没有图片资产。`reference_image` 目前只代表可查看保存的图片,尚不能复制到 input 或配置 LoadImage。
配方可保存全部、单节点或多选参数;提示词笔记可保存整份组合或仅提示词。保存后可直接“查看素材”。笔记素材能载入为新笔记,不覆盖原笔记;其中文字段是翻译,不是负面提示词。
图片来源需要带 UI workflow 的 PNG。只有 API prompt 的图片、损坏元数据、越界路径和超大图片会被拒绝。
## 保存与本地数据
数据在 ComfyUI 用户目录的 `workflows/anomalous_materials/`:JSON 记录配套 `.assets/<素材文件名去掉后缀>/` 下的原始 PNG 与 WebP 预览,不写回插件源码目录。
保存先暂存 JSON 和图片,资产就位后再发布记录。最终记录写入失败会回滚新增资产;名称和标签修改采用原子写入。删除只清理该素材及其私有图片,不影响原始出图。
提示词笔记独立保存在 `workflows/anomalous_notebooks`,旧文件采用可恢复复制并保留原件;素材副本与原笔记各自独立。
## 浏览与性能边界
- 列表只返回摘要;摘要按文件路径、大小、mtime、ctime 缓存,最多 4,096 条,检测文件新增、修改和删除。
- 素材详情请求 `material_full?include_workflow=0`,只返回元信息与所选范围内的完整参数块。提示词节点默认展开,其他节点默认闭合,首次展开才创建参数 DOM;再次展开复用内容。
- “全部展开/收起”根据实际展开状态切换。图片工作台中可勾选的节点卡保持默认闭合。
- 点击“打开完整工作流”才请求 `include_workflow=1`,返回原始 workflow,`node_blocks` 为空,避免两份 widget 值。
- 旧调用不传该参数时,完整素材仍保留旧返回格式;节点参数素材始终不返回隐藏 workflow,显式请求打开时返回 403。
- 后端在工作线程读取、整理和序列化详情;节点选择先过滤再复制参数。磁盘中仍保留完整来源,读取记录时仍需解析它。
- 搜索取消旧请求;返回列表保留筛选并释放详情 DOM。主图库复用短期目录快照,刷新和删除即时失效。
- 图片工作台另有 16 条解析缓存、可取消导航和延迟模型预览查询。
**仍待优化**:节点助手按类型查询尚未分页;重复确认前已暂存图片,另存会重复处理。大量提示词节点、单个长提示词以及用户主动“全部展开”仍可能产生较多 DOM;本轮没有实现虚拟列表。
## 职责边界
- 素材库保存和展示内容,不判断模型身份。
- 素材库与节点助手共用事务性应用,跳过种子;只搬运已有 Hash 证据,不自动全图修复。
- 模型医生负责身份判断:完整文件哈希一致可自动修复,只有大小一致需确认当前节点,证据冲突必须拒绝。
- 文件名、路径、封面、素材名和模型大小不能成为自动身份依据。
- 提示词和动态名称使用文本 DOM,界面文案进入 `locales.js`。
## 关联模块
- `api/materials.py`:存储、摘要、筛选分页、详情响应、资产和节点类型查询。
- `ui_materials.js`:素材列表、筛选、编辑、详情与工作流打开。
- `material_inspector.js`:共享参数展示与惰性渲染。
- `ui_gallery_detail.js`:图片工作台、图片与节点保存。
- `ui_doctor.js`:按当前节点类型复用参数。
## 下一步开发顺序
已完成:配方参数与提示词笔记进入素材库、统一查看入口、主要页面按需展开。
1. 参考图片应用:先确定 input 子目录、重名、精确节点适配与失败回滚。
2. 文件夹、批量管理、导入导出、全文提示词索引和视觉去重留待后续。
默认命名继续沿用:`配方名 · 节点标题/全部参数/选中 N 个模块`;`笔记名`,范围由“提示词笔记/提示词片段”标识。
## 固定冒烟清单
1. 点卡片进详情;点删除或快捷打开不会同时进入详情。
2. 连续搜索取消旧请求,返回列表保留筛选条件。
3. 名称或标签保存失败时显示失败,编辑内容可重试。
4. 2,001 节点样例仅提示词默认展开,普通节点无参数内容 DOM;单节点和全部展开/收起均正常。
5. 详情响应无 workflow;打开完整素材才读取原图,并保留 seed 和模型 Hash。节点参数素材不能请求隐藏工作流。
6. 剪贴板失败有可见提示;再次复制保留原始提示词空白。
7. 从节点助手应用参数仍跳过种子并按目标节点 ID 重映射 Hash。
本轮新增本地回归:`tests/test_material_details.py` 覆盖详情协议、兼容性和选中范围;`tests/material_inspector_lazy.mjs` 覆盖大列表惰性渲染和复制反馈。测试目录继续只供本地 AI 验证。
## 2026-09-09 本轮交接(给 Gemini)
- 左下角现在是独立素材库,不带工作台三个标签,也不预加载笔记;右上角仍是配方/提示词笔记。旧导入导出在素材库“更多”中。
- 提示词面板先做简单版:正负词分别填写,拖入时选“放前面/放后面”。复杂通用/专用分组、排序和节点片段管理先不做;旧方案可读取为合成后的文字。
- 素材库中带节点参数的卡片拖入时替换整组可用参数,保留种子和连线;提示词面板拖入时插入文字。两条路径都可撤销,不自动运行。
- 拖动时窗口临时让出画布,松手后恢复;以实际落点节点为目标。多组参数/多个文本框需选择,不兼容或取消时不改节点。
- 素材、原笔记、已存方案各自独立;草稿只保留到页面刷新,需主动保存。
- Gemini 可调整间距、卡片、分组和视觉层级。保留“当前目标”“浏览/应用模式”“正负词”“撤销”信息;不要加入常驻轮询、全量详情预读或大片模糊动画。
- 本轮本地检查:85 项 Python 测试、6 个 JS 回归脚本及语法/中英文键检查通过。按用户要求未做浏览器验收;真实 ComfyUI 节点兼容、窄屏、焦点与遮罩体验留待集中验收。
- 后端新增路由,集中验收前需重启 ComfyUI。节点助手查询分页、图片素材包进出、参考图应用仍待后续。
-740
View File
@@ -1,740 +0,0 @@
# 📖 AI Lessons — Error & Experience Summary (开发避坑与经验总结)
This document serves as an architectural retrospective and UX diagnostic log for the development of the Anomalous Model Browser.
## 68. Missing Model URLs in Hash Cache & Asynchronous Path Resolution in Model Source Hub
**The Problem**: When opening the Model Source Hub (模型来源统一中控中心), active workflow models showed empty source URL inputs and "已配置: 0 / N", even when valid `.civitai.info` or `.info` sidecars with official download URLs existed on disk. This occurred because `/anomalous/all_hashes` only extracted `{"hash": ..., "size": ...}` and omitted the parsed `"url"`, while the frontend modal opened synchronously without querying `/anomalous/resolve_paths_to_previews`. Furthermore, the single-item auto-detect function tried direct browser `fetch('https://civitai.com/...')` which fails under firewall/CORS conditions instead of probing local sidecars first, and `api_update_metadata` lacked fallback path resolution when called with unknown folder types or relative subpaths from workflow scope.
**The Solution**:
1. Enrich `/anomalous/all_hashes` payload with `"url": meta.get("source_url") or meta.get("civitai_url") or ""` so models in cache immediately provide URLs.
2. In `ui_model_sources.js`, invoke `resolveWorkflowModelsMetadata` upon opening the modal to resolve active models via `/anomalous/resolve_paths_to_previews` in the background, dynamically populating URLs, platform tags, types, and hashes.
3. In `autoDetectModelSource`, prioritize local sidecar and metadata lookup before falling back to Civitai cloud hash API or search.
4. In `api_update_metadata`, normalize path separators and add fallback model resolution via `_resolve_paths_to_model_info_sync` across all model roots so saving custom URLs to local sidecars from workflow scope works reliably.
## 67. Incomplete Try-Catch Block & Code Duplication in Submodules Silently Halts Extension Registration
**The Problem**: During automated text replacements in `ui_doctor.js`, corrupted line splices created an unclosed `try` block without `catch`/`finally` (`SyntaxError: Missing catch or finally after try`), alongside duplicate loader append statements. Because `main.js` statically imports `ui_sidebar.js` which in turn imports `ui_doctor.js`, the browser's JavaScript engine rejected the entire ES module graph at evaluation time. Consequently, `app.registerExtension()` and the DOM creation logic for `#anomalous-trigger-btn` never ran, leading to the total disappearance of the floating trigger icon and topbar button in ComfyUI without visible error prompts on the canvas.
**The Solution**: Always verify the closure of code blocks (especially `try { ... } catch (error) { ... }`) when performing targeted code refactorings. Validate module ASTs and link the entire frontend dependency tree using Node's experimental VM modules (`node --experimental-vm-modules --input-type=module -e "new vm.SourceTextModule(...)"`) across all frontend files before committing. In addition, ensure `#anomalous-trigger-btn` and `.anomalous-trigger-icon` have robust fallback flex layout styles and clean 1em SVG icon sizing to guarantee immediate visual clarity upon load.
## 66. Unescaped Multiline Regex Literal Leads to ES Module SyntaxError & Disappearing Extension Icon
**The Problem**: During prompt composer assembly logic updates, an accidental newline inside a regex literal (`split(/[,,\n]+/` broke into `/[,,` and `]+/`) created an invalid regular expression token (`SyntaxError: Invalid regular expression: missing /`). Under ES module import rules, any unhandled `SyntaxError` immediately halts module graph compilation. Because `main.js` imports `ui_materials.js` which in turn imports `ui_prompt_composer.js`, the entire extension crashed during initialization before reaching `app.registerExtension()` or `setup()`. This caused the extension entry icon (`#anomalous-trigger-btn`) and topbar entry to completely vanish from ComfyUI without an obvious UI-level trace.
**The Solution**: Keep all character-class regex patterns strictly on a single line (e.g. `split(/[\s,,\n\r]+/)`). To prevent `node -c` false negatives on ES modules, test the real module import chain with `node --input-type=module -e "import('./web/main.js').catch(...)"` to catch syntax and token errors early.
## 65. Missing Module Imports Under Global `try-catch` Masks as Data Fetch Failures
**The Problem**: During visual refactoring of UI cards, if an external dependency (such as `bindMaterialDrag` from `material_drag.js`) is omitted from the file's `import` declarations, calling it inside `createRecipeCard` throws a runtime `ReferenceError`. Because card construction runs inside the global `try { ... } catch (error) { ... }` block of `refreshRecipes()`, this runtime error was swallowed and triggered the generic network failure message (`"无法加载工作流配方。"`), creating the false impression that backend data could not be fetched (even though the topbar filter counter had already successfully computed and displayed `1/1`).
**The Solution**: Ensure every cross-module function called in the component is explicitly imported. Separate network data fetching from DOM list rendering inside `refreshRecipes()`, and log DOM rendering errors with stack traces so unexpected component exceptions do not falsely masquerade as network outages.
## 64. On-Demand Dynamic Hashing & Slash Normalization for Missing Model Recovery
When models are renamed locally and their sidecar `.info` files are deleted, static metadata caches and header inspections may return empty hash sets. Treating size-matching candidates as an immediate `identity_conflict` causes false-negative rejections. Instead, if candidate file size matches the provenance record and the file lacks cached hashes, calculate SHA256 on-demand. Upon cryptographic verification, write an offline inferred `.info` sidecar to persist provenance. Furthermore, always normalize path separators (`\` vs `/`) when indexing and querying workflow metadata (`extra.anomalous_hashes`) to avoid lookup misses across operating systems.
## 60. Beware of `node -c` False Negatives with ES Modules
When checking syntax using `node -c` for browser-targeted ES Module JavaScript files on Windows PowerShell, the Node.js CommonJS parser can stop at the first `export` keyword it encounters, throwing an early `SyntaxError: Unexpected token 'export'`. If a severe syntax error (such as an unclosed bracket or duplicated function definition) occurs earlier in the file, it will cause the browser to silently abort parsing the module without rendering the UI, yet `node -c` might not properly expose the real location of the mismatch if it bails out at the `export` keyword or misinterprets the module boundary. Never blindly trust `node -c` returning `0` if the browser UI disappears; prioritize a manual eyeball check of the replaced blocks and unclosed symbols.
When a visual refactor merges information into a compact overview, do not remove a semantic tab until every dependent interaction has an intentional replacement. Model preview loading, model navigation, and return-state restoration were still designed around a Models tab, so removing only the tab made those actions unreachable. Keep tab state, asynchronous loading conditions, and return paths aligned with the visible navigation.
## 57. Detail Return Must Restore the List Viewport
Opening a detail panel should capture the list's semantic location and viewport, not only the selected item. Restore the folder/type and `scrollTop`/`scrollLeft` when the user returns to the grid, while keeping recipe-detail return state separate from browser-grid state. This prevents a visually correct Back action from still feeling like a navigation reset.
## 58. Overlay Close Must Restore Its Covered Panel
An overlay that only toggles its own visibility can reveal a blank host surface if the underlying panel was hidden by an earlier navigation path. Record the covered panel at overlay entry and restore it through one shared close function. Keep a safe visible fallback, such as the main grid, for stale or missing return state.
## 59. Reused Overlay DOM Must Be Rehydrated on Reopen
When an overlay is closed by hiding both its shell and its child sections, the already-initialized fast path must restore the active child section before returning. Refreshing data alone does not make a hidden DOM subtree visible and can produce a blank overlay on the second open.
## 60. Refresh Modes Must Be Explicit Data, Not List Attributes
Python lists cannot carry ad-hoc control flags. When enrichment needs a refresh mode, pass it as an explicit function argument so the code remains valid for every list implementation and the preserve-versus-recompute behavior is visible at the call site.
## 61. Long Status Explanations Do Not Belong in Bounded Badges
Keep status badges short and move the explanation to a tooltip or disclosure. Long localized text inside a `white-space: nowrap` flex item can overflow its card even when the parent has a nominal width; use concise labels plus `min-width: 0` and explicit wrapping for resilient layouts.
## 62. Disabled Is Not the Same as Feedback
For network-backed buttons, disabling the control prevents duplicate requests but does not tell the user that anything happened. Pair the disabled state with an immediate loading label, a visible progress cue, and a failure path that restores the original action.
## 63. Native Title Tooltips Are Not Reliable UI Feedback
Browser `title` hints are delayed, low-contrast, difficult to style, and poor for keyboard users. For explanations that must not cover neighboring content, an inline click-to-expand note is safer than an absolutely positioned tooltip and avoids duplicate browser/custom hints.
这份文档旨在记录 Anomalous 插件开发过程中的架构演进、UX 设计教训以及核心 Bug 排查经验。
---
## 60. Async UI Actions Need One Ownership Boundary
**The Problem**: Card actions each implemented their own `try/catch`, disabled state, or no state at all. A failed request could leave a control stuck, while a double click could start two recipe mutations.
**The Solution**: Put busy-state ownership, duplicate-click prevention, error reporting, and `finally` restoration in one small action runner. Individual actions should only describe their domain operation and return/throw normally.
## 59. Cross-Panel Navigation Needs an Explicit Return Token
**The Problem**: A recipe detail panel can launch a model detail panel owned by the main browser. Without a return token, the model Back button correctly returned to the model grid but lost the recipe tab, scroll position, and active view. Separately, Open/Append actions performed their work but left the detail Promise unresolved.
**The Solution**: Treat recipe detail as a small controller with an idempotent finish/dispose path. Store only a lightweight payload, active tab, and scroll position for model navigation; consume that token from the main detail Back action. Every successful action must settle the controller, while external Workspace changes must dispose it as well.
## 58. Identity, Preview, and Filename Are Different Evidence Classes
**The Problem**: A recipe's saved model filename is useful for showing the user a basename and for locating a current-machine preview, but it cannot prove that a same-named local file is the same model. Package enrichment also rebuilt model references and accidentally discarded imported identity and historical preview descriptors.
**The Solution**: Keep identity records and presentation descriptors keyed by node/widget/category/saved value, then rebuild only transient local availability around them. Resolver matching must use hash, exact size, and category rules; filename/path remains a locator only. Export must collect assets from every selected recipe version, remove local output paths, and commit replacement packages with rollback across recipe, assets, and history.
## 57. A Summary Must Never Become the Save/Edit Source
**The Problem**: Recipe metadata deliberately truncates generic widget values for fast cards and detail browsing. Reusing it for edit controls meant a long prompt could be saved back as its truncated preview. A canvas-based update also started with an empty pin selection, silently dropping valid prior key parameters.
**The Solution**: Capture one serialized workflow snapshot before the save dialog, use it as the only outbound graph, and resolve editable/pinnable widget values from that snapshot by stable node ID plus widget index. Carry forward only pin keys that still resolve on the current graph and make dropped pins explicit. Server acceptance must validate basic graph topology and return a compact receipt so the UI can show what was accepted.
## 51. A Bounded Card Summary Is Not a Detail Value (卡片摘要不能冒充详情值)
**The Problem (问题)**: The recipe card intentionally limits generic widget text for fast browsing, but the detail panel reused that same summary. Long prompts and output settings looked saved yet could not be inspected or copied in full, while the negative prompt was hidden behind a positive-only overview layout.
**The Solution (方案)**: Keep the compact summary for cards, then resolve the matching safe value from the already-loaded serialized workflow only in the detail panel. Present positive and negative prompts explicitly. Any long value must offer visible expand/collapse and copy actions; do not replace available data with an unexplained ellipsis.
## 50. Host Prompt APIs Often Return an Envelope (宿主 Prompt API 可能返回包装对象)
**The Problem (问题)**: ComfyUI's `api.queuePrompt` destructures the `{ output, workflow }` envelope returned by `app.graphToPrompt(graph)`. Passing only the nested `output` object loses the workflow metadata and produces an invalid queue payload.
**The Solution (方案)**: When bridging a plugin action to a host API, inspect both the producer and consumer contracts and preserve the complete envelope at the boundary. Keep temporary graph conversion separate from the live canvas so this compatibility fix cannot introduce canvas mutation.
## 52. Child Overlay Navigation Must Not Close Its Host (子覆盖层跳转不能关闭宿主)
**The Problem (问题)**: Recipe model cards lived in a child Workspace overlay, but their local-model click handler called the browser-wide `close()` method. That method hides the host modal and releases its grid state, so the target model detail panel appeared to close immediately.
**The Solution (方案)**: Treat the click as an in-place host-panel transition: prepare the model detail state, ensure the host modal remains visible, render the detail panel, and hide only the child recipe overlay. Keep the full close/release lifecycle reserved for explicit browser dismissal.
**Related presentation rule (相关展示规则)**: Repeated model references need independent responsive cards. A shared dense container makes preview, identity, availability, and actions appear mixed together; a card grid preserves one-reference-per-unit without changing the saved workflow data.
## 53. Model Composition Is Not a Generic Parameter Row (模型组成不能当作普通参数行)
**The Problem (问题)**: Putting the base model and a comma-joined LoRA list into the same responsive key-value grid made long names compete with labels and unrelated sampling values. The browser wrapped the text into narrow columns, producing a tall, visually mixed summary.
**The Solution (方案)**: Give model composition its own vertical section. Render the base model and each LoRA as an independent full-width block with safe wrapping and copy/expand controls; reserve the compact grid for short scalar values such as steps, CFG, sampler, and resolution.
## 54. Saved Prompt Metadata Needs a Detail-Tab Contract (已保存提示词必须有明确详情页入口)
**The Problem (问题)**: Prompt extraction already persisted `params.promptPositive` and `params.promptNegative`, but only the Overview invoked the prompt renderer. The Parameters tab only walked bounded generic node summaries, so a valid saved positive prompt looked absent.
**The Solution (方案)**: Treat prompt summaries as first-class recipe parameters and render them in both Overview and Parameters. Keep the existing full-workflow lookup, copy control, and expand/collapse behavior so prompt display does not depend on the generic node summary limit.
## 55. Native Prompt Nodes Must Survive Bounded Summaries (原生提示词节点不能被摘要限制吞掉)
**The Problem (问题)**: A separate prompt summary in Parameters duplicated the native `CLIPTextEncode` rows, while older native nodes could be omitted from `params.nodes` when their text was only available in serialized `workflow.nodes[].widgets_values`. This made one polarity, commonly the positive prompt, appear missing.
**The Solution (方案)**: Keep prompt summaries as an Overview convenience only. Parameters must use native node names and supplement the bounded summary with serialized `CLIPTextEncode` nodes, preserving each node's `text` widget and full-value lookup.
## 56. Fallback Rows Must Have a Presentation Order (补回的参数行必须有展示顺序)
**The Problem (问题)**: Appending a fallback-recovered positive CLIP node to the end of the parameter list left it far away from the existing negative CLIP node.
**The Solution (方案)**: Rebuild the parameter presentation order from serialized workflow order, then group native `CLIPTextEncode` rows together. This changes only display ordering; node names, widget values, and the authoritative workflow remain unchanged.
## 1. UX Scroll Traps (交互嵌套滚动陷阱)
**The Problem (问题)**: In the Notebook modal, the translation editor had a `max-height` and `overflow-y`. Since the parent modal was also scrollable, it created a "Scroll Trap". Hitting the bottom of the editor unpredictably transferred the scroll event to the outer body.
(在笔记本弹窗中,由于翻译编辑器固定了最大高度并允许滚动,导致与父级弹窗的滚动条发生冲突。用户滚动到底部时会突然把外层页面卷走,体验极差。)
**The Solution (方案)**: Removed the `max-height` and internal scrollbar. Letting the translation editor expand naturally utilizing the primary window scrollbar guarantees a continuous experience.
(彻底移除内部最大高度和滚动条。让编辑器自然撑开,统一使用最外层的主滚动条,保证心流不断档。)
## 2. Artificial Metadata Pollution (伪造数据的反噬)
**The Problem (问题)**: To make the "Base Model" dropdown look complete, the backend artificially injected hardcoded architectures (`SDXL`, `HunyuanVideo`) even if the user didn't own them. This confused users.
(为了让“模型架构”下拉菜单看起来更“丰满”,后端硬编码注入了一些用户根本没下载过的新架构。结果让用户极度困惑,以为自己眼花了。)
**The Solution (方案)**: Data truth must strictly reflect the local disk state. Dropped artificial padding; the UI dropdown now accurately mirrors only the physical files on disk.
(数据必须绝对忠于本地磁盘。删除了所有的硬编码注入,确保下拉菜单严格反映本地真实的生态。)
## 3. UI Strobe Effects on Grid Hovers (网格悬浮的“光污染”)
**The Problem (问题)**: Standard `transition: all 0.2s` meant quickly sweeping the mouse across the model grid caused all cards to rapidly light up like a strobe light.
(为卡片设置了标准的 0.2秒 悬浮动画,导致鼠标快速扫过网格时,所有卡片像迪厅频闪一样疯狂亮起,显得非常廉价。)
**The Solution (方案)**: Introduced a `transition-delay: 0.08s` low-pass filter. The recommended UX range for hover intent is 50ms - 100ms. This 80ms delay is imperceptible for intentional clicks but completely eliminates flashes from accidental sweeps.
(引入了 `0.08s` 动画延迟作为低通滤波器。根据人机交互体验,区分“无意滑过”与“有意悬停”的黄金推荐区间是 50ms - 100ms。我们采用的 80ms 延迟对正常点击毫无察觉,但完美过滤了鼠标无意扫过造成的闪烁。)
## 4. The ComfyUI Manager Auto-Update File Wipe (Manager自动更新导致文件丢失)
**The Problem (问题)**: A custom documentation file (`docs/核心按钮操作指南_大白话版.md`) generated by AI locally was silently deleted when clicking "Update" on the node inside ComfyUI Manager.
(本地让 AI 写的说明文件,在点击 ComfyUI Manager 的更新按钮后离奇失踪了。)
**The Solution (方案)**: ComfyUI Manager's update mechanism often performs a raw `git pull` or hard reset. If a local file is not tracked in the remote GitHub repository (i.e., you haven't committed and pushed it), it is highly likely to be overwritten or wiped during the sync process. Always commit and push your documentation, or keep personal untracked notes outside the extension directory!
(ComfyUI Manager 的更新机制依赖 Git 同步(类似 `git reset --hard` 或强制 pull)。如果你在本地创建了文件却没有提交并推送到 GitHub,更新时 Manager 会强行把本地状态与云端对齐,导致这些“未追踪(untracked)”的文件被直接抹杀。教训:重要的文档一定要及时 push,或者别放在插件目录下!)
## 5. "Hostage" Inline Confirmations (被“绑架”的内联确认)
**The Problem (问题)**: Clicking "Delete" changed the button to "Are you sure?" with a 3-second timeout, but lacked a "Cancel" button. Users misclicking were held hostage for 3 seconds.
(点击删除后会变成“确定删除吗?”并锁定 3 秒,但没有“取消”按钮。用户如果不小心点错,只能像被绑架一样干等 3 秒它恢复。)
**The Solution (方案)**: Wrapped the logic to instantly reveal a secondary `[✕]` cancel button upon the first click, giving control back immediately.
(在点击删除时,立马在旁边弹出 `[✕]` 取消按钮。点击取消直接清空倒计时,立刻把控制权还给用户。)
## 6. First-load "Dead Interface" Avoidance (首次加载的“白屏假死”)
**The Problem (问题)**: Initializing the sidebar to the root directory `/` presented a blank screen if users only had models nested deep in subfolders.
(插件默认打开根目录 `/`。如果用户的模型全都在很深的子文件夹里,打开插件就会看到一片空白,以为插件坏了。)
**The Solution (方案)**: Implemented `firstLoadDone` to automatically force the UI to open the very first directory possessing `model_count > 0`.
(加入了智能寻路逻辑,插件首次加载时自动扫描目录树,直接定位并打开第一个存有模型的文件夹。)
## 7. Structural Layout Breakages (流式布局的坍塌)
**The Problem (问题)**: Placing varying lengths of translation tags sequentially caused extreme layout jitter when hovering.
(把中英文标签简单并排放在一起,由于字符串长度不一,鼠标悬浮时极容易发生换行挤压,导致整个界面疯狂跳动。)
**The Solution (方案)**: Enforced a strict "Row-based" flex architecture (`align-items: stretch`). Each tag pair is locked into a geometrical row.
(采用极其严格的行级 Flex 布局,把中英文双语硬性锁死在同一个拉伸行内,无论字数多少,几何结构绝对稳定。)
## 8. Modal State Persistence (弹窗状态的幽灵)
**The Problem (问题)**: Closing and reopening the Notebook modal showed a blank editor because the JS object cached `this.currentNotebook` but the DOM was obliterated.
(关掉笔记本弹窗再打开,右侧编辑器全空。因为 JS 内存里还记着上一次的状态,但 DOM 节点已经被销毁重建了。)
**The Solution (方案)**: Explicitly check cached state during modal initialization and force `renderNotebookEditor()` if necessary.
(在弹窗初始化时主动校验缓存,强制触发重新渲染,实现了完美的“会话级记忆”。)
## 9. Missing Visual Feedback for Background Operations (无反馈的幽灵进程)
**The Problem (问题)**: The Notebook "Save" button executed a background `fetch` perfectly, but provided absolutely no visual feedback. Users assumed it was broken.
(点击“保存”按钮后,后台默默完成了请求,但按钮本身没有任何变化。用户以为没点上,引发数据丢失的焦虑。)
**The Solution (方案)**: Clicking Save instantly changes the button to a green `✅` state for 1.5 seconds.
(点击保存后强制变绿 `✅` 1.5 秒,给用户极强的心理安全感。)
## 10. Spatial Geometry for Multi-Node Spawning (多节点生成的空间重叠)
**The Problem (问题)**: Spawning an entire Notebook (Main Model + Loras + CLIPs) caused all nodes to clump perfectly at `(0,0)`.
(一键生成整个笔记本(主模型+N个Lora+提示词)到画布时,所有节点全叠在同一个坐标上,变成了一坨。)
**The Solution (方案)**: Implemented a "Relative Offset Matrix Layout" (`X + 350*N`, `Y + 250`) and bound it to the `mousemove` event (Magnetic Sticking).
(加入了偏移矩阵计算,并绑定鼠标移动。用户可以直观地拖着排成流水的节点序列在画布上移动,然后再点击放下。)
## 11. Localization Host-Environment Constraints (寄生环境的本地化陷阱)
**The Problem (问题)**: Reading `document.documentElement.lang` failed because ComfyUI forces the host HTML to `en-US`, breaking our Chinese translations.
(插件试图读取网页根节点的 lang 属性来决定语言。但 ComfyUI 强行把底座设成了纯英文,导致中国用户永远看不到中文提示。)
**The Solution (方案)**: Never trust the host document properties. Rerouted localization strictly to our internal `currentLang` variable.
(永远不要相信宿主环境。彻底切断外部依赖,纯靠插件内部自己的 `currentLang` 变量来统治中英文环境。)
## 12. Strict Path Separator Validation (路径分隔符的玄学崩溃)
**The Problem (问题)**: Windows uses backslashes (`\`), but assigning `anime/model` to a node's combo widget failed ComfyUI's strict string matching, causing red nodes.
(Windows 的底层模型路径带反斜杠,但前端组件要求精确匹配字符串,哪怕是一个斜杠方向不对,节点也会标红报错。)
**The Solution (方案)**: Built `setWidgetValuePath` to normalize both target paths and native options with `.replace(/\\/g, '/')` before assignment.
(做了一个拦截器,强行把系统内的选项和我们要赋的值全部统一转成正斜杠再进行比较,直接绕过操作系统的路径陷阱。)
## 13. Backend Starvation During Inference (算力挤兑导致的接口饿死)
**The Problem (问题)**: Fetching plugin data while ComfyUI is generating an image causes timeouts because of PyTorch GIL-locking.
(在 ComfyUI 画图满载的时候,点插件会导致接口超时。因为单线程的 Python 完全被 GPU 推理抢占了。)
**The Solution (方案)**: Accept it gracefully. Database management and intense GPU inference are mutually exclusive workflows.
(优雅地接受这个现实。不需要过度工程化地去做离线队列,画图和管理模型本来就不该同时进行。)
## 14. DOM Obliteration vs Virtual DOM Hoarding (物理毁灭 vs 虚拟回收)
**The Problem (问题)**: Maintaining performance when navigating folders containing thousands of models.
(当一个文件夹里有上千个模型时,频繁切换文件夹会导致浏览器内存爆炸。)
**The Solution (方案)**: Instead of `display: none` hoarding, strictly enforce `this.grid.innerHTML = ''` to physically obliterate nodes and trigger immediate Garbage Collection.
(放弃“隐藏重用”的传统思路。切文件夹时直接暴力清空内部的 HTML 节点,强迫浏览器立马回收内存,保持零负担。)
## 15. The "Missing Plugin Icon" Trap (LocalStorage 坐标越界飞天)
**The Problem (问题)**: If the floating trigger button was saved at `X=3000px` on a 4K monitor, loading it on a `1920px` laptop rendered it permanently off-screen.
(在大显示器上把插件按钮拖到了最右边,换到小笔记本上时,坐标超出了屏幕,按钮彻底消失,再也点不出来了。)
**The Solution (方案)**: Implemented a bounds clamp checking coordinates against `window.innerWidth`. If out of bounds, snap it to the bottom-right corner.
(在启动时强制对比当前屏幕大小,一旦发现越界,立刻把它抓回到右下角的安全区内。)
## 16. The `[vite:preloadError]` Template Literal Crash (一个反引号引发的血案)
**The Problem (问题)**: A missing backtick in a dynamic ES Module dictionary crashed the entire ComfyUI extension loader before execution.
(在汉化字典里不小心删掉了一个反引号,导致 Vite 打包工具报语法错误,整个插件直接在加载前暴毙,UI 按钮全都没了。)
**The Solution (方案)**: JS zero-tolerance for syntax errors requires extreme validation. Fixed the missing backtick.
(原生 JS 没有任何编译容错,任何语法拼写都必须如履薄冰,及时修复了那个反引号。)
## 17. Target Size Deduplication Bug (重叠目录的体积匹配悖论)
**The Problem (问题)**: Fixing models by file size required finding *exactly one* matching file. But users mapping `checkpoints` and `diffusion_models` to the same folder caused `os.walk` to find the same physical file twice, aborting the fix.
(物理寻址兜底算法要求“体积必须唯一”。但用户配置了多个虚拟路径指向同一个物理文件夹,导致同一个文件被扫出两次,系统误判为“体积不唯一”而放弃修复。)
**The Solution (方案)**: Added `os.path.realpath` deduplication matrix to condense multiple virtual paths back to a single hard drive sector.
(引入了绝对物理地址 `realpath` 去重机制,无论虚拟路径怎么重叠,物理层面上都将其归一化。)
## 18. The "Lightweight Scan" Re-Hash Catastrophe (轻量扫描的重算灾难)
**The Problem (问题)**: The lightweight scan (`--skip-media`) demanded *both* an `.info` file and a preview image to skip hashing. Since it didn't download images, it forcefully re-hashed hundreds of GBs of models every time.
(轻量化极速扫描因为不会下载图片,导致“是否跳过计算”的判断条件(既有 info 又有图)永远不成立。引发了灾难性的全盘重新计算 SHA256,慢如蜗牛。)
**The Solution (方案)**: Modified skip logic conditionally: if explicitly skipping media, the existence of `.info` is sufficient to bypass hashing.
(加入了情境判断:只要是在极速模式下,不管有没有图,只要有文字信息就无条件跳过,恢复了真正的极速。)
## 19. The Stale Dropdown Visual Residue (顽固的红框残留)
**The Problem (问题)**: Even after a model path was auto-fixed, ComfyUI maintained a permanent red error border on the node if the frontend cache was stale.
(底层其实已经修复并加载了正确的模型,但因为前端选项缓存没刷过来,ComfyUI 死死把节点标红,看着十分闹心。)
**The Solution (方案)**: Forcefully execute `delete node.color; delete node.bgcolor;` to violently strip native error states without requiring a refresh.
(既然修好了,就直接暴力删除节点的 `color` 属性,强行洗白红框,无需刷新网页。)
## 20. The Silent Subprocess Death (DEVNULL 黑洞)
**The Problem (问题)**: `scraper.py` crashed due to Python indentation, but piping `stderr` to `DEVNULL` made it totally invisible. The UI waited forever.
(后台脚本缩进写错了导致崩溃,但代码里把报错信息塞进了无尽深渊 `DEVNULL`,没有任何提示,前端界面只能干等着,陷入死局。)
**The Solution (方案)**: Never pipe `stderr` to DEVNULL during active development. Let it log.
(开发阶段绝对不要屏蔽底层报错,必须让 `stderr` 打印到控制台以便排查。)
## 21. Tiered Fallback Resolution for O(1) Scans (多级降级寻址打造 O(1) 扫描)
**The Problem (问题)**: Unscanned 10GB models required full disk read to calculate SHA256, taking minutes.
(即便是第一次扫描,遇到几十 GB 的新模型,完整计算 SHA256 也要几分钟,用户体验极度卡顿。)
**The Solution (方案)**:
1. 0ms: Read existing `.info`. (直接读取已有的 info)
2. 0.01s: Extract 64KB JSON header from `.safetensors` via `struct` for pre-computed hash. (用 struct 撬开文件头瞬间提取内置的 Hash,降维打击)
3. Fallback: Full SHA256 only for legacy models. (真找不到了才迫不得已做全量计算)
## 22. Zero-API Tensor Fingerprinting (零API脱机张量基因识别)
**The Problem (问题)**: HuggingFace or private models returned 404 from Civitai, failing to generate `.info` and completely breaking UI integration.
(从 HF 拿到的或者是自己练的私模,由于 C站 没有记录,API 直接报错 404,导致模型彻底被忽略,前端里连看都看不到。)
**The Solution (方案)**: Read the first 500 Tensor Keys via struct. Identifying `double_blocks.0.img_attn` strictly confirms Flux.1 D architecture completely offline.
(不管你有没有网,强行读取模型前 500 层的网络结构。只要看到里面有 `double_blocks`,直接盖章确认为 Flux 架构。完全脱机,无缝融入前端兼容雷达。)
## 23. Gemini-Style Popover UI (告别全屏遮罩,采用悬浮弹窗)
**The Problem (问题)**: Center-screen settings modals with blur backgrounds felt heavy and disconnected users from the workspace.
(原本的设置面板是个带有大面积模糊遮罩的全屏居中弹窗,显得特别笨重,像个垃圾网页游戏。)
**The Solution (方案)**: Refactored to a lightweight side-popover (`left: 100%; bottom: 15px;`) imitating Gemini's web UI, maintaining deep workflow immersion.
(改成绝对定位,像 Gemini 网页端那样从侧边栏右侧悄悄弹出一个悬浮气泡,点外侧自动收起,极大保证了沉浸感。)
## 24. Dock & Mode Lock (全宽底座与多状态锁死)
**The Problem (问题)**: Floating capsules looked cramped, and entering fullscreen Gallery while the Sidebar menu remained clickable caused DOM rendering conflicts.
(底部按钮挤在一起极其难看。而且进入全屏图库的时候,居然还能点左上角的汉堡菜单,导致页面状态直接崩溃交织在一起。)
**The Solution (方案)**: Extended to a 100% width solid Dock. Disabled sidebar triggers during fullscreen views via strict State Locks.
(把底部做成了满宽的实体底座,稳固了视觉重心。一旦进入图库这种排他性的全视图,强行给菜单上互斥锁,彻底断绝乱按引发的 Bug。)
## 25. Resolving "Missing" vs "Unscanned" Conflicts (防重叠逻辑拦截自相踩踏)
**The Problem (问题)**: Deleting a model and reloading the workflow popped BOTH the "Missing Model" dark modal and the "Unscanned Model" orange toast.
(把一个模型删掉后重新加载图片,系统一方面觉得“模型丢了”弹出黑框,一方面因为缓存里没它又觉得它是“没扫过的新模型”弹出橙黄色的警告,两个弹窗叠在一块极其弱智。)
**The Solution (方案)**: The unscanned detector now ignores nodes already flagged with red error colors (`node.color === "#FF3333"`), preventing system overlapping.
(加入了双轨防踩踏:未扫描检测器只要看到这个节点已经红了(彻底丢了),就主动闭嘴放弃拦截,边界极其清晰。)
## 26. The CSS `zoom` Devastation (Zoom 属性对坐标系的降维打击)
**The Problem (问题)**: Using `zoom` to scale the UI broke bounding boxes. `container.offsetHeight > window.innerHeight` caused `Y` coordinates to become negative, permanently launching the window off-screen into the void.
(早期为了偷懒,用 CSS `zoom` 属性来做 UI 缩放。结果它彻底破坏了浏览器的物理坐标系,一旦窗口过大,防越界系统就算出了个负数坐标,导致插件整个闪现飞出屏幕外,再也拖不回来了。)
**The Solution (方案)**: Banned `zoom`. Transitioned to relative scaling via `font-size: calc(16px * var(--anomalous-scale))`. Fixed bounding clamps to execute `< 0` checks last.
(彻底封杀 zoom。改用 `calc(16px * scale)` 的底层字体相对推算放大法。并且把防负数的保底检测代码调到了逻辑的最后一行,永远杜绝负数坐标。)
## 27. Vanilla JS "Fake" i18n Failure (原生状态管理漏斗下的假性汉化失效)
**The Problem (问题)**: Newly added permanent buttons ("UI Scale", "Reset") remained in Chinese when English was toggled. Since we lacked React/Vue two-way binding, they missed the global state update and got stuck.
(新加了“UI缩放”等按钮后,即使点了切换英文,它们还是中文。因为我们没有使用 Vue 的数据绑定,纯靠原生 JS。如果新加了常驻按钮,却忘了把它登记到顶层 `langBtn.onclick` 突变循环里,它就永远被隔离在旧时间线里了。)
(只要是常驻的按钮,必须强制在语言切换中枢里注册重写一遍。同时开发过程中如果遇到“代码没变”的灵异现象,第一反应永远是按 Ctrl+F5 强制破除浏览器静态缓存。)
## 28. The Image Cropping Dilemma (图库的裁切与留白博弈)
**The Problem (问题)**: Using `object-fit: cover` in the image gallery created perfectly uniform grid squares, but brutally decapitated tall images (like 9:16 vertical character portraits).
(在图库排版时,为了让网格看起来绝对整齐,使用了 `object-fit: cover`。结果导致偏长的图片(如 9:16 的人物竖图)顶部和底部被无情裁掉,经常出现“砍头”现象。)
**The Solution (方案)**: Changed to `object-fit: contain` and paired it with a deep black background (`#000`). The images now scale down to fit entirely, and the resulting letterboxing seamlessly blends into the dark UI as a natural frame.
(改为 `object-fit: contain`,完整显示图片。由于底层卡片背景本身就是极黑(`#000`),缩小后产生的黑边完美融入了 UI 背景,不仅没有任何违和感,反而像是一圈高级的画框,一举两得。)
## 29. Vanilla JS Global Click-Outside (原生悬浮面板的全局点击收起)
**The Problem (问题)**: The settings popover only closed if the user explicitly clicked the gear icon again. Clicking elsewhere on the canvas did nothing, making the popover feel rigid and intrusive.
(点击齿轮打开设置悬浮气泡后,必须再点一次齿轮才能关掉。如果随手点一下旁边或者点画布,面板居然不收回,交互显得非常死板和占眼。)
**The Solution (方案)**: Attached a temporary `mousedown` event listener to the `document` when the popover opens. If the click target is outside the modal, it triggers `display: none` and instantly `removeEventListener` to prevent memory leaks.
(面板弹出时,立刻给全局 `document` 挂载一个监听器。只要检测到鼠标点击了面板外部区域,立刻收缩面板,并且**顺手注销掉这个监听器**,实现了极度顺滑的 Gemini 式收放体验且毫无性能负担。)
## 30. Context Preservation vs UX Intention (上下文保留与用户意图的冲突)
**The Problem (问题)**: After picking a new cover from the Gallery while inside the Model Edit Modal, the UI destroyed the modal, showed the gallery, and then *restored* the modal upon completion to 'preserve context'. However, users who just wanted to change the cover felt confused, thinking they still needed to click 'Save' to apply it.
(在编辑面板中点击更换封面后,UI 会先销毁面板,进入图库,选完后再把编辑面板弹回来以“保留上下文”。但用户其实只想换个封面,重新弹出的面板反而让他们误以为必须再点一次“保存”才算数。)
**The Solution (方案)**: Respect the primary intention. When the cover is changed, directly return to the model card/grid. Do not stubbornly restore intermediate modals unless necessary.
(顺应主要意图。换完封面直接回到网格卡片,干脆利落。不要去死板地恢复中间态面板。)
## 31. The Missing Reference Crash (引用丢失导致的状态黑洞)
**The Problem (问题)**: A refactor failed to assign `this.models = data.models` in the load function. When a UI callback later tried to find the updated model using `this.models.find()`, JS threw an `undefined` error and aborted execution midway. This left the UI with both the gallery and grid hidden, resulting in a completely blank screen.
(重构时忘了一行赋值代码,导致 `this.models` 为空。后续 UI 切换时试图从中查找模型直接报错崩溃。由于崩溃发生在显示/隐藏面板的切换中途,导致所有面板都被隐藏,屏幕变成了彻底的“真空空白”。)
**The Solution (方案)**: Always ensure foundational state variables are populated before executing callbacks that depend on them.
(底层状态变量必须绝对可靠,一旦中途崩溃,UI 的半途状态是毁灭性的。)
## 32. Backup Entanglement of Non-Platform Models (非平台模型的备份污染)
**The Problem (问题)**: To ensure a 'Reset' always had a fallback, changing any cover backed up the original file to `.civitai_bak`. For non-Civitai models, this meant user's manual `.png` covers were forcefully renamed to `.civitai_bak`, causing severe semantic confusion.
(为了保证“重置”功能永远有退路,之前的逻辑会把任何被替换的封面强行改名为 `.civitai_bak`。这就导致用户自己放的非 C 站模型封面,也被莫名其妙打上了 C 站的烙印,逻辑极其混乱。)
**The Solution (方案)**: Decouple custom covers by strictly saving them as `.preview.*` and leaving original standard `.png` files untouched. Resetting now just deletes the `.preview.*` file, naturally falling back to the untouched original, or copying the true `.civitai_bak` if it actually came from Civitai.
(让自定义封面独占 `.preview.*` 后缀,绝不触碰原有的标准封面。重置时只需删掉自定义后缀,系统就会自然回退。彻底切断了非 C 站模型与 C 站备份标识的无理纠缠。)
## 33. DOM Video Audio Leaks on innerHTML Clear (DOM 销毁导致的幽灵音频泄漏)
**The Problem (问题)**: When navigating from a model detail view containing an actively playing <video> to another model, setting container.innerHTML = '' removed the video from the DOM, but its audio continued playing infinitely in the background because the browser engine did not immediately garbage-collect the orphaned playing media.
(在模型详细页如果有视频正在播放,当用户点击兼容模型跳转到另一个模型时,虽然通过 innerHTML = '' 清空了面板,DOM 节点被移除了,但由于浏览器没有立刻回收这个还在播放的媒体对象,导致旧视频的音频在后台像幽灵一样继续循环播放,并且再也无法通过 UI 停止。)
**The Solution (方案)**: Implemented a strict cleanup lifecycle. Before destroying or overwriting a container's DOM, explicitly query all ideo and udio elements within it, invoke .pause(), and remove their src attributes to force the browser to release the media resources instantly.
(引入了严格的 DOM 销毁生命周期。在清空任何面板前,必须先获取里面所有的音视频元素,显式调用 .pause() 并剥夺它们的 src,强制浏览器当场释放媒体资源,彻底杜绝后台泄漏。)
## 34. The Search Bar "Hidden State" Trap (搜索框的隐藏状态盲区)
**The Problem (问题)**: Users typing in the global search bar while viewing a Model Detail panel thought search was broken because the filtered results were successfully generated but the grid container was hidden (`display: none`).
(用户在模型详细页里直接使用全局搜索框搜索,后台成功过滤了网格卡片,但因为当前网格容器是隐藏状态,用户看不见结果,误以为搜索功能坏了。)
**The Solution (方案)**: Added an explicit state-exit in the `searchInput.oninput` event. If a detail panel is open, it destroys the panel and forces the grid back to `display: grid` so results are immediately visible.
(在搜索输入事件中强制介入。只要开始搜索,立刻销毁详情面板并强行把网格视图切换回可见状态,保证搜索结果永远“所见即所得”。)
## 35. The Metadata Overwrite Paradox (伪元数据强行覆盖的惨案)
**The Problem (问题)**: A background script updating a model's custom name without fetching full Civitai data wrote a `.civitai.info` file containing *only* the custom name. This obliterated the real Civitai data (description, tags) and caused the frontend to render an empty detail page.
(后台在更新自定义名字时,遇到了一个没有 `.info` 的模型,直接暴力生成了一个只包含自定义名字的 `.civitai.info`。结果反而把原本应有的全量 C 站详情数据给“排挤”掉了,导致详细页一片空白。)
**The Solution (方案)**: Never trust partial updates to structural files. If it's corrupted or just a stub, delete it so the frontend can freshly pull real data from the Civitai API via the Scan Wizard.
(对于结构性文件,宁缺毋滥。发现被污染成只有两个空字段的残疾文件后直接删除,让向导重新去 C 站抓取原汁原味的完整数据。)
## 36. The API Response Mismatch & Cache Deadlock (前后端格式割裂与缓存死锁)
**The Problem (问题)**: `api.py` was updated to send an array of strings (`['/view?...']`) but `main.js` expected objects (`[{url: '/view?...'}]`). When trying to fix `main.js`, the browser's aggressive caching of ComfyUI extensions meant the fix never propagated. Changing `api.py` to match the cached JS failed until the ComfyUI server was restarted, causing a "nothing works" deadlock.
(后端发送字符串,前端却按对象去读取,导致图片全部变成 `undefined` 的隐形方块。试图修改前端修复时,遇到浏览器死忠缓存;试图修改后端去迎合缓存时,又遇到 ComfyUI 不重启不重新加载后端的死锁,导致两头受堵。)
**The Solution (方案)**: Implement "Double-Blind Compatibility". The backend strictly returns structural objects `{'url': ...}` to satisfy legacy cached frontends, and the frontend JS falls back via `el.src = img.url || img;` to parse anything. Most importantly: ALWAYS restart ComfyUI completely when touching backend Python APIs.
(实施"双盲兼容"。后端退回发送对象格式来迎合那些死活不肯刷新的旧版缓存;前端同时加上 `img.url || img` 双轨读取能力。最核心的教训:只要改了后端的 Python 文件,别折腾了,必须彻底重启整个控制台黑框。)
## 37. The Identifier Collision Catastrophe (变量重名引发的插件暴毙)
**The Problem (问题)**: When moving the "Model Doctor" button from TopNav to the Sidebar, a new `const doctorBtn` declaration was injected without removing the original one. Since both lived in the same function scope, JavaScript threw `SyntaxError: Identifier 'doctorBtn' has already been declared` at parse time, which silently killed the **entire** plugin before any code executed — including the trigger icon.
(把"模型医生"按钮从顶部导航栏搬到底部侧边栏时,新代码里声明了一个新的 `const doctorBtn`,但旧的那个忘删了。由于两个 `const` 在同一个函数作用域里,JS 直接在编译阶段就报语法错误,整个插件的代码一行都不会执行——连那个 📦 入口图标都跟着蒸发了。)
**The Solution (方案)**: When relocating UI elements across DOM zones, always perform a full-text search for the old declaration and its associated event handlers. Remove **both** the element creation AND its `appendChild` call. A single leftover `const` can nuke an entire 5000-line module.
(搬家 UI 元素时,必须全局搜索旧的变量声明和所有关联调用(创建、事件绑定、appendChild),全部清理干净。一个多余的 `const` 就能让整个 5000 行模块原地爆炸。)
## 38. The `this` vs Closure Scope Mismatch (this 指向错误导致的静默失败)
**The Problem (问题)**: `hideAllPanels()` was defined as a plain `const` closure function inside `createDOM()`, but the doctor button's click handler called it as `this.hideAllPanels()`. Since it was never defined as a class method, `this.hideAllPanels` resolved to `undefined`, and calling `undefined()` threw a TypeError — silently aborting the entire click handler. The doctor panel never became visible, and the node listener never activated, making it look like the button "did nothing".
(`hideAllPanels()` 是 `createDOM()` 内部的一个普通闭包函数,但医生按钮的点击回调里错误地用 `this.hideAllPanels()` 去调用它。由于它并不是类方法,`this.hideAllPanels` 为 `undefined`,直接抛出 TypeError。这个错误静默中断了整个 onclick 执行链——面板没打开、节点监听没激活,用户只看到"按钮点了没反应"。)
**The Solution (方案)**: Audit every function reference in click handlers. If it's a closure-scoped `const`, call it directly (`hideAllPanels()`). If it's a class method, call via `this.`. Mixing the two is the most common silent-failure trap in Vanilla JS class patterns.
(必须逐一审计点击回调里的每个函数引用。闭包函数直接裸调,类方法才走 `this.`。两者混用是原生 JS 类模式中最常见的静默失败陷阱。)
## 39. The Tool-Escaped Backslash Corruption (编辑工具转义反斜杠的连环陷阱)
**The Problem (问题)**: When fixing a Python `str.replace('\\', '/')` call via code-editing tools, the tool's internal escaping mechanism quadrupled the backslashes: the file ended up containing `replace('\\\\', '/')`, which searches for a **four-character** literal string `\\\\` that never exists in real Windows paths. The fix was invisible because the diff output *looked* correct due to terminal rendering also escaping backslashes. This wasted two full restart-debug cycles.
(通过代码编辑工具修复 Python 的 `replace('\\', '/')` 时,工具内部的转义机制把反斜杠翻了四倍:文件里实际写入的是 `replace('\\\\\\\\', '/')`,也就是在搜索一个根本不存在的四字符字符串。更可怕的是,diff 输出看起来是对的,因为终端渲染本身也会做转义,所以肉眼完全看不出问题。白白浪费了两轮重启调试。)
**The Solution (方案)**: **Never use hardcoded backslash literals in Python path operations when editing through intermediary tools.** Use `os.sep` or `os.path.normpath()` instead. These are immune to any escaping chain and are also cross-platform correct. Always verify the actual file content with `view_file` after editing path-related strings.
(通过中间工具编辑路径代码时,**永远不要手写反斜杠字面量**。用 `os.sep` 或 `os.path.normpath()` 代替,它们对任何转义链路完全免疫,且天然跨平台。编辑完路径相关字符串后,务必用 `view_file` 验证文件里的实际内容。)
## 40. The Uninitialized State Guard (未初始化状态的跨入口崩溃)
**The Problem (问题)**: When a user clicked "View Model Profile" from the Model Doctor panel *without ever having opened the main browser grid*, `showDetail()` → `renderSidebar()` was called. But `renderSidebar()` internally accessed `this.foldersData.length`, and since `loadFolders()` (which populates `foldersData`) only runs when the main grid initializes, `foldersData` was `undefined`. The resulting `Cannot read properties of undefined (reading 'length')` error aborted the entire transition. Similarly, `this.historyStack` could be undefined if accessed before the class constructor's initialization path completed.
(用户直接从模型医生面板点击"查看模型档案",但此时**从未打开过主浏览器网格**。`showDetail()` 内部调用了 `renderSidebar()`,而 `renderSidebar()` 访问了 `this.foldersData.length`。由于 `loadFolders()`(负责填充 `foldersData`)只在主网格初始化时运行,此时 `foldersData` 还是 `undefined`,直接报错崩溃。类似地,`historyStack` 如果在构造函数初始化路径完成前被访问,也会 undefined。)
**The Solution (方案)**: Any cross-entry-point function (accessible from multiple UI paths like Doctor → Detail, or Grid → Detail) must defensively guard ALL state dependencies. Use `this.historyStack = this.historyStack || [];` and `if (this.foldersData) this.renderSidebar();`. Never assume the "happy path" initialization order.
(任何可以从多个 UI 入口调用的函数,必须对所有状态依赖做防御性守卫。用 `this.x = this.x || []` 和 `if (this.x)` 兜底。永远不要假设"正常流程"的初始化顺序。)
## 41. Ternary String Replace Crash (暴力替换引发的连锁崩溃)
**The Problem (问题)**: Using Python `code.replace()` to globally replace localized strings with `window.anomalous_browser_lang === 'zh' ? ... : ...` was efficient but dangerous. A previous script accidentally deleted the declaration of `const checkUnscannedBtnRef` in the language toggle handler while doing a replacement. The missing declaration caused a `ReferenceError`, which crashed the `langBtn.onclick` event silently halfway through execution. This left the user permanently stuck in an English interface with no way to switch back, as the re-render code was never reached.
(在使用脚本暴力全局替换双语三元运算符时,不小心干掉了某一个不相干按钮的 const 声明,导致点击语言切换时直接触发 `ReferenceError` 崩溃。最终结果是,界面再也无法切换回中文,用户被死死锁在英文界面里且没有任何明显报错。)
**The Solution (方案)**: Always test interactive elements (like Language toggles) thoroughly after bulk string replacements. If a bulk replace is needed, verify syntax with `node -c web/main.js` and double-check variable scopes.
(每次用脚本跑完正则替换后,一定要检查所有的点击事件是否还能完整跑完。或者用 `node -c` 检查语法。)
## 42. The CSS ID "Display None" Trap (幽灵般的 CSS ID 冲突)
**The Problem (问题)**: We tried to fix a dynamic translation bug by adding an ID to a DOM element (`settingsBtn.id = 'anomalous-settings-btn'`). However, the original developer had previously written a CSS rule `#anomalous-settings-btn { display: none !important; }` in `styles.css` (inside a narrow-mode `@container` query). By adding the ID, we unintentionally activated the CSS trap, causing the settings button to vanish completely!
(为了让 JS 能够抓取齿轮按钮更新中英文,我们给它加上了 `anomalous-settings-btn` 这个 ID。没想到 CSS 样式表里竟然潜伏着一条专门针对这个 ID 的 `display: none` 隐藏规则。加上 ID 的瞬间,齿轮按钮就因为命中规则而原地蒸发了!)
**The Solution (方案)**: Never assign generic DOM IDs without `grep` searching the `.css` files first! We changed the ID to `anomalous-global-settings-btn`, completely sidestepping the hidden CSS rule.
(在给已有组件加上新的 ID 时,必须先搜索整个 `.css` 文件确认是否撞名!最终我们用 `anomalous-global-settings-btn` 作为新 ID 绕过了这个地雷。)
## 43. The Hash Injection Toggle Misunderstanding (哈希注入总闸的认知错位)
**The Problem (问题)**: The system relied on `anomalous_inject_hash` (Model Provenance Binding) to inject and read hashes. Users misunderstood this toggle, thinking they could turn it off and still use the Model Doctor to auto-fix nodes. Without the injected hashes, the Model Doctor was completely blind.
(用户误以为关掉“模型溯源绑定”不影响模型医生的工作,结果关掉后生成的工作流完全没有哈希值,导致不论怎么用深度扫描,医生都无法替换爆红节点,形成了一个逻辑认知死锁。)
**The Solution (方案)**: Explicitly documented in the README and Help Panel that the Model Doctor STRICTLY REQUIRES workflows to be exported with this plugin's hash injection enabled.
(在所有文档和帮助面板中加上极其醒目的警告,明确指出模型医生的前提条件,打破用户的认知盲区。)
## 44. UI Documentation Factual Disconnect (UI 文档的物理错位)
**The Problem (问题)**: The `Deep Hash Scan` button was physically moved to the `Model Doctor` panel, but the README and in-app Help Panel still told users to find it in the `Scan Wizard` / `Settings` panel.
(把“深度哈希扫描”按钮从设置面板搬到了模型医生面板里,但说明书和 UI 帮助文本没跟着改,还在让用户去设置面板里找,导致用户完全找不到。)
**The Solution (方案)**: Restructured the documentation into a strict visual-flow-based SOP (Standard Operating Procedure). Every UI feature mentioned in the docs is now paired with its exact corresponding Emoji icon (e.g., 🩺, 🤖) and location, completely eliminating navigational ambiguity.
(按视觉动线重构了整份说明书。给文档里提到的每一个按钮都加上了精准的 Emoji 图标(如 🩺、🤖)和精确位置定位,实现了文档与界面的 100% 物理映射。)
## 45. Backup Files Are State, Not Garbage (`.civitai_bak.*` 不是垃圾而是恢复状态)
**The Problem (问题)**: A broad sidecar-cleanup rule made deletion and rename sound identical and treated every matching suffix as disposable. This hid two risks: `.civitai_bak.*` is the persistent source used by Reset, and model extensions in a cleanup list can delete a legitimate same-stem model. A missing backup could also leave Reset deleting the only active preview.
(过宽的伴生文件清理规则把“删除”和“重命名”说成了同一件事,还把所有同后缀文件都当成垃圾。这掩盖了两个风险:`.civitai_bak.*` 是重置封面的永久恢复源;把模型扩展名放进清理列表还可能误删同名但不同格式的真实模型。备份缺失时,重置也可能删掉唯一封面。)
**The Solution (方案)**: Treat the operations as separate lifecycle transitions. Custom covers only replace `.preview.*`; Reset restores a Civitai backup atomically, falls back to an untouched bare cover, or preserves the only preview and warns. Rename migrates sidecars, while deletion cleans them only after the main model is gone and only when no same-stem model survives. Use fixed suffix tuples and bounded exact-path checks so cost does not grow with directory size.
(把这些操作视为不同的生命周期转换:自定义封面只替换 `.preview.*`;重置优先原子恢复 C 站备份,其次回退到未动过的裸封面;若当前预览是唯一图片则保留并提示。重命名是迁移伴生文件,删除则只在主模型成功删除且没有同名模型存活时清理。扩展名使用固定元组和有上限的精确路径检查,性能不随目录文件数量增长。)
## 46. Cache Busting Can Become Cache Destruction (缓存刷新不能变成缓存摧毁)
**The Problem (问题)**: The backend already versioned preview URLs with the file modification time, but the frontend appended `Date.now()` after every model-list refresh. Every unchanged image/video therefore received a brand-new URL and had to be fetched and decoded again. At the same time, synchronous metadata/header reads and one full model-library walk per missing node multiplied disk work on large installations.
(后端本来已经用封面修改时间生成版本号,前端却在每次列表刷新时继续追加 `Date.now()`。于是完全没变化的图片和视频也会得到新 URL,被浏览器重新读取和解码。同时,同步元数据/模型头读取以及“每个爆红节点遍历一次全库”又把大型模型库的磁盘开销成倍放大。)
**The Solution (方案)**: Use stable nanosecond file-version tokens, bounded signature-aware metadata/header caches, one `os.scandir()` inventory per current folder, worker threads for large disk operations, chunked/lazy media rendering, and type-grouped batch resolution. Cache invalidation remains tied to physical file signatures, and batch resolution delegates every item to the unchanged identity decision function.
(使用稳定的纳秒文件版本号、带物理签名的有界元数据/模型头缓存、当前目录单次 `os.scandir()` 清单、磁盘任务工作线程、分帧与懒加载媒体,以及按模型类型分组的批量解析。缓存仍由真实文件签名失效;批量接口中的每一项仍交给完全相同的身份判定函数,因此提速不以削减效果为代价。)
## 47. A Small Card Can Decode a Huge Cover (小卡片也可能吃掉整张大图的内存)
**The Problem (问题)**: CSS makes a 4K cover look like a small card, but Chromium may still fetch and decode the full-resolution source into RAM/GPU textures. Native lazy loading delays that cost; it does not reduce the decoded size. Always-playing video cards can add more decode buffers even when most cards are offscreen.
(CSS 虽然把 4K 封面显示成一张小卡片,但 Chromium 仍可能把完整原图下载并解码进内存/GPU 纹理。原生懒加载只能延后成本,不能缩小解码尺寸;若视频卡片始终播放,视野外的视频解码缓冲还会继续增加负担。)
**The Solution (方案)**: Give users one understandable Model Settings panel. The balanced image mode serves a cached 512px WebP only to grid cards, while original mode remains available and detail views always retain the source cover. Limit thumbnail generation concurrency, store derivatives only in a bounded temporary cache, activate autoplay only near the viewport, release media immediately on close, and release warm card state after a short idle period. Never resize or rewrite the user's cover in place.
(把选择集中在一个普通用户能理解的“模型设置”面板:流畅模式只给卡片提供缓存的 512px WebP,仍允许用户选择原始封面,且详情页始终保留原图。缩略图生成限制并发,派生文件只进入有上限的临时缓存;自动播放只在视野附近激活,关闭时立即释放媒体,短暂保留卡片热状态后再自动回收。任何时候都不能原地压缩或改写用户封面。)
## 48. Semantic Diff Must Not Become a Graph Diff (语义 Diff 不能偷偷膨胀成整图 Diff)
**The Problem (问题)**: Recipe history contains complete serialized graphs, but showing a raw JSON or visual node/link diff would expose noisy internal details, overwhelm the detail panel, and risk surfacing sensitive widget values that the normal parameter view intentionally keeps opaque.
(配方历史保存的是完整序列化图,但直接展示 JSON 或可视化节点/连线差异会制造大量噪声、压垮详情面板,还可能暴露参数页刻意隐藏的敏感控件。)
**The Solution (方案)**: Compare bounded semantic summaries only: prompts, pinned primitives, known model references, safe sampler/resolution fields, graph counts/fingerprint, and presentation metadata. Keep comparison pure and read-only; history restoration remains a separate destructive action with its own confirmation.
(只比较有上限的语义摘要:提示词、钉选原始值、已知模型引用、安全的采样/分辨率字段、图数量/指纹和展示信息。比较器必须是纯只读逻辑,历史恢复仍是独立的破坏性动作并单独确认。)
## 49. Inspect Before Import (导入必须先检查再提交)
**The Problem (问题)**: A recipe package contains user-controlled archive names, JSON, and image bytes. Writing entries directly into the recipe directory would make traversal, symlink, decompression-bomb, dangling-asset, and silent-overwrite failures possible before the UI can explain them.
(配方包里的归档名称、JSON 和图片字节都由用户控制。若直接写进配方目录,路径穿越、符号链接、解压炸弹、悬空资源和静默覆盖都可能在界面解释前发生。)
**The Solution (方案)**: Keep the upload in a bounded single-use inspection record, validate every entry and checksum, show a dry-run summary, then stage the normalized recipe and contained assets before atomic commit. Imports never execute code or install dependencies.
(上传包先保存在有上限、一次性的检查记录中,逐项校验路径、大小、压缩比、校验和和资源引用,展示预检摘要后再把规范化配方和资产写入临时目录并原子提交。导入永远不执行代码,也不安装依赖。)
## 48. Semantic Diff Must Not Become a Graph Diff (语义 Diff 不能偷偷膨胀成整图 Diff)
**The Problem (问题)**: Recipe history contains complete serialized graphs, but showing a raw JSON or visual node/link diff would expose noisy internal details, overwhelm the detail panel, and risk surfacing sensitive widget values that the normal parameter view intentionally keeps opaque.
(配方历史保存的是完整序列化图,但直接展示 JSON 或可视化节点/连线差异会制造大量噪声、压垮详情面板,还可能暴露参数页刻意隐藏的敏感控件。)
**The Solution (方案)**: Compare bounded semantic summaries only: prompts, pinned primitives, known model references, safe sampler/resolution fields, graph counts/fingerprint, and presentation metadata. Keep comparison pure and read-only; history restoration remains a separate destructive action with its own confirmation.
(只比较有上限的语义摘要:提示词、钉选原始值、已知模型引用、安全的采样/分辨率字段、图数量/指纹和展示信息。比较器必须是纯只读逻辑,历史恢复仍是独立的破坏性动作并单独确认。)
## 52. Execution Actions Need One Canvas-Owned Path (执行动作必须统一回到画布)
**The Problem (问题)**: A detached queue action created a second workflow-execution path beside the normal ComfyUI canvas. Maintaining prompt conversion, queue contracts, validation, and UI feedback separately from Open/Append made interaction drift likely and made failures hard to explain.
(脱离画布的快速排队在正常 ComfyUI 画布之外又建立了一条执行路径。提示词转换、排队契约、校验和 UI 反馈都要单独维护,容易造成不同入口行为漂移,也难以解释失败原因。)
**The Solution (方案)**: Remove the detached execution surface and make recipe actions load or transactionally append the saved workflow through shared handlers. List cards and detail panels now reuse the same confirmation, missing-node warning, error feedback, and successful Workspace closure behavior.
(移除脱离画布的执行入口,让配方动作通过共享处理器加载或事务性追加已保存工作流。列表卡片和详情面板统一复用确认、缺失节点提示、错误反馈和成功关闭工作台的行为。)
## 53. Inline Metadata Editing Must Preserve the Full Recipe Contract (内联元数据编辑也必须保留完整配方契约)
**The Problem (问题)**: The update endpoint validates a complete recipe, not a partial metadata patch. A convenient inline editor that sends only `name` or `notes` would either fail validation or tempt the backend to grow a second partial-update contract with weaker history semantics.
(更新接口校验的是完整配方,而不是局部元数据补丁。内联编辑如果只发送 `name` 或 `notes`,要么直接失败,要么会诱导后端新增一套历史语义更弱的局部更新契约。)
**The Solution (方案)**: Keep the inline editor lightweight at the UI layer, but clone and submit the already-loaded complete recipe through the existing update endpoint. Mutate the local view only after a successful response, then refresh the lightweight card list.
(界面可以轻量内联编辑,但仍复制已加载的完整配方并走现有更新接口。只有收到成功响应后才更新当前视图,并刷新轻量卡片列表。)
## 54. Local Matching Must Activate Presentation, Not Rewrite Provenance (本地匹配只激活展示,不改写来源)
**The Problem (问题)**: Imported recipes may contain a model path that is absent on the current machine. Treating a basename or preview hit as authoritative would silently change the workflow's model input or incorrectly promote a visual match to a verified identity.
(导入配方可能引用本机不存在的模型路径。若把文件名或预览命中直接当成权威依据,就会静默改写工作流模型输入,或把视觉命中错误提升为已验证身份。)
**The Solution (方案)**: Keep the saved workflow path and stored identity immutable during lazy matching. Use the existing explicit hash/size/category resolver only after the user requests a match; attach the current local model descriptor transiently for preview and browser navigation, while leaving identity status and workflow content unchanged.
(延迟匹配期间保持已保存工作流路径和身份记录不变。只有用户明确请求后才调用现有哈希/大小/类别解析器;本地模型描述只临时用于预览和跳转,不改变身份状态或工作流内容。)
## 55. Recipe Detail UI Refactoring: Consolidate Information and Guard State Transitions (合并信息噪音与状态切换守卫)
**The Problem (问题)**: The Recipe Detail UI originally had too many tabs separating 'Overview', 'Models', and 'Parameters', causing unnecessary cognitive load. The Quick Queue feature was also highly prone to state bugs due to bypassing the canvas. During refactoring, removing the separate 'Models' tab and injecting them into the 'Overview' caused the preview loading mechanism (which was tied to checking `activeTab === 'models'`) to break.
(Recipe 详情页原本分了太多的 Tab(概览、模型、参数),导致信息过于分散、认知负担过重。同时 Quick Queue 功能由于绕过了画布,极易产生状态 Bug。在重构时,把模型和参数全塞进概览页后,原本绑定在“当处于模型 Tab 时才加载预览图”的机制断裂,导致模型封面无法显示。)
**The Solution (方案)**: Consolidate noisy parameters into a `<details>` fold inside the Overview, simplifying the UI visually. Strip out Quick Queue and standardize on Open/Append logic. Most importantly, when changing Tab semantic structures, carefully audit and update any conditional logic tying background fetching (like preview loading) to active Tab state.
(将杂乱的参数收纳进详情页的 `<details>` 折叠组件中,极大降低视觉噪音。彻底删除 Quick Queue 逻辑并统一为在画布中操作。最重要的是,在改变 Tab 结构时,必须仔细审计并更新那些依赖 Tab 状态的后台加载逻辑(如模型封面预加载),保证从卡片到详情面板的心流与数据流一致。)
## 64. Refresh Commands Need an Explicit Compact Contract
**The Problem**: A UI action that only asks the server to refresh model identities may send `{filename, refreshIdentities}`. Routing that request through a complete-recipe validator makes the button fail even though the stored recipe is valid; a second partial-update path would also risk losing history guarantees.
**The Solution**: Detect the narrowly defined refresh-only payload, deep-copy the existing recipe, and run the same enrichment and archive/write path as a full update. Keep all other update payloads on complete-recipe validation, and match manual origin edits using stable reference provenance rather than display-only fields.
## 65. Imported Recipe Recovery Must Separate Matching from Applying
**The Problem**: An imported workflow stores the author's model path, which is often invalid on the current machine. Treating an exact-path availability refresh as identity matching makes the feature look broken; silently replacing the workflow after a hash match is also unsafe.
**The Solution**: Batch-resolve unresolved references by hash, exact size, and constrained model category while ignoring saved filenames and paths as identity evidence. Show the local candidate first, then require an explicit Apply action that updates the matching widget through the full-recipe update path and preserves history.
## 66. ComfyUI workflow-tab loads need an explicit active workflow
In current ComfyUI frontend builds, `app.loadGraphData(data)` without its fourth argument creates or opens a temporary workflow tab. A plugin that intends to replace the current canvas must pass the active `ComfyWorkflow` object. For legacy-compatible extensions, discover the workflow store from the mounted Vue app's provided Pinia instance when available, then retain the old call as a fallback. Also make plugin-shell cleanup explicit after a successful canvas transition; hiding only the inner panel can leave an empty modal visible when the frontend changes workflow tabs.
## 67. Prefer one unambiguous recipe canvas action
When ComfyUI versions give “Open” different meanings—replace the current graph in one version and open a new workflow tab in another—do not preserve a confusing button merely for compatibility. If append is the user's reliable composition primitive, remove Open from cards and detail views. Keep structural editing separate and label it as a confirmed new-canvas edit flow.
## 68. Remove obsolete save-dialog choices without deleting stored data
If a save-dialog option no longer provides value, remove its controls and transient state rather than preserving a misleading interaction. Keep the serialized workflow unchanged, and preserve legacy metadata when editing existing records unless the user explicitly requests a data migration.
## 69. Compact sidebars need explicit flex shrink rules
A responsive sidebar can become unusable even when its parent is correctly sized: text inputs keep a min-content width and push adjacent action buttons out of the hit area. Give the input `min-width: 0` and make the compact action fixed-width. For file-backed galleries, use a no-store head signature poll instead of rebuilding on every timer tick; this keeps live updates without needless DOM churn.
## 70. Use explicit refresh for filesystem-backed galleries when polling is unnecessary
For a gallery whose source is a directory walk, querying only when the panel opens plus an explicit Refresh action is a better default than background polling when users do not require live monitoring. Keep the button visibly busy during the scan and preserve scroll position after rebuilding the first page.
## 71. Recipe galleries must match a structural workflow, not exact run data
**The Problem**: Exact serialized-workflow hashes make a generated image disappear from the recipe that created it as soon as a sampler seed or batch counter changes.
**The Solution**: Canonicalize a narrow, documented set of run-volatile fields before fingerprinting. Retain topology, model values, prompts, sampler choices, and all nonvolatile parameters so the gallery remains a recipe result view rather than a fuzzy similarity search. Keep the scan on demand and bounded; output folders are user data and must never be continuously walked just to decorate a panel.
## 72. A selected output is a cover asset, not a fragile filesystem reference
**The Problem**: An output image can be cleaned up, moved, or unavailable on an imported recipe, so storing only its `/view` locator makes the chosen recipe cover disappear.
**The Solution**: Treat the output path as an input to a one-time, bounded WebP conversion. Save the derived cover in the recipe-owned asset directory, validate its ID, package it independently of optional model-preview snapshots, and keep the original output locator only as local convenience metadata.
## 73. Separate gallery discovery identity from parameter comparison
**The Problem**: A parameter-level workflow fingerprint is useful for integrity, but it is too strict for finding related output images. ComfyUI can emit a UI workflow or an API prompt with different serialization details, and users commonly vary prompts, models, seeds, or sampler settings while using the same graph.
**The Solution**: Use a separate node-composition signature for discovery: sorted node class names with multiplicity, independent of parameters and node IDs. Keep the structural fingerprint for recipe/history integrity. Load one matched image's bounded metadata only after the user asks to inspect it, then show parameter differences explicitly.
## 74. Opening a filesystem gallery is a natural refresh boundary
**The Problem**: A permanent refresh strip adds visual weight to an image-first gallery, while continuous polling is unnecessary for a directory the user can reopen.
**The Solution**: Refresh the main output gallery when its panel is opened. Remove the dedicated toolbar button and keep any future manual refresh as an unobtrusive secondary action only if real usage proves it necessary.
## [Snapshot] 2026-08-04 - Harden Parameter Notebook backend and workspace lifecycle
**Implemented**
- Added missing asynchronous runtime support for the Parameter Gallery endpoint.
- Made parameter signatures understand both UI workflow widget values and API prompt inputs, while retaining volatile-field filtering.
- Added parameter notebook object/workflow validation, bounded payloads, safe atomic writes, strict fingerprint validation, and background directory reads.
- Ensured the Parameter Notebook overlay participates in panel hiding and Workspace close/restore behavior; stale selections and failed deletes now recover visibly.
**Validation**
- `node --check web/main.js`
- `node --check web/modules/ui_parameters.js`
- `node --check web/modules/ui_recipe_detail.js`
- `node --check web/modules/ui_recipes.js`
- `node --check web/modules/ui_sidebar.js`
- `python_embeded/python.exe -m py_compile api/__init__.py api/parameters.py api/recipes.py api/recipe_packages.py`
- `python_embeded/python.exe tests/test_recipe_roundtrip.py` (15 tests passed)
- `node tests/recipe_parser_roundtrip.mjs`
## 75. Keep the plugin entry point independent from panel construction
The floating trigger is the recovery path for the entire extension, so it must not be appended only after every panel has initialized. Mount it first, catch constructor failures, and retry on click. Optional browser APIs such as `IntersectionObserver` must be feature-detected before use; otherwise a nonessential gallery enhancement can make the whole plugin appear to have disappeared.
## 77. A missing icon can be an ES-module parse failure
**The Problem**: The backend served the extension files and the optional hash resolver appeared in ComfyUI's extension settings, but the visible Anomalous Model Browser entry point was absent. CSS and z-index changes could not fix it.
**The Cause**: A recipe UI merge left duplicate top-level `summaryValue` and `createBadge` declarations in `ui_recipes.js`. The browser rejected the module with `SyntaxError: Identifier 'summaryValue' has already been declared`; because `main.js` imports that module, its registration and trigger creation never ran.
**The Practice**: When a plugin entry disappears, inspect the extension registry and isolate-load the main entry before changing presentation styles. Search for duplicate declarations across recently merged modules, run `node --check` over the complete web tree, and verify the actual trigger DOM node in a live ComfyUI session.
## 76. An optional auto-hook must not own extension startup
ComfyUI loads every JavaScript file under a custom node's `WEB_DIRECTORY`. A resolver that assumes the legacy global `LGraph` can fail before the visible browser entry point is initialized. Optional graph hooks must detect the current graph constructor, guard duplicate patching, and return cleanly when the host API is unavailable.
Keep visual identifiers stable during bug fixing. Restore the established 📦 trigger mark unless the user explicitly asks for a new icon; visibility and startup resilience are separate concerns.
## 78. A second parameter store creates drift and hides the real bug
**The Problem**: Saving a recipe also created a separate parameter notebook, while the recipe detail already persisted the complete workflow. The two surfaces could disagree, and the standalone panel appeared blank when opened.
**The Cause**: The legacy panel used a class selector for a container created with an id (`.anomalous-container` vs `#anomalous-container`). The resulting null dereference happened before the panel could render.
**The Practice**: Keep recipe parameters in the recipe detail as the canonical presentation, resolve full values from the authoritative serialized workflow, and retain old notebook routes only as compatibility. When mounting dynamically created DOM, verify the selector against the actual creation code and guard the host before calling `appendChild`.
## 79. A parameter snapshot needs a stable parent identity
**The Problem**: A recipe can be updated many times, so showing only the latest workflow cannot answer what parameters were saved at an earlier point.
**The Practice**: Create an immutable snapshot after each successful recipe save/update and bind it to the exact recipe filename. Filter snapshots on the server, then let the detail view select one snapshot while keeping the recipe workflow as the fallback for older recipes. Snapshot persistence must be best-effort after the authoritative recipe write; a secondary failure must not cause a duplicate recipe retry.
## 80. A responsive grid can still be unusable when its minimum is too small
**The Problem**: `auto-fit` with a 124px minimum created many narrow summary cards. Flex children then wrapped Chinese labels and numeric values vertically, making ordinary sampler/seed data hard to read.
**The Practice**: Give summary cards a readable minimum width, use an explicit label/value grid inside each card, and reserve ellipsis for compact summaries. Keep the full value and copy action available in the detailed viewer rather than forcing every summary card to expand.
## 81. Volatile values need a display policy separate from workflow storage
**The Problem**: Seeds and similar runtime fields are useful slots to ComfyUI but are not useful identities for a parameter notebook. Showing them as ordinary values also makes users think they define the saved recipe result.
**The Practice**: Keep the raw slot only for host compatibility, but redact volatile values from display, copy, editing, and parameter matching. Keep the field label so users understand why a parameter is omitted. New editable notes must create a new immutable snapshot rather than mutating the selected history item.
## 82. Parameter application must preflight the whole skeleton
**The Problem**: Applying values one node at a time can leave a partially modified canvas when a recipe node or dynamic widget is missing.
**The Practice**: Match every source node before mutating anything, validate all target widget slots up front, then apply inside the host graph's change boundary. Keep a complete previous-value list so callback failures can roll back the whole parameter action; extra local nodes may remain untouched.
## 83. Exportable presentation data must be created before export
**The Problem**: A recipe export can include model-preview snapshots only when those snapshots already exist in the saved recipe. Leaving the save-time packaging option unchecked by default makes the later export choice appear to do nothing.
**The Practice**: Enable sharing-oriented presentation assets by default when creating a recipe or editing one with no stored preference. Preserve an explicit `false` as a user opt-out, and keep export-time inclusion as a separate choice so package size remains under the user's control.
## 84. A two-pane history switch needs right-pane confirmation
**The Problem**: A selected-state color change in the left notebook list is easy to miss, especially when two notes contain similar values. A slow gallery request can also make the right pane show data from the previous selection.
**The Practice**: Repeat the active note's name and timestamp in the content pane, animate that pane only for a real selection change, and guard asynchronous gallery results with the selected-note identity plus a request token.
## 85. Serialized graph records are not live ComfyUI nodes
**The Problem**: Parameter application matched against `graph.serialize()` correctly, then tried to read `widgets` and invoke callbacks on those serialized records. Serialized nodes generally contain `widgets_values`, not runtime widget objects, so every ordinary parameter could be reported as missing even when the canvas skeleton was complete.
**The Practice**: Use serialized data only for bounded identity and skeleton matching. Resolve each matched ID through the live graph before reading `node.widgets`, assigning values, or invoking widget/node callbacks; report the specific node or widget boundary when the live structure is incompatible.
## 86. Parameter apply must tolerate optional widget values and hooks
**The Problem**: A serialized workflow may contain `undefined` widget slots, and third-party nodes may expose optional callback properties that are absent or non-callable. Unchecked cloning or optional-call syntax can fail before the transaction reports a useful reason.
**The Practice**: Preserve `undefined` safely, verify callback types explicitly, and surface unknown local errors in the apply status while retaining the rollback boundary.
## 87. Match host callback signatures before invoking node hooks
**The Problem**: ComfyUI's frontend wraps `onWidgetChanged` to clear node errors and expects the live widget object in the fourth argument. Calling it with only an index and value made the host inspect `sourceNodeId` on `undefined` and abort an otherwise valid parameter update.
**The Practice**: Inspect the active host frontend's callback wrapper, pass the complete host-compatible argument shape, and keep the live widget object available for host-side validation and error cleanup.
## 88. Cross-panel integrations must not report success after a swallowed exception
**The Problem**: The Node Assistant preset button caught application errors internally, then the caller unconditionally changed the button to a success state. A separate refresh addition also referenced a parameter that was not in scope, so the preset panel could fail before rendering.
**The Practice**: Keep the transaction owner responsible for success/failure UI. Validate optional data before iterating, rethrow after rollback, and pass refresh state explicitly through every render boundary. A host integration should emit its graph-change signal only after the write and callbacks complete successfully.
## 89. Selection hooks are shared host infrastructure
**The Problem**: A merged assistant/doctor implementation called a removed doctor-specific method from the shared canvas selection callback. Selecting a node could therefore throw even when the assistant itself was not the active panel.
**The Practice**: Preserve prior host callbacks, keep panel-specific work behind existence checks, and never let an optional panel update call determine whether ComfyUI can continue selecting nodes.
## 90. Legacy metadata needs a value-based compatibility bridge
**The Problem**: A recipe saved before per-node prompt roles could still contain correct positive/negative prompt arrays, but the assistant had no role field to display and defaulted every `CLIPTextEncode` node to positive.
**The Practice**: When adding a new derived metadata field, recover it from an existing authoritative value for legacy records. Require an exact unambiguous match, preserve explicit new metadata as the source of truth, and leave collisions unresolved rather than inventing a role.
## 91. Unknown prompt semantics are a valid product state
**The Problem**: Treating every text-like or `CLIPTextEncode`-named node as positive made the UI look decisive while silently reversing prompts in third-party workflows. Titles and type-name fragments are presentation data, not proof of graph semantics.
**The Practice**: Keep the automatic contract as an explicit allowlist, stop traversal at unknown semantic nodes, and show an `unknown` state instead of guessing. Store user corrections separately from derived metadata, guard them with structural identity, and make every consuming surface honor the same override priority.
## 92. Destructive list actions need their own interaction boundary
**The Problem**: A full-row button is convenient for selecting a history item, but placing a delete button inside it creates invalid nested interactive elements and unreliable click behavior. Deleting the selected item can also leave stale detail/gallery state behind.
**The Practice**: Make selection and deletion sibling buttons inside one row, confirm the exact item, disable the row during the request, and invalidate dependent asynchronous state before refreshing the collection. Never expose deletion for a virtual baseline that has no standalone persisted file.
## 93. Keep open-source permission separate from brand identity
**The Problem**: Saying the whole repository is MIT-licensed while also calling broad, undefined “UI assets” proprietary makes downstream rights unclear and can look like an extra restriction on an otherwise permissive license.
**The Practice**: Leave the standard software license untouched, state exactly which repository materials it covers, and put source-identification rules in a separate trademark/brand policy. Limit that policy to named marks and designated logos, permit truthful attribution, and target confusing claims of official status rather than ordinary code modification.
## 94. Beta warnings must say what to protect and when
**The Problem**: A generic “experimental” badge does not help users avoid loss when a feature can import, restore, delete, or apply saved workflow data. Documentation can also become misleading when one surface describes an older, narrower feature than the UI currently ships.
**The Practice**: Name the risky operations, the exact user-data directories to back up, and the distinction between presentation snapshots and real model backups. Keep the warning visible in each beta surface and repeat the fuller instructions in Help and README. Drive runtime text from the locale dictionary and verify language-key parity instead of maintaining separate hardcoded copies.
## 95. Locale parity does not prove live language switching
**The Problem**: Both locale dictionaries can contain the right keys while a panel created before the language change keeps its old text in the DOM. Static key audits and syntax checks cannot detect that stale rendered state.
**The Practice**: Give persistent localized elements their locale-key metadata and refresh those elements when the language changes. Verify the switch in the running host with the affected panel already open, then switch the user's language back after testing.
## 96. Beta labels belong to the smallest experimental surface
**The Problem**: A beta banner attached to the Node Assistant root visually classified its mature Actions tab as experimental even though only the recipe-powered Parameter Notebook tab was under test.
**The Practice**: Put maturity labels inside the exact tab, control, or workflow they qualify, and state adjacent stable behavior explicitly when confusion is likely. Release notes, README, Help, and runtime placement must describe the same boundary.
## 97. Cross-panel return callbacks need an abandonment path
**The Problem**: Recipe model detail had a callback for its own Back button, but top-level navigation simply nulled that callback. The source recipe list stayed hidden, while a previously visible Node Assistant resurfaced beneath model detail.
**The Practice**: Treat temporary cross-panel navigation as an exclusive transition with both consume and abandon semantics. Hide all sibling surfaces on entry; on normal return reconstruct the source; on top-level escape clear temporary state, release destination content, and normalize the source list. The destination's fallback rendering must not depend solely on the return callback still existing.
## 98. Hidden dialogs must not claim to be active modals
**The Problem**: Queue Prompt, Interrupt, and Escape-to-exit-subgraph stopped working as soon as the plugin loaded, even when its model settings overlay was hidden. Rebinding the commands could not help.
**The Cause**: The reusable settings dialog stayed mounted with `role="dialog"` and `aria-modal="true"`. ComfyUI Frontend's keybinding guard treats any matching element as an open modal without checking ancestor visibility, so command-backed shortcuts were rejected before execution.
**The Practice**: Keep reusable dialog DOM if needed, but synchronize its modal semantics with visibility: use `aria-modal="false"` while hidden and switch to `true` only for the visible lifetime. Do not work around host command guards with duplicate global keyboard listeners; fix the incorrect UI state at its source and verify every open/close path.
## 99. Browsable does not mean physically renameable
**The Problem**: Treating every scanned category as a physical-rename target changed stable VAE filenames and made existing workflows report missing models.
**The Practice**: Keep VAE, VAE Approx, CLIP/Text Encoder, and CLIP Vision available for browsing, metadata scans, and virtual naming while denying physical rename in the detail UI, scan APIs, metadata API, and standalone scraper. A safety policy needs an authoritative backend guard even when the current UI already disables the action.
## 100. A virtual name must own the primary browsing surface
**The Problem**: Scanning correctly wrote `anomalous_custom_name`, and model detail displayed it, but the main grid always rendered the physical filename. Users reasonably concluded that virtual rename had failed, and searching the visible name could not find the card.
**The Practice**: Treat the virtual/custom name as the card title, retain the physical filename as a smaller diagnostic subtitle, and search physical paths before falling back to sidecar display names. Keep the full absolute path in model detail with a copy action; folder-tree navigation already owns relative location context.
## 101. Foundation-component recovery is hash-only
**The Problem**: VAE and text-encoder files often keep ecosystem-stable names while Civitai and Hugging Face publish different labels. Size-only recovery could silently select a different foundational component, while blocking an existing same-named file would disagree with ComfyUI's own valid combo state.
**The Practice**: If the native filename exists, keep it usable and surface a non-blocking warning only when both workflow and local hashes exist but differ. If the filename is missing, VAE, VAE Approx, CLIP/Text Encoder, and CLIP Vision may recover only from one exact in-category workflow hash. Without that hash, leave the reference unresolved for manual handling; never guess from size or display names.
## 102. A lock file is evidence, not proof of a live job
**The Problem**: Folder and global scans wrote a plain marker before starting a daemon thread and removed it only from the thread's `finally`. Closing ComfyUI during a scan could terminate the daemon before cleanup, so every later session treated the leftover file as a permanently active scan. Reinstalling appeared to be the only recovery because it happened to remove the plugin-local global marker.
**The Practice**: Persist enough ownership to validate a background job: version, backend session, owner PID, worker PID, and job ID. A status/start request must preserve a genuinely live worker but atomically clear markers and progress artifacts owned by dead or legacy sessions. Pair that recovery contract with real, atomically written progress data; a spinner or bare file-existence flag cannot tell the user whether work is advancing.
## 103. Preserving core navigation buttons and enforcing sharp dark-crimson styling
**The Problem**: During top-bar refactoring and visual updates, the sidebar docking button (`dockBtn`) was accidentally omitted from the header's right action group. Concurrently, button hover halos were over-diffused (large blur radius resembling blurry flashlights), panel backdrops lacked fallback styling and turned pitch-black when image assets were absent, and residual cold-blue/slate-gray gradients polluted the intended dark gothic crimson aesthetic.
**The Practice**:
1. **Never drop persistent navigation actions during refactoring**: Core actions like `#anomalous-dock-btn` must be preserved in header DOM groups alongside proper localization and tooltips (`[data-tooltip-pos="bottom"]`).
2. **Constrain button hover glows**: For premium dark/crimson themes, constrain `:hover` blur radii to 2px–4px (`box-shadow: 0 0 4px 1px ...`) to achieve tight, razor-sharp magic rim glows rather than muddy flashlight halos.
3. **Always supply CSS gradient fallbacks for conceptual backdrops**: Pair image backdrops with dark crimson radial gradients (`background-image: url('...'), radial-gradient(...)`) and base colors (`#0d080c`) so missing or slow-loading assets never degrade into dead black voids.
4. **Purge cold slate tones from dark crimson palettes**: Enforce pure dark tones tinted with deep crimson (`linear-gradient(to top, rgba(15, 5, 10, 0.95), transparent)`) across card title masks, drawer inputs, and containers.
## 104. Header drag delegation must guard closest interactive elements, and model card palettes must strictly harmonize with Gothic Sovereign colors
**The Problem**:
1. When header buttons contain nested SVGs or spans (e.g. `#anomalous-notebook-btn`), checking `e.target.tagName === 'BUTTON'` in `header.addEventListener('mousedown')` fails whenever users click text or icons (`e.target` is `SPAN`, `SVG`, or `PATH`). The handler treated the interaction as a header drag, set `isDragging = true`, and executed `e.preventDefault()`, which prevented the browser from firing the subsequent `click` event and rendered the button completely unresponsive.
2. Model cards featured hardcoded raw cyan badges (`#00ffcc`), severely clashing with the dark crimson/wine Gothic Vampire aesthetic.
3. The main gallery/grid area suffered from a "dead pitch-black void" due to flat `#000000` / `#0d080c` fills with overly dark overlay blending and no ambient illumination behind floating cards.
**The Practice**:
1. **Interactive element guard in drag handlers**: Always use `e.target.closest('button') || e.target.closest('input') || e.target.closest('select') || e.target.closest('textarea') || e.target.closest('.anomalous-header-close')` in mousedown drag handlers to guarantee that clicks on nested child icons and text spans are never intercepted or suppressed.
2. **Harmonize badge palettes (Gothic Sovereign Palette)**: Replace cold cyan badges with semantic Gothic classes: Dark Gold (`#f59e0b`), Amber (`#fbbf24`), Abyssal Purple (`#c084fc`), and Rose Crimson (`#ff4d6d`), strictly eliminating jarring neon cyan.
3. **Eliminate dead black voids with multi-layered ambient lighting**: Build depth behind floating cards using deep dark wine obsidian bases (`#0b050f` / `#0c0510`), top-centered radial crimson moon glow (`radial-gradient(ellipse ... rgba(190, 20, 55, 0.12)...)`), soft-light concept mansion backing (`mix-blend-mode: soft-light`), and custom gothic crimson-accented scrollbars.
## 105. Precise safe-corridor geometry and blank-space dismiss guards for popover tooltips
**The Problem**:
1. In the Prompt Studio sidebar, `isPointInSafeZone` used a coarse bounding rectangle (`minX` to `maxX`, `minY` to `maxY`) between the anchor card and the floating popover. When a card preview popover was tall, this bounding rectangle spanned the entire width of the left column (0~190px) across hundreds of vertical pixels, falsely treating the list's empty space as part of the bridge corridor and continuously resetting the hide timer (`cancelHidePopover()`), resulting in a zombie/stuck popover.
2. CSS added a 28px wide transparent `::before` pseudo-element on the popover that penetrated 22px into the left panel, capturing pointer events and triggering `popover.onmouseenter` over whitespace.
3. No explicit dismiss handling was attached to blank container areas or outside pointerdowns when unpinned.
**The Practice**:
1. **Never use full bounding boxes for separate columns**: Safe corridors must be strictly bounded to the physical gap between trigger and target (`card.right - 6` to `popover.left + 6`), ensuring the pointer immediately leaves the safe zone when moving into whitespace above, below, or to the side.
2. **Differentiate dismiss timing**: Use snappy delays (80~100ms) when the pointer moves outside the safe zone or into whitespace, while maintaining standard tolerance (200~250ms) for deliberate transitions into the popover.
3. **Empty space & outside click dismissal**: Containers must explicitly trigger fast hide on whitespace hover/movement and immediately destroy unpinned popovers on whitespace or outside clicks.
4. **Avoid oversized invisible CSS hitboxes**: Prefer bounded mathematical hit testing in JavaScript over large transparent pseudo-elements (`::before`) that bleed across panel boundaries.
+1 -33
View File
@@ -71,39 +71,7 @@ AI_SYSTEM_TEMPLATE.md
# Test files
test_*.js
test*.mjs
tests/*
!tests/package.json
!tests/ui_fixture.mjs
!tests/prompt_ui_lifecycle.mjs
!tests/prompt_material_source.mjs
!tests/prompt_library_sync.mjs
!tests/translation_service_contracts.mjs
!tests/studio_contracts.mjs
!tests/material_workspace_flow.mjs
!tests/export_availability.mjs
!tests/update_guide.mjs
!tests/test_export_availability.py
!tests/test_route_manifest.py
!tests/test_model_source_catalog.py
!tests/test_model_doctor_resolution.py
!tests/model_sources_hub.mjs
!tests/model_source_components.mjs
!tests/shortcut_layout.mjs
!tests/shortcut_organizer.mjs
!tests/sidebar_feature_modules.mjs
!tests/recipe_detail_subviews.mjs
!tests/main_module_boundaries.mjs
!tests/recipe_card_render.mjs
!tests/recipe_model_matching.mjs
!tests/recipe_parameter_utils.mjs
!tests/node_feature_modules.mjs
!tests/model_detail_modules.mjs
!tests/image_workbench_modules.mjs
!tests/material_feature_modules.mjs
!tests/notebook_feature_modules.mjs
!tests/css_bundle_order.mjs
!tests/notebook_render_test.mjs
!tests/notebook_save_roundtrip.mjs
tests/
# Local diagnostic/test artifacts
grep_doctor.txt
-23
View File
@@ -1,23 +0,0 @@
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const entry = await fs.readFile(path.join(root, 'web/styles.css'), 'utf8');
const imports = [...entry.matchAll(/@import\s+url\("\.\/styles\/([^?"\s]+)\?v=([^"\s]+)"\);/g)];
assert.equal(imports.length, 11, 'the stylesheet entry must contain the full ordered import list');
assert.equal(imports.map(match => match[1]).length, new Set(imports.map(match => match[1])).size, 'imports must be unique');
assert.equal(new Set(imports.map(match => match[2])).size, 1, 'child stylesheets must share one cache version');
assert.equal(entry.replace(/@import[^;]+;\s*/g, ''), '', 'styles.css must remain an import-only external entry');
const chunks = await Promise.all(imports.map(async match => {
const content = await fs.readFile(path.join(root, 'web/styles', match[1]), 'utf8');
return Buffer.from(content.replace(/\r\n/g, '\n'));
}));
const digest = crypto.createHash('sha256').update(Buffer.concat(chunks)).digest('hex');
assert.equal(digest, 'b4b133ca259cd97ea814e74194074b652695547be53000858175e8b6ec118e02', 'CSS rule bytes or cascade order changed');
console.log('CSS bundle: ordered imports preserve the original stylesheet byte-for-byte.');
-97
View File
@@ -1,97 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import { deflateRawSync } from 'node:zlib';
import { fixture, Element, all } from './ui_fixture.mjs';
const f = fixture();
const studio = await f.module('ui_prompt_composer.js');
const owner = {};
await studio.openPromptStudio(owner);
await f.flush();
const workbench = f.document.querySelector('.anomalous-prompt-workbench');
assert.equal(all(workbench).some(el => el.tagName === 'button' && /export|导出/i.test(el.textContent)), false);
owner.sidePromptComposerControl.addBlock({ content: 'cinematic light', role: 'positive' });
await f.button(workbench, '📋').click();
assert.equal(f.clipboard.at(-1), 'cinematic light');
const name = workbench.querySelector('.anomalous-prompt-name-input');
name.value = 'Local combination'; name.oninput();
f.fetch = async () => ({ ok: true, json: async () => ({ status: 'success', material: { filename: 'local.json' } }) });
await f.button(workbench, '💾').click();
const save = f.requests.findLast(([url]) => url.includes('/save_prompt_plan'));
assert.equal(JSON.parse(save[1].body).plan.positive, 'cinematic light');
studio.closePromptStudio(owner);
const recipes = await f.module('ui_recipes.js');
const recipeOwner = { recipeListContainer: new Element('div') };
recipes.renderRecipeList.call(recipeOwner, [{ filename: 'example.json', data: { name: 'Example', params: {} } }]);
const exportButton = all(recipeOwner.recipeListContainer).find(el => el.attrs?.['aria-label'] === 'Recipe package export is temporarily unavailable');
assert.ok(exportButton);
assert.equal(exportButton.disabled, true);
await exportButton.click();
assert.equal(f.requests.some(([url]) => url.includes('/export_recipe_package')), false);
const recipeWorkspace = { nbPanel: new Element('div'), refreshRecipes: async () => {} };
await recipes.showRecipes.call(recipeWorkspace);
const importPackage = all(recipeWorkspace.recipeContainer).find(el => el.attrs?.['aria-label'] === 'Recipe package import is temporarily unavailable');
assert.ok(importPackage);
assert.equal(importPackage.disabled, true);
await importPackage.click();
assert.equal(f.requests.some(([url]) => url.includes('/import_recipe_package')), false);
assert.deepEqual(f.errors, []);
console.log('Transfer availability: prompt export absent; copy/local save work; recipe import/export cannot issue a request.');
// Evaluate the existing share object independently of unrelated host bootstrap.
const main = fs.readFileSync(new URL('../web/main.js', import.meta.url), 'utf8');
const shareStart = main.indexOf('const AMB_WorkflowShare');
assert.ok(shareStart >= 0);
const locales = await f.module('locales.js');
f.window.atob = atob;
f.window.btoa = btoa;
f.document.getElementById = id => all(f.document.body).find(el => el.id === id);
let importedWorkflow;
f.app.loadGraphData = workflow => { importedWorkflow = workflow; };
f.app.graphToPrompt = () => { throw new Error('Disabled export must not read the canvas'); };
vm.runInNewContext(main.slice(shareStart), {
window: f.window, document: f.document, app: f.app,
t: locales.createTranslator('en'), setTimeout: () => 0,
Blob, Response, CompressionStream, DecompressionStream, TextEncoder, TextDecoder,
});
const share = f.window.AMB_WorkflowShare;
const toolboxModule = await f.module('ui_toolbox.js');
const toolboxContainer = new Element('div');
const toolboxOwner = { sidebarActions: new Element('div') };
toolboxContainer.appendChild(toolboxOwner.sidebarActions);
const settingsButton = new Element('button');
const toolboxControl = toolboxModule.createToolbox(toolboxOwner, {
container: toolboxContainer,
menuBtn: new Element('button'),
toolboxIcon: 'tools',
isScanning: () => false,
getSettingsButton: () => settingsButton,
onBeforeOpen() {},
});
toolboxControl.mount();
await toolboxControl.button.click();
const workflowTransfer = all(toolboxControl.modal).find(el => el.attrs?.['data-tool-id'] === 'workflow-transfer');
await workflowTransfer.click();
assert.equal(toolboxControl.modal.style.display, 'none');
const exportShare = f.button(f.document.body, 'Export Workflow to Share Code');
assert.ok(exportShare);
assert.equal(exportShare.disabled, false);
await exportShare.click();
assert.ok(f.document.getElementById('amb-export-modal'));
await f.button(f.document.getElementById('amb-export-modal'), 'Close').click();
share.showUnifiedModal();
await f.button(f.document.body, 'Import Workflow from Share Code').click();
const importModal = f.document.getElementById('amb-import-modal');
assert.ok(importModal);
const workflow = { nodes: [], extra: { model_hashes: { example: 'keep-this-hash' } } };
importModal.querySelector('textarea').value = 'AMB0-' + deflateRawSync(JSON.stringify(workflow)).toString('base64');
await f.button(importModal, 'Import & Load').click();
assert.equal(JSON.stringify(importedWorkflow), JSON.stringify(workflow));
for (const skeleton of [false, true]) {
const code = await share.encodeShareCode(workflow, skeleton);
const decoded = await share.decodeShareCode(code);
assert.equal(JSON.stringify(decoded.extra), JSON.stringify(workflow.extra));
}
console.log('Verified share-code tools: export UI enabled; both formats round-trip and import preserves metadata.');
-75
View File
@@ -1,75 +0,0 @@
import assert from 'node:assert/strict';
import { fixture } from './ui_fixture.mjs';
const f = fixture();
f.document.getElementById = id => f.document.body.querySelector(`#${id}`);
const stage = await f.module('ui_image_stage.js');
const inspector = await f.module('ui_image_inspector.js');
const gallery = await f.module('ui_gallery.js');
assert.deepEqual(Object.keys(stage).sort(), [
'buildFilmstripRail',
'buildWorkbenchHeader',
'preloadAdjacentImages',
'setupStagePanZoom',
]);
assert.deepEqual(Object.keys(inspector).sort(), ['renderImageInspectorContent']);
let dismissed = false;
const workbench = { isFilmstripVisible: true, filmstripEl: null };
const header = stage.buildWorkbenchHeader(
{ filename: 'sample.png', subfolder: 'outputs' },
0,
2,
() => {},
{ workbench, onDismiss: () => { dismissed = true; } },
);
header.querySelector('.anomalous-workbench-close-btn').click();
assert.equal(dismissed, true);
const rail = stage.buildFilmstripRail([
{ filename: 'one.png', url: '/one.png' },
{ filename: 'two.png', url: '/two.png' },
], 1, () => {}, workbench);
assert.equal(rail.querySelectorAll('.anomalous-workbench-filmstrip-thumb').length, 2);
assert.equal(rail.querySelectorAll('.is-active').length, 1);
const stageEl = f.document.createElement('div');
const imageEl = f.document.createElement('img');
const listenersBefore = f.window.listenerCount();
const panZoom = stage.setupStagePanZoom(stageEl, imageEl);
assert.equal(f.window.listenerCount(), listenersBefore + 2);
panZoom.zoomIn();
assert.match(imageEl.style.transform, /scale\(1\.3\)/);
panZoom.cleanup();
assert.equal(f.window.listenerCount(), listenersBefore);
await inspector.renderImageInspectorContent({ workbench: null }, {}, {});
let parameterWorkbenchOpens = 0;
const galleryGrid = f.document.createElement('div');
const gallerySentinel = f.document.createElement('div');
galleryGrid.appendChild(gallerySentinel);
f.document.body.appendChild(galleryGrid);
const galleryOwner = {
galleryGrid,
gallerySentinel,
galleryLoading: false,
galleryRefreshLoading: false,
galleryImagesList: [],
showImageWorkbench() { parameterWorkbenchOpens += 1; },
};
galleryOwner.loadGalleryImages = gallery.loadGalleryImages.bind(galleryOwner);
f.fetch = async () => ({
ok: true,
json: async () => ({ images: [{ filename: 'sample.png', subfolder: 'outputs' }], pages: 1 }),
});
await galleryOwner.loadGalleryImages(1, true);
const galleryImage = galleryGrid.querySelector('img');
galleryImage.click();
assert.equal(f.document.getElementById('anomalous-gallery-viewer')?.open, true, 'image click opens the zoom viewer');
assert.equal(parameterWorkbenchOpens, 0, 'image click does not open parameter inspection');
galleryGrid.querySelector('.anomalous-gallery-details').click();
assert.equal(parameterWorkbenchOpens, 1, 'the dedicated parameter button still opens inspection');
console.log('Image workbench modules: stage lifecycle, filmstrip, and inspector boundary passed.');
-80
View File
@@ -1,80 +0,0 @@
import assert from 'node:assert/strict';
import { fixture } from './ui_fixture.mjs';
const storage = new Map();
const f = fixture({ storage });
f.document.getElementById = id => f.document.body.querySelector(`#${id}`);
const registeredSettings = {};
const settingValues = new Map();
let registeredExtension;
f.app.registerExtension = extension => { registeredExtension = extension; };
f.app.extensionManager = {
setting: {
settings: registeredSettings,
get: id => settingValues.get(id),
set: (id, value) => settingValues.set(id, value),
}
};
const interfaceModule = await f.module('interface_settings.js');
const entryModule = await f.module('browser_entry.js');
const shortcutModule = await f.module('shortcut_controls.js');
const entry = entryModule.createBrowserEntry({
translate: interfaceModule.t,
getCurrentLanguage: interfaceModule.getCurrentLanguage,
});
assert.equal(entry.settings.map(setting => setting.id).join(','), [
entryModule.FLOATING_TRIGGER_STYLE_SETTING_ID,
entryModule.FLOATING_TRIGGER_SIZE_SETTING_ID,
entryModule.SHORTCUT_SETTING_ID,
entryModule.MATERIALS_SHORTCUT_SETTING_ID,
entryModule.ENTRY_MODE_SETTING_ID,
].join(','));
assert.equal(interfaceModule.createInterfaceSettings().map(setting => setting.id).join(','), [
interfaceModule.LANGUAGE_SETTING_ID,
interfaceModule.ABYSSAL_SCARLET_SETTING_ID,
].join(','));
entry.settings.find(setting => setting.id === entryModule.ENTRY_MODE_SETTING_ID).onChange('menu');
assert.equal(f.document.documentElement.classList.contains('anomalous-floating-entry-enabled'), false);
assert.equal(f.document.documentElement.classList.contains('anomalous-topbar-entry-enabled'), false);
const interfaceSettings = interfaceModule.createInterfaceSettings();
interfaceSettings.find(setting => setting.id === interfaceModule.LANGUAGE_SETTING_ID).onChange('en');
assert.equal(storage.get('anomalous_lang'), 'en');
assert.equal(f.window.anomalous_browser_lang, 'en');
interfaceSettings.find(setting => setting.id === interfaceModule.ABYSSAL_SCARLET_SETTING_ID).onChange(true);
assert.equal(f.document.documentElement.classList.contains('theme-abyssal-scarlet'), true);
assert.equal(storage.get('anomalous_theme_abyssal_scarlet'), 'true');
await f.module('../main.js');
assert.equal(registeredExtension.name, 'Anomalous.ModelBrowser');
assert.equal(registeredExtension.settings.length, 7);
assert.equal(registeredExtension.commands.length, 3);
const materialsBinding = registeredExtension.keybindings.find(binding => binding.commandId === entryModule.OPEN_MATERIALS_COMMAND_ID)?.combo;
assert.equal(materialsBinding?.key, 'l');
assert.equal(materialsBinding?.ctrl, true);
assert.equal(materialsBinding?.shift, true);
assert.equal(typeof registeredExtension.setup, 'function');
let fallbackCalls = 0;
let handled = false;
const disposeFallback = shortcutModule.installDeferredShortcutFallback({
target: f.window,
getCombo: () => shortcutModule.DEFAULT_MATERIALS_SHORTCUT,
isHandled: () => handled,
onFallback: () => { fallbackCalls += 1; },
});
f.window.dispatch('keydown', { key: 'L', ctrlKey: true, shiftKey: true, altKey: false, metaKey: false });
await f.flush();
assert.equal(fallbackCalls, 1, 'material shortcut falls back when the host command misses the event');
f.window.dispatch('keydown', { key: 'L', ctrlKey: true, shiftKey: true, altKey: false, metaKey: false });
handled = true;
await f.flush();
assert.equal(fallbackCalls, 1, 'material shortcut does not duplicate a handled native command');
disposeFallback();
console.log('Main module boundaries: entry settings, language, and theme behavior passed.');
-32
View File
@@ -1,32 +0,0 @@
import assert from 'node:assert/strict';
import { fixture } from './ui_fixture.mjs';
const f = fixture();
const catalog = await f.module('ui_materials.js');
const cards = await f.module('ui_material_cards.js');
const detail = await f.module('ui_material_detail.js');
const application = await f.module('ui_material_application.js');
assert.deepEqual(Object.keys(catalog).sort(), [
'openMaterialLibrary',
'openSavedMaterial',
'refreshMaterials',
'showImageMaterialDetail',
'showMaterials',
]);
assert.deepEqual(Object.keys(cards).sort(), ['renderMaterialCard']);
assert.equal(typeof detail.showMaterialDetail, 'function');
assert.equal(typeof detail.leaveMaterialDetail, 'function');
assert.equal(typeof application.applyLibraryMaterial, 'function');
assert.equal(typeof application.watchMaterialSelection, 'function');
assert.equal(typeof application.updateMaterialContext, 'function');
const promptMaterial = { kind: 'prompt_text', name: 'Prompt', filename: 'prompt.json' };
assert.equal(detail.isPromptMaterial(promptMaterial), true);
assert.match(detail.materialAssetUrl('a b.json', 'preview image.png'), /a%20b\.json.*preview%20image\.png/);
const card = cards.renderMaterialCard({ materialApplyMode: false }, promptMaterial);
assert.equal(card.classList.contains('anomalous-material-card'), true);
assert.ok(card.querySelector('.anomalous-material-card-preview'));
console.log('Material feature modules: catalog, cards, detail, and application boundaries passed.');
-78
View File
@@ -1,78 +0,0 @@
import assert from 'node:assert/strict';
import { fixture, Element, all } from './ui_fixture.mjs';
const f = fixture();
const record = { filename: 'parameters.json', kind: 'image_node_selection', name: 'Parameters', node_types: ['CLIPTextEncode'], selection: { scope: 'nodes' } };
f.fetch = async url => ({ ok: true, json: async () => String(url).includes('material_full')
? {
status: 'success', data: record,
node_blocks: [2, 3].map(id => ({ node_id: id, type: 'CLIPTextEncode', widgets_values: [`text ${id}`] })),
prompt_roles: { 2: { role: 'positive', source: 'topology' }, 3: { role: 'negative', source: 'topology' } },
}
: { status: 'success', materials: [record], page: 1, pages: 1, total: 1, tags: [] } });
const materials = await f.module('ui_materials.js');
const owner = { nbPanel: new Element('div') };
owner.nbPanel.style.display = 'flex';
f.document.body.appendChild(owner.nbPanel);
for (const key of ['showMaterials', 'refreshMaterials', 'openSavedMaterial', 'openMaterialLibrary']) owner[key] = materials[key].bind(owner);
await owner.openMaterialLibrary();
assert.equal(all(owner.materialContainer).some(el => el.tagName === 'input' && el.type === 'file'), false);
assert.equal(all(owner.materialContainer).some(el => el.tagName === 'button' && /import|export/i.test(el.textContent)), false);
assert.equal(owner.notebookContainer, undefined);
assert.equal(owner.materialApplyMode, true);
assert.equal(new URL(f.requests.at(-1)[0], 'http://test').searchParams.get('node_type'), 'CLIPTextEncode');
assert.deepEqual(f.errors, []);
f.app.canvas.selected_nodes = {};
await owner.openMaterialLibrary();
assert.equal(owner.materialApplyMode, false, 'opening without a selection uses browse mode');
assert.equal(owner.materialTarget, null, 'opening without a selection clears the application target');
assert.equal(new URL(f.requests.at(-1)[0], 'http://test').searchParams.has('node_type'), false);
assert.ok(owner.materialContext.textContent.includes((await f.module('locales.js')).translate('materialSelectOneNode')), 'browse mode explains that selection is optional');
const browseCard = owner.materialList.querySelector('article');
await browseCard.click();
assert.ok(owner.materialDetailView, 'material details open without a selected node');
assert.deepEqual(f.errors, []);
f.app.canvas.selected_nodes = { 1: f.node };
await owner.openMaterialLibrary();
const card = owner.materialList.querySelector('article');
assert.ok(card, 'current material card renders');
await card.click();
assert.equal(f.node.widgets[0].value, 'original', 'opening details must not write to canvas');
assert.ok(owner.materialDetailView);
await card.querySelector('.anomalous-material-card-apply-btn').click();
assert.deepEqual(f.errors, []);
assert.ok(owner.materialBlockDialog);
let choices = owner.materialBlockDialog.querySelectorAll('.anomalous-material-choice-card');
assert.ok(choices[0].textContent.includes('Positive'));
assert.ok(choices[1].textContent.includes('Negative'));
await choices[1].querySelector('button').click();
assert.equal(f.node.widgets[0].value, 'text 3');
await f.button(owner.materialContext, 'Undo').click();
assert.equal(f.node.widgets[0].value, 'original');
f.runTimers();
await card.querySelector('.anomalous-material-card-apply-btn').click();
const dialog = owner.materialBlockDialog;
f.app.canvas.selected_nodes = {};
await dialog.querySelector('.anomalous-material-choice-card').querySelector('button').click();
assert.equal(f.node.widgets[0].value, 'original');
assert.ok(dialog.textContent.includes((await f.module('locales.js')).translate('materialTargetChanged')));
dialog.close();
f.app.canvas.selected_nodes = { 1: f.node };
const notes = await f.module('ui_notebooks.js');
owner.showNotebooks = notes.showNotebooks.bind(owner);
owner.refreshNotebooks = () => {};
await owner.showNotebooks();
assert.equal(owner.materialContainer.style.display, 'none');
assert.ok(owner.notebookContainer);
assert.equal(owner.notebookMaterialsTab, undefined);
await owner.showMaterials();
assert.equal(owner.notebookContainer.style.display, 'none');
assert.equal(owner.materialContainer.style.display, 'flex');
assert.equal(all(owner.materialContainer).some(el => el.className === 'anomalous-nb-section-tabs'), false);
assert.deepEqual(f.errors, []);
console.log('material workspace: current cards, explicit apply, undo, stale selection and notes isolation passed');
-28
View File
@@ -1,28 +0,0 @@
import assert from 'node:assert/strict';
import { fixture } from './ui_fixture.mjs';
const f = fixture();
const detail = await f.module('ui_detail.js');
const editor = await f.module('ui_model_editor.js');
const selector = await f.module('ui_model_selector.js');
assert.deepEqual(Object.keys(detail).sort(), ['showDetail']);
assert.deepEqual(Object.keys(editor).sort(), ['showEditModal']);
assert.deepEqual(Object.keys(selector).sort(), [
'_openAdvancedModelSelector',
'setWidgetValuePath',
]);
const exactWidget = {
type: 'combo',
value: '',
options: { values: ['folder/model.safetensors'] },
};
selector.setWidgetValuePath({ widgets: [exactWidget] }, 'folder\\model.safetensors');
assert.equal(exactWidget.value, 'folder/model.safetensors');
const fallbackWidget = { type: 'text', value: '' };
selector.setWidgetValuePath({ widgets: [fallbackWidget] }, 'other/model.ckpt');
assert.equal(fallbackWidget.value, 'other/model.ckpt');
console.log('Model detail modules: detail, editor, selector, and widget path behavior passed.');
-123
View File
@@ -1,123 +0,0 @@
import assert from 'node:assert/strict';
import { fixture, all } from './ui_fixture.mjs';
const f = fixture();
f.app.canvas.setDirty = () => {};
const hub = await f.module('ui_model_sources.js');
const node = (id, type, name, value, values) => ({ id, type, widgets: [{ name, value, options: { values } }] });
f.app.graph._nodes = [
node(1, 'DualCLIPLoader', 'clip_name1', 'encoders/t5.gguf', ['encoders\\t5.gguf']),
node(2, 'CLIPLoader', 'clip_name', 'clip.pth', () => ['clip.pth']),
node(3, 'VAELoader', 'vae_name', 'taesd', ['taesd']),
node(4, 'VAELoader', 'vae_name', 'missing.safetensors', ['available.safetensors']),
node(5, 'CLIPVisionLoader', 'clip_name', 'vision.safetensors', ['vision.safetensors']),
node(6, 'DualCLIPLoader', 'device', 'cpu', ['cpu', 'cuda']),
node(7, 'VAELoader', 'vae_name', 'None', ['None']),
node(8, 'VAELoader', 'vae_name', 'uninstalled.safetensors', []),
];
f.app.graph.extra = {};
let entries = hub.collectWorkflowModels();
assert.equal(entries.length, 6, 'Include components without requiring a source URL or hash; skip non-model controls');
assert.equal(entries[0].isMissing, false, 'Native slash variants are not missing files');
assert.equal(entries[1].isMissing, false, 'Dynamic choices are not proof of a missing file');
assert.equal(entries[3].isMissing, true);
assert.equal(entries[5].isMissing, true, 'An empty native file list still identifies an unavailable selected model');
assert.deepEqual([...entries[4].folderTypes], ['clip_vision']);
f.fetch = async (url, options) => {
assert.equal(url, '/anomalous/resolve_paths_to_previews', 'Opening only reads local metadata');
const body = JSON.parse(options.body);
const context_models = {};
for (const request of body.context_requests || []) {
assert.equal(request.exact_only, true);
assert.ok(request.folder_types.length);
if (request.path === 'vision.safetensors') context_models[request.key] = {
type: 'clip_vision', subfolder: '', path_idx: 1, file_path: 'E:/fixtures/vision.safetensors',
metadata: { source_url: 'https://huggingface.co/example/vision' },
};
}
return { ok: true, json: async () => ({ context_models, models: {
// A same-named checkpoint must not be used for a VAE.
'missing.safetensors': { type: 'checkpoints', metadata: { source_url: 'https://example.com/wrong' } },
} }) };
};
await hub.resolveWorkflowModelsMetadata(entries);
assert.equal(entries[3].url, '');
assert.equal(entries[4].url, 'https://huggingface.co/example/vision');
assert.equal(entries[4].path_idx, 1);
assert.equal(await hub.autoDetectModelSource(entries[3]), '', 'Unknown component stays unresolved; no cloud search URL is treated as a source');
await assert.rejects(() => hub.saveSingleModelToLocalSidecar(entries[2], 'https://example.com/taesd'));
entries[2].url = 'https://example.com/taesd';
hub.syncWorkflowSources(entries);
assert.equal(f.app.graph.extra.anomalous_model_sources.taesd.url, entries[2].url, 'Unresolved local options can still carry workflow links');
const many = Array.from({ length: 19 }, (_, i) => ({ ...entries[4], key: `component-${i}` }));
const start = f.requests.length;
await hub.resolveWorkflowModelsMetadata(many);
const batches = f.requests.slice(start).map(([, o]) => JSON.parse(o.body)).filter(b => b.context_requests);
assert.deepEqual(batches.map(b => b.context_requests.length), [16, 3]);
await hub.copySourcesSummary([{ ...entries[2], url: '' }]);
assert.ok(f.clipboard.at(-1).includes('Not provided (optional)'));
assert.ok(!f.clipboard.at(-1).includes('⚠️'));
// Exercise the actual UI renderer, including manual entry and both status badges.
f.app.graph.extra = {};
const close = hub.openModelSourcesModal('workflow');
await f.flush();
assert.equal(f.document.body.querySelectorAll('.anomalous-source-row-item').length, 0, 'Collapsed components do not build row DOM');
let disclosure = f.document.body.querySelector('.anomalous-sources-components-toggle');
assert.ok(disclosure.textContent.includes('Foundation components (advanced) · 6'));
assert.ok(disclosure.textContent.includes('Missing local files × 2'));
assert.equal(disclosure.getAttribute('aria-expanded'), 'false');
disclosure.click();
let rows = f.document.body.querySelectorAll('.anomalous-source-row-item');
assert.equal(rows.length, 6);
assert.ok(rows[0].textContent.includes('Text encoder'));
assert.ok(rows[4].textContent.includes('Vision encoder'));
const missing = rows[3];
assert.ok(missing.querySelector('.anomalous-source-badge-danger'));
const pending = all(missing).find(el => el.textContent === 'Not provided (optional)' && el.className === 'anomalous-source-badge-neutral');
assert.equal(pending.hidden, false);
const input = missing.querySelector('input');
assert.equal(input.readOnly, false);
input.value = 'https://example.com/vae';
input.oninput();
assert.equal(pending.hidden, true, 'Typing a source removes the pending-source hint without hiding missing-file warning');
assert.ok(missing.querySelector('.anomalous-source-badge-danger'));
assert.equal(rows[4].querySelector('input').readOnly, true, 'Resolved source is preserved');
// Source filters apply to components as well, so resolved components never appear under source-needed.
f.button(f.document.body, 'Source provided').click();
assert.equal(f.document.body.querySelectorAll('.anomalous-source-row-item').length, 2);
let search = f.document.body.querySelector('.anomalous-sources-search-input');
search.value = 'vision';
search.oninput();
rows = f.document.body.querySelectorAll('.anomalous-source-row-item');
assert.equal(rows.length, 1);
disclosure = f.document.body.querySelector('.anomalous-sources-components-toggle');
assert.ok(disclosure.textContent.includes('Foundation components (advanced) · 1'));
assert.equal(disclosure.textContent.includes('Missing local files'), false, 'Missing warning describes only visible component matches');
await f.button(f.document.body, 'Copy Visible Range').click();
assert.ok(f.clipboard.at(-1).includes('vision.safetensors'));
assert.ok(!f.clipboard.at(-1).includes('t5.gguf'), 'Summary exports only the currently visible range');
f.button(f.document.body, 'Save to Workflow').click();
assert.equal(f.app.graph.extra.anomalous_model_sources['missing.safetensors'].url, 'https://example.com/vae');
assert.equal(f.app.graph.extra.anomalous_model_sources['vision.safetensors'].url, 'https://huggingface.co/example/vision', 'Filtered save retains full workflow semantics');
assert.equal(f.document.body.querySelectorAll('.anomalous-source-row-item').length, 1, 'Save refresh preserves expansion and search');
close();
assert.equal(f.document.body.querySelector('.anomalous-model-sources-overlay'), null);
const closeReopened = hub.openModelSourcesModal('workflow');
assert.equal(f.document.body.querySelectorAll('.anomalous-source-row-item').length, 0, 'A new modal session starts collapsed');
closeReopened();
let saved = null;
f.fetch = async (url, options) => {
assert.equal(url, '/anomalous/update_metadata');
saved = JSON.parse(options.body);
return { ok: true, json: async () => ({ status: 'success' }) };
};
await hub.saveSingleModelToLocalSidecar(entries[4], 'https://example.com/vision');
assert.equal(saved.type, 'clip_vision');
assert.equal(saved.path_idx, 1);
assert.equal(saved.filename, 'vision.safetensors');
assert.equal(saved.custom_source_url, 'https://example.com/vision');
console.log('PASS component collection, category-scoped lookup, manual workflow links, batching and independent UI status.');
-346
View File
@@ -1,346 +0,0 @@
import assert from 'node:assert/strict';
import { fixture } from './ui_fixture.mjs';
const f = fixture();
// Setup LiteGraph global for note node creation
const addedNodes = [];
const mockLiteGraph = {
createNode(type) {
return {
type,
title: 'Note',
widgets: [{ name: 'text', type: 'customtext', value: '' }],
pos: [0, 0],
size: [200, 100],
color: '#332222',
bgcolor: '#553333',
};
}
};
globalThis.LiteGraph = mockLiteGraph;
f.window.LiteGraph = mockLiteGraph;
// Setup mock app.graph and app.canvas
f.app.graph._nodes = [
{
id: 10,
type: 'CheckpointLoaderSimple',
title: 'Load Checkpoint',
widgets: [
{ name: 'ckpt_name', value: 'epicRealism_v5.safetensors' }
],
pos: [100, 200],
size: [300, 150]
},
{
id: 11,
type: 'LoraLoader',
title: 'Load LoRA',
widgets: [
{ name: 'lora_name', value: 'detail_tweaker.safetensors' },
{ name: 'strength_model', value: 0.8 }
],
pos: [450, 200],
size: [250, 120]
}
];
f.app.graph.add = function(node) {
addedNodes.push(node);
f.app.graph._nodes.push(node);
};
f.app.graph.extra = {
anomalous_hashes: {
'epicRealism_v5.safetensors': 'abc12345',
}
};
f.app.canvas = {
setDirty: () => {}
};
// Load ui_model_sources module
const hub = await f.module('ui_model_sources.js');
const sourceData = await f.module('model_source_data.js');
const offlinePlaceholder = sourceData.shapeLibrarySourceModels([{
type: 'diffusion_models', path_idx: 0, filename: 'offline.safetensors',
metadata: { civitai_url: 'https://civitai.com/models/-1?modelVersionId=-1' },
}], hub.detectPlatform);
assert.equal(offlinePlaceholder[0].url, '');
assert.equal(sourceData.partitionSourceModels(offlinePlaceholder, 'unresolved').mainModels.length, 1);
assert.equal(sourceData.partitionSourceModels(offlinePlaceholder, 'resolved').mainModels.length, 0);
assert.equal(
sourceData.usableSourceUrl('https://civitai.com/models/123?modelVersionId=456'),
'https://civitai.com/models/123?modelVersionId=456',
);
const filteredComponents = [
{ type: 'vae', filename: 'resolved-vae.safetensors', url: 'https://huggingface.co/example/vae' },
{ type: 'clip', filename: 'pending-clip.safetensors', url: '' },
];
assert.deepEqual(sourceData.partitionSourceModels(filteredComponents, 'resolved').componentModels.map(item => item.filename), ['resolved-vae.safetensors']);
assert.deepEqual(sourceData.partitionSourceModels(filteredComponents, 'unresolved').componentModels.map(item => item.filename), ['pending-clip.safetensors']);
// 1. detectPlatform test
assert.equal(hub.detectPlatform('https://civitai.com/models/12345')?.name, 'Civitai');
assert.equal(hub.detectPlatform('https://huggingface.co/runwayml/stable-diffusion-v1-5')?.name, 'HuggingFace');
assert.equal(hub.detectPlatform('https://www.liblib.art/model/67890')?.name, 'LiblibAI');
assert.equal(hub.detectPlatform('https://modelscope.cn/models/test')?.name, 'ModelScope');
assert.equal(hub.detectPlatform('https://github.com/comfyanonymous/ComfyUI')?.name, 'GitHub');
assert.equal(hub.detectPlatform('https://pan.baidu.com/s/abcdef')?.name, 'CloudNet');
assert.equal(hub.detectPlatform('https://example.com/some_model')?.name, 'Web Link');
assert.equal(hub.detectPlatform(''), null);
assert.equal(hub.detectPlatform(null), null);
// 2. isModelFilename test
assert.equal(hub.isModelFilename('v1-5-pruned.safetensors'), true);
assert.equal(hub.isModelFilename('my_lora.ckpt'), true);
assert.equal(hub.isModelFilename('model.pt'), true);
assert.equal(hub.isModelFilename('image.png'), false);
assert.equal(hub.isModelFilename('workflow.json'), false);
assert.equal(hub.isModelFilename(123), false);
// 3. normalizeUrl test
assert.equal(hub.normalizeUrl('civitai.com/models/123'), 'https://civitai.com/models/123');
assert.equal(hub.normalizeUrl(' https://civitai.com/models/123 '), 'https://civitai.com/models/123');
assert.equal(hub.normalizeUrl(''), '');
assert.equal(hub.normalizeUrl(null), '');
// 4. collectWorkflowModels test
const workflowModels = hub.collectWorkflowModels();
assert.equal(workflowModels.length, 2);
assert.equal(workflowModels[0].filename, 'epicRealism_v5.safetensors');
assert.equal(workflowModels[0].hash, 'abc12345');
assert.equal(workflowModels[0].nodeType, 'CheckpointLoaderSimple');
assert.equal(workflowModels[1].filename, 'detail_tweaker.safetensors');
// 5. syncWorkflowSources test (initial save vs update)
workflowModels[0].url = 'https://civitai.com/models/12345';
const saveResult = hub.syncWorkflowSources(workflowModels);
assert.equal(saveResult.count, 1);
assert.equal(saveResult.isUpdate, false);
assert.ok(f.app.graph.extra.anomalous_model_sources['epicRealism_v5.safetensors']);
assert.equal(f.app.graph.extra.anomalous_model_sources['epicRealism_v5.safetensors'].url, 'https://civitai.com/models/12345');
// Second sync should detect that it's an update
workflowModels[0].url = 'https://civitai.com/models/99999';
const updateResult = hub.syncWorkflowSources(workflowModels);
assert.equal(updateResult.isUpdate, true);
assert.equal(f.app.graph.extra.anomalous_model_sources['epicRealism_v5.safetensors'].url, 'https://civitai.com/models/99999');
// 6. createCanvasNoteNode test
const note = hub.createCanvasNoteNode(workflowModels);
assert.ok(note);
assert.equal(note.type, 'Note');
assert.equal(addedNodes.length, 1);
assert.ok(note.widgets[0].value.includes('epicRealism_v5.safetensors'));
assert.ok(note.widgets[0].value.includes('https://civitai.com/models/99999'));
// 7. resolveWorkflowModelsMetadata test
f.fetch = async (url, options) => {
if (url === '/anomalous/resolve_paths_to_previews') {
return {
ok: true,
status: 200,
json: async () => ({
models: {
'detail_tweaker.safetensors': {
type: 'loras',
subfolder: '/styles',
path_idx: 0,
file_path: 'E:/ComfyUI/models/loras/styles/detail_tweaker.safetensors',
metadata: {
hash: 'deadbeef999',
civitai_url: 'https://civitai.com/models/77777',
}
}
}
})
};
}
if (url === '/anomalous/update_metadata') {
const body = JSON.parse(options.body);
return {
ok: true,
status: 200,
json: async () => ({ status: 'success', saved: body })
};
}
return { ok: true, status: 200, json: async () => ({ status: 'success' }) };
};
const unresolvedModel = { filename: 'detail_tweaker.safetensors', basename: 'detail_tweaker.safetensors', url: '' };
const hasChanges = await hub.resolveWorkflowModelsMetadata([unresolvedModel]);
assert.equal(hasChanges, true);
assert.equal(unresolvedModel.url, 'https://civitai.com/models/77777');
assert.equal(unresolvedModel.platform?.name, 'Civitai');
assert.equal(unresolvedModel.type, 'loras');
assert.equal(unresolvedModel.subfolder, '/styles');
assert.equal(unresolvedModel.hash, 'deadbeef999');
// 8. saveSingleModelToLocalSidecar test
let postedBody = null;
f.fetch = async (url, options) => {
if (url === '/anomalous/update_metadata') {
postedBody = JSON.parse(options.body);
return { ok: true, status: 200, json: async () => ({ status: 'success' }) };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const modelToSave = {
filename: 'SDXL/styles/nested_lora.safetensors',
basename: 'nested_lora.safetensors',
type: 'loras',
subfolder: '/SDXL/styles',
path_idx: 0,
};
await hub.saveSingleModelToLocalSidecar(modelToSave, 'https://civitai.com/models/88888');
assert.ok(postedBody);
assert.equal(postedBody.filename, 'nested_lora.safetensors');
assert.equal(postedBody.custom_source_url, 'https://civitai.com/models/88888');
assert.equal(postedBody.type, 'loras');
assert.equal(postedBody.subfolder, '/SDXL/styles');
// 9. autoDetectModelSource test (local metadata priority)
f.fetch = async (url, options) => {
if (url === '/anomalous/resolve_paths_to_previews') {
return {
ok: true,
status: 200,
json: async () => ({
models: {
'local_known.safetensors': {
type: 'checkpoints',
subfolder: '/',
path_idx: 0,
metadata: {
source_url: 'https://huggingface.co/runwayml/stable-diffusion-v1-5'
}
}
}
})
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const detectTarget = { filename: 'local_known.safetensors', basename: 'local_known.safetensors', url: '' };
const detectedUrl = await hub.autoDetectModelSource(detectTarget);
assert.equal(detectedUrl, 'https://huggingface.co/runwayml/stable-diffusion-v1-5');
f.fetch = async () => ({ ok: true, status: 200, json: async () => ({ models: {} }) });
assert.equal(await hub.autoDetectModelSource({ filename: 'unknown.safetensors', basename: 'unknown.safetensors', url: '', hash: '' }), '');
// 10. Opening directly into the local library starts its load and distinguishes a successful empty result.
const libraryFixture = fixture();
libraryFixture.app.graph._nodes = [];
libraryFixture.app.graph.extra = {};
let finishLibrary;
libraryFixture.fetch = (url) => {
assert.equal(url, '/anomalous/all_scan_models?limit=0');
return new Promise(resolve => { finishLibrary = resolve; });
};
const libraryHub = await libraryFixture.module('ui_model_sources.js');
const closeLibrary = libraryHub.openModelSourcesModal('library');
assert.ok(libraryFixture.document.body.textContent.includes('Loading the current Folder Manager scope'));
finishLibrary({ ok: true, status: 200, json: async () => ({ models: [] }) });
await libraryFixture.flush();
assert.ok(libraryFixture.document.body.textContent.includes('Local library scope follows Settings → Folder Manager'));
assert.ok(libraryFixture.document.body.textContent.includes('No models detected'));
closeLibrary();
// Exact detection moves to the resolved filter and keeps the recognized row visible.
const detectFixture = fixture();
detectFixture.app.graph._nodes = [];
detectFixture.app.graph.extra = {};
detectFixture.fetch = async (url) => {
if (url === '/anomalous/all_scan_models?limit=0') {
return { ok: true, status: 200, json: async () => ({ models: [{
type: 'checkpoints', path_idx: 0, filename: 'recognized.safetensors', metadata: {},
}] }) };
}
return { ok: true, status: 200, json: async () => ({ models: {
'recognized.safetensors': {
type: 'checkpoints', path_idx: 0, subfolder: '',
metadata: { source_url: 'https://huggingface.co/example/recognized' },
},
} }) };
};
const detectHub = await detectFixture.module('ui_model_sources.js');
const closeDetected = detectHub.openModelSourcesModal('library');
await detectFixture.flush();
detectFixture.button(detectFixture.document.body, 'Source needed').click();
await detectFixture.button(detectFixture.document.body, 'Detect').click();
await detectFixture.flush();
assert.equal(detectFixture.document.body.querySelectorAll('.anomalous-source-row-item').length, 1);
assert.ok(detectFixture.document.body.querySelectorAll('.anomalous-sources-pill')
.find(pill => pill.classList.contains('is-active'))?.textContent.includes('Source provided'));
assert.equal(detectFixture.document.body.querySelector('.anomalous-source-url-input')?.value, 'https://huggingface.co/example/recognized');
closeDetected();
// Closing aborts ownership of a late library response and must not recreate the modal.
let finishLate;
libraryFixture.fetch = () => new Promise(resolve => { finishLate = resolve; });
const closeLate = libraryHub.openModelSourcesModal('library');
closeLate();
finishLate({ ok: true, status: 200, json: async () => ({ models: [{ type: 'clip', path_idx: 0, filename: 'late.GGUF' }] }) });
await libraryFixture.flush();
assert.equal(libraryFixture.document.body.querySelector('.anomalous-model-sources-overlay'), null);
const errorFixture = fixture();
errorFixture.app.graph._nodes = [];
errorFixture.app.graph.extra = {};
errorFixture.fetch = async () => ({ ok: false, status: 500, json: async () => ({}) });
const errorHub = await errorFixture.module('ui_model_sources.js');
const originalConsoleError = console.error;
console.error = () => {};
const closeError = errorHub.openModelSourcesModal('library');
await errorFixture.flush();
console.error = originalConsoleError;
assert.ok(errorFixture.document.body.textContent.includes('Failed to load the local model library'));
assert.ok(errorFixture.button(errorFixture.document.body, 'Retry'));
closeError();
// 12. Switching from workflow -> library -> workflow -> library preserves state and correctly re-renders library models.
const toggleFixture = fixture();
toggleFixture.app.graph._nodes = [
{ type: 'CheckpointLoaderSimple', widgets: [{ name: 'ckpt_name', value: 'wf_model.safetensors' }] },
];
toggleFixture.app.graph.extra = {};
toggleFixture.fetch = async (url) => {
if (url === '/anomalous/all_scan_models?limit=0') {
return {
ok: true,
status: 200,
json: async () => ({
models: [
{ type: 'checkpoints', path_idx: 0, filename: 'lib_model.safetensors', metadata: {} },
],
}),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const toggleHub = await toggleFixture.module('ui_model_sources.js');
const closeToggle = toggleHub.openModelSourcesModal('workflow');
assert.ok(toggleFixture.document.body.textContent.includes('wf_model.safetensors'), 'workflow model is visible initially');
// Switch to library
let tabs = toggleFixture.document.body.querySelectorAll('.anomalous-sources-scope-tab');
assert.equal(tabs.length, 2, 'two scope tabs exist');
tabs[1].click();
await toggleFixture.flush();
assert.ok(toggleFixture.document.body.textContent.includes('lib_model.safetensors'), 'library model visible after first switch');
// Switch back to workflow
tabs = toggleFixture.document.body.querySelectorAll('.anomalous-sources-scope-tab');
tabs[0].click();
await toggleFixture.flush();
assert.ok(toggleFixture.document.body.textContent.includes('wf_model.safetensors'), 'workflow model visible after switching back');
// Switch to library again! (Previously failed due to missing refreshUi)
tabs = toggleFixture.document.body.querySelectorAll('.anomalous-sources-scope-tab');
tabs[1].click();
await toggleFixture.flush();
assert.ok(toggleFixture.document.body.textContent.includes('lib_model.safetensors'), 'library model visible on repeated switch to library');
closeToggle();
console.log('model sources hub tests: all passed!');
-44
View File
@@ -1,44 +0,0 @@
import assert from 'node:assert/strict';
import { fixture, Element, all } from './ui_fixture.mjs';
const f = fixture();
const assistant = await f.module('ui_node_assistant.js');
const picker = await f.module('ui_node_model_picker.js');
const presets = await f.module('ui_node_presets.js');
const assistantOwner = {
assistantPanel: new Element('div'),
diagnoseNode() {},
};
assistant.initAssistantPanel.call(assistantOwner);
assert.equal(assistantOwner.assistantPanelInitialized, true);
assert.equal(assistantOwner.assistantPanel.children.length, 2);
assert.equal(typeof assistant.renderAssistantModelCard, 'function');
assert.equal(typeof assistant._loadAssistantHistory, 'function');
const combo = { type: 'combo', value: 'old.safetensors', options: { values: ['old.safetensors', 'new.safetensors', 'new.safetensors', 7] } };
const node = { widgets: [combo], widgets_values: ['old.safetensors'] };
assert.deepEqual([...picker.getNativeWidgetValues(node, combo)], ['old.safetensors', 'new.safetensors']);
assert.equal(picker.findModelComboWidget(node), combo);
picker.setWidgetValue(node, combo, 'new.safetensors');
assert.equal(combo.value, 'new.safetensors');
assert.equal(node.widgets_values[0], 'new.safetensors');
const ggufCombo = { type: 'combo', value: 'models/current.gguf', options: { values: ['models/current.gguf', 'models/next.gguf'] } };
const pickerNode = { id: 7, type: 'UNETLoader', widgets: [ggufCombo], widgets_values: ['models/current.gguf'] };
assert.equal(picker.findModelComboWidget(pickerNode), ggufCombo);
assert.doesNotThrow(() => picker._openGalleryReplacer.call({ diagnoseNode() {} }, pickerNode, ggufCombo));
const pickerModal = f.document.body.children.at(-1);
assert.ok(pickerModal.textContent.includes('All Models (2)'), 'picker renders its root folder');
assert.ok(pickerModal.textContent.includes('current.gguf'), 'picker renders GGUF model cards');
const pickerSearch = all(pickerModal).find(el => el.tagName === 'input' && el.type === 'search');
pickerSearch.value = 'next';
pickerSearch.oninput();
const visibleModelCards = all(pickerModal).filter(el => el.getAttribute('role') === 'button');
assert.equal(visibleModelCards.length, 1, 'picker search narrows the model cards');
assert.ok(visibleModelCards[0].textContent.includes('next.gguf'), 'picker search finds matching model paths');
assert.equal(typeof presets.applyLocalNodeParameters, 'function');
assert.equal(typeof presets.renderParameterPresets, 'function');
console.log('Node feature modules: assistant lifecycle, model combo helpers, and preset exports passed.');
-21
View File
@@ -1,21 +0,0 @@
import assert from 'node:assert/strict';
import { fixture } from './ui_fixture.mjs';
const f = fixture();
const catalog = await f.module('ui_notebooks.js');
const editor = await f.module('ui_notebook_editor.js');
const canvas = await f.module('notebook_canvas.js');
assert.deepEqual(Object.keys(catalog).sort(), [
'deleteCurrentNotebook',
'refreshNotebooks',
'saveCurrentNotebook',
'showNotebooks',
]);
assert.deepEqual(Object.keys(editor).sort(), [
'fillNotebookGalleries',
'renderNotebookEditor',
]);
assert.deepEqual(Object.keys(canvas).sort(), ['sendNotebookToCanvas']);
console.log('Notebook feature modules: catalog, editor, and canvas boundaries passed.');
-54
View File
@@ -1,54 +0,0 @@
import assert from 'node:assert/strict';
import { fixture } from './ui_fixture.mjs';
const f = fixture();
let mockNotebooks = [{ filename: '1.json', name: 'Test Note', data: { baseModel: 'SDXL', loras: [] } }];
f.fetch = async (url) => {
if (String(url).includes('/anomalous/base_models')) {
return { ok: true, json: async () => ({ base_models: ['SD 1.5', 'SDXL', 'Flux.1'] }) };
}
if (String(url).includes('/anomalous/compatible_models')) {
return { ok: true, json: async () => ({ models: [] }) };
}
if (String(url).includes('/anomalous/notebooks')) {
return { ok: true, json: async () => ({ notebooks: mockNotebooks }) };
}
return { ok: true, json: async () => ({ status: 'success' }) };
};
const notes = await f.module('ui_notebooks.js');
const editor = await f.module('ui_notebook_editor.js');
const owner = {
currentNotebook: null,
nbListEl: f.document.createElement('div'),
nbEditor: f.document.createElement('div'),
saveCurrentNotebook: async () => true,
deleteCurrentNotebook: async () => {},
sendNotebookToCanvas: () => {},
fillNotebookGalleries: () => {},
};
owner.renderNotebookEditor = editor.renderNotebookEditor.bind(owner);
owner.refreshNotebooks = notes.refreshNotebooks.bind(owner);
// 1. Test refreshNotebooks with autoOpenFirst=true
await owner.refreshNotebooks(true);
assert.ok(owner.currentNotebook, 'First notebook should be selected');
assert.equal(owner.currentNotebook.filename, '1.json');
assert.ok(owner.nbEditor.children.length >= 4, 'nbEditor should contain at least 4 main sections');
const fold = owner.nbEditor.querySelector('.anomalous-nb-models-fold');
assert.ok(fold, 'Companion models fold should exist in nbEditor');
assert.equal(fold.open, true, 'Companion models fold should be open by default');
const promptSection = owner.nbEditor.querySelector('.anomalous-nb-prompt-section');
assert.ok(promptSection, 'Prompt section should exist');
// 2. Test refreshNotebooks when list is empty
mockNotebooks = [];
owner.currentNotebook = null;
await owner.refreshNotebooks(false);
assert.ok(owner.nbEditor.textContent.includes('提示词笔记') || owner.nbEditor.textContent.includes('Prompt Notes') || owner.nbEditor.textContent.includes('暂无笔记'), 'Empty state should be displayed when no notebooks exist');
console.log('notebook_render_test: passed successfully');
-30
View File
@@ -1,30 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
const source = (await fs.readFile(new URL('../web/modules/ui_notebooks.js', import.meta.url), 'utf8'))
.replace(/^import .*;$/gm, '')
.replace("const t = (key, params) => translate(key, params);", 'const t = key => key;');
const { saveCurrentNotebook } = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
const calls = [];
let release;
globalThis.fetch = async (_, options) => {
calls.push(JSON.parse(options.body));
if (calls.length === 1) await new Promise(resolve => { release = resolve; });
return { ok: true, json: async () => ({ status: 'success' }) };
};
const owner = { currentNotebook: { filename: 'one.json', data: { text: 'first' } }, refreshNotebooks: async () => {} };
const first = saveCurrentNotebook.call(owner);
owner.currentNotebook.data.text = 'second';
const second = saveCurrentNotebook.call(owner);
await new Promise(resolve => setImmediate(resolve));
assert.equal(calls.length, 1);
release();
assert.deepEqual(await Promise.all([first, second]), [true, true]);
assert.deepEqual(calls.map(item => item.data.text), ['first', 'second']);
globalThis.document = { createElement: () => ({ setAttribute() {} }) };
owner.nbEditor = { prepend(element) { element.isConnected = true; } };
globalThis.fetch = async () => ({ ok: false, json: async () => ({ status: 'error' }) });
assert.equal(await saveCurrentNotebook.call(owner), false);
assert.equal(owner.nbSaveStatus.textContent, 'notebookSaveError');
assert.equal(owner.currentNotebook.data.text, 'second');
console.log('notebook save ordering and failure: ok');
-3
View File
@@ -1,3 +0,0 @@
{
"type": "module"
}
-63
View File
@@ -1,63 +0,0 @@
import assert from 'node:assert/strict';
import { fixture, Element, all } from './ui_fixture.mjs';
const f = fixture();
let name = 'Library source';
let content = 'original library prompt';
let available = true;
let fail = false;
f.fetch = async url => {
if (fail) throw new Error('offline');
return { ok: true, json: async () => url.includes('/materials?')
? { status: 'success', pages: 1, materials: available ? [{ filename: 'source.json', name }] : [] }
: { status: 'success', data: { kind: 'prompt_text', note: { promptEn: content } } } };
};
const { createPromptSourceDeck } = await f.module('ui_prompt_source_deck.js');
const { createViewScope } = await f.module('ui_lifecycle.js');
const scope = createViewScope();
const grid = new Element('div');
f.document.body.appendChild(grid);
const selected = [];
createPromptSourceDeck(grid, grid, scope, card => selected.push(card));
await f.flush();
const libraryCards = () => all(grid).filter(el => el.dataset.sourceFilename === 'source.json');
assert.equal(libraryCards().length, 1, 'initial sync needs no manual action');
assert.match(libraryCards()[0].textContent, /From Material Library/);
assert.equal(all(libraryCards()[0]).some(el => el.tagName === 'button'), false, 'source has no destructive action');
await libraryCards()[0].click();
assert.equal(selected[0].content, content);
assert.equal(selected[0].sourceKind, 'material');
name = 'Renamed source'; content = 'updated library prompt';
f.window.dispatch('focus'); await f.flush();
assert.match(libraryCards()[0].textContent, /Renamed source/);
await libraryCards()[0].click();
assert.equal(selected.at(-1).content, content);
assert.equal(selected[0].content, 'original library prompt', 'existing mixer copies are independent');
fail = true;
f.window.dispatch('focus'); await f.flush();
assert.equal(libraryCards().length, 1, 'failure must not erase cached source cards');
assert.match(grid.querySelector('.anomalous-source-sync-status').textContent, /sync failed/);
fail = false; available = false;
f.runTimers(); await f.flush();
assert.equal(libraryCards().length, 0, 'background retry reconciles deleted source');
assert.equal(grid.querySelectorAll('.anomalous-source-card-compact').length, 6, 'starter cards remain');
scope.dispose();
const requestCount = f.requests.length;
f.window.dispatch('focus'); f.runTimers(); await f.flush();
assert.equal(f.requests.length, requestCount, 'closing cancels background sync and listeners');
const lateScope = createViewScope();
let complete;
f.fetch = () => new Promise(resolve => { complete = resolve; });
createPromptSourceDeck(grid, grid, lateScope, () => {});
await f.flush();
lateScope.dispose();
complete({ ok: true, json: async () => ({ status: 'success', materials: [{ filename: 'late.json' }], pages: 1 }) });
await f.flush();
assert.equal(all(grid).some(el => el.dataset.sourceFilename === 'late.json'), false);
assert.equal(f.window.listenerCount(), 0);
assert.equal(f.document.listenerCount(), 0);
console.log('Library sync: automatic load, provenance, refresh, deletion, retry and close cancellation passed.');
-33
View File
@@ -1,33 +0,0 @@
import assert from 'node:assert/strict';
import { loadPromptSourceCards, mergePromptSourceCards } from '../web/modules/prompt_material_source.js';
const requests = [];
globalThis.fetch = async (url, options) => {
requests.push([url, options]);
const parsed = new URL(url, 'http://test');
const page = Number(parsed.searchParams.get('page'));
const filename = parsed.searchParams.get('filename');
return { ok: true, json: async () => filename
? { status: 'success', data: { kind: 'prompt_text', note: { promptEn: filename } } }
: { status: 'success', pages: 2, materials: page === 1 ? [{ filename: 'first.json' }] : [{ filename: 'first.json' }, { filename: 'last.json' }] } };
};
const controller = new AbortController();
const cards = await loadPromptSourceCards(controller.signal);
assert.deepEqual(cards.map(card => card.content), ['first.json', 'last.json']);
assert.equal(requests.length, 4, 'two pages and two unique details');
assert.equal(requests.every(([, options]) => options.signal === controller.signal), true);
const deck = [];
assert.equal(mergePromptSourceCards(deck, cards), 2);
assert.equal(mergePromptSourceCards(deck, cards), 0);
const local = { id: 'local', role: 'positive', content: 'local draft' };
deck.unshift(local);
const updated = { ...cards[0], title: 'Renamed source', content: 'Updated prompt' };
const otherSource = { ...updated, id: 'mat_positive_other.json', filename: 'other.json' };
mergePromptSourceCards(deck, [updated, otherSource]);
assert.deepEqual(deck, [local, updated, otherSource], 'refresh edits/removes by source identity, preserving equal text from different materials');
mergePromptSourceCards(deck, []);
assert.deepEqual(deck, [local], 'deleted library sources disappear without clearing local cards');
controller.abort();
await assert.rejects(loadPromptSourceCards(controller.signal), { name: 'AbortError' });
assert.equal(requests.length, 4);
console.log('prompt material source: later pages, deduplication, counts and cancellation passed');
-172
View File
@@ -1,172 +0,0 @@
import assert from 'node:assert/strict';
import { fixture, all } from './ui_fixture.mjs';
const f = fixture();
const studio = await f.module('ui_prompt_composer.js');
const translator = await f.module('ui_prompt_translator.js');
const owner = { setTriggerVisible(value) { this.triggerVisible = value; } };
const listeners = () => f.window.listenerCount() + f.document.listenerCount();
const clean = () => {
assert.equal(listeners(), 0, 'all global listeners must be released');
assert.equal(f.document.querySelector('.anomalous-prompt-studio-overlay'), null);
assert.equal(f.document.querySelector('.anomalous-translator-overlay'), null);
assert.equal(owner.sidePromptComposerControl, null);
assert.equal(owner.triggerVisible, true);
};
for (let i = 0; i < 3; i++) {
await studio.openPromptStudio(owner);
await f.flush();
assert.equal(f.document.querySelectorAll('.anomalous-source-card-compact').length, 6);
owner.sidePromptComposerControl.addBlock({ content: 'quality', role: 'positive', category: 'base' });
assert.equal(owner.promptPlanDraft.plan.positive, 'quality');
f.window.dispatch('keydown', { key: 'Escape' });
clean();
owner.promptPlanDraft = null;
}
await studio.openPromptStudio(owner);
await studio.openPromptStudio(owner);
assert.equal(f.document.querySelectorAll('.anomalous-prompt-studio-overlay').length, 1);
const beforeDrag = listeners();
const handle = f.document.querySelector('.anomalous-studio-resize-handle');
f.document.body.style.cursor = 'crosshair';
handle.onmousedown({ button: 0, clientX: 100, preventDefault() {} });
assert.equal(listeners(), beforeDrag + 2);
f.window.dispatch('mousemove', { clientX: 160 });
assert.equal(f.document.querySelector('.anomalous-prompt-studio-drawer').style.width, '640px');
studio.closePromptStudio(owner);
assert.equal(f.document.body.style.cursor, 'crosshair');
clean();
await studio.openPromptStudio(owner);
owner.sidePromptComposerControl.addBlock({ content: 'quality', role: 'positive' });
await f.button(f.document.body, '⛶').click();
assert.ok(f.document.querySelector('.anomalous-prompt-inspector-overlay'));
f.window.dispatch('keydown', { key: 'Escape' });
assert.equal(f.document.querySelector('.anomalous-prompt-inspector-overlay'), null);
assert.ok(f.document.querySelector('.anomalous-prompt-studio-overlay'), 'first Escape closes only inspector');
f.window.dispatch('keydown', { key: 'Escape' });
clean();
// Full-text edits remain authoritative after recomposition, save and reopening.
await studio.openPromptStudio(owner);
owner.sidePromptComposerControl.addBlock({ content: 'scene', role: 'positive' });
await f.button(f.document.body, 'Negative (').click();
owner.sidePromptComposerControl.addBlock({ content: 'bad anatomy', role: 'negative' });
await f.button(f.document.body, 'Positive (').click();
await f.button(f.document.body, '⛶').click();
const inspectorText = f.document.querySelector('.anomalous-inspector-textarea');
inspectorText.value = 'edited whole prompt';
inspectorText.oninput();
f.window.dispatch('keydown', { key: 'Escape' });
await f.button(f.document.body, '📋').click();
assert.equal(f.clipboard.at(-1), 'edited whole prompt');
assert.equal(owner.promptPlanDraft.plan.negative, 'bad anatomy');
const name = f.document.querySelector('.anomalous-prompt-name-input');
name.value = 'round trip'; name.oninput();
f.fetch = async url => ({ ok: true, json: async () => String(url).includes('/materials?')
? { status: 'success', materials: [], pages: 1 }
: { status: 'success', material: { filename: 'saved.json' } } });
await f.button(f.document.body, '💾').click();
const savedRequest = f.requests.findLast(([url]) => url.includes('save_prompt_plan'));
const saved = JSON.parse(savedRequest[1].body);
assert.equal(saved.name, 'round trip');
assert.equal(saved.plan.positive, 'edited whole prompt');
assert.equal(saved.plan.negative, 'bad anatomy');
f.window.dispatch('focus');
await f.flush();
assert.ok(f.requests.at(-1)[0].includes('category=prompts'));
studio.closePromptStudio(owner);
await studio.openPromptStudio(owner);
assert.equal(owner.promptPlanDraft.plan.positive, 'edited whole prompt');
await f.button(f.document.body, '✨').click();
assert.equal(owner.promptPlanDraft.plan.positive, '');
assert.equal(owner.promptPlanDraft.plan.negative, '');
studio.closePromptStudio(owner);
clean();
// Closing while a saved plan is loading must not replace the draft or reopen.
const keptDraft = owner.promptPlanDraft;
let releasePlan;
f.fetch = async () => new Promise(resolve => { releasePlan = () => resolve({ ok: true, json: async () => ({ status: 'success', data: { kind: 'prompt_plan', name: 'late', plan: { positive: 'late text' } } }) }); });
const loadingPlan = studio.showPromptComposer(owner, { filename: 'late.json' });
await f.flush();
studio.closePromptStudio(owner);
releasePlan(); await loadingPlan;
assert.equal(owner.promptPlanDraft, keptDraft);
clean();
for (const path of ['button', 'escape', 'backdrop']) {
translator.openPromptTranslator(owner);
translator.openPromptTranslator(owner);
assert.equal(f.document.querySelectorAll('.anomalous-translator-overlay').length, 1);
const overlay = f.document.querySelector('.anomalous-translator-overlay');
if (path === 'button') f.document.querySelector('.anomalous-translator-close').click();
if (path === 'escape') f.document.dispatch('keydown', { key: 'Escape' });
if (path === 'backdrop') { overlay.classList.remove('is-sidebar'); overlay.onclick({ target: overlay }); }
clean();
}
// A delayed translation may not write into a new selection, overwrite an edited
// widget, or write after the translator closes. Kana must use the EN service too.
for (const change of ['selection', 'widget', 'close', 'none']) {
translator.openPromptTranslator(owner);
const target = f.document.querySelector('.is-target');
target.value = 'かわいい';
let release;
f.fetch = async (url, options) => {
assert.equal(JSON.parse(options.body).target_lang, 'en');
return new Promise(resolve => { release = () => resolve({ ok: true, json: async () => ({ translated: 'cute' }) }); });
};
const pending = f.button(f.document.body, 'Translate to EN & Write').click();
await f.flush();
if (change === 'selection') f.app.canvas.selected_nodes = {};
if (change === 'widget') f.node.widgets[0].value = 'edited';
if (change === 'close') f.document.querySelector('.anomalous-translator-close').click();
release?.(); await pending;
assert.equal(f.node.widgets[0].value, change === 'widget' ? 'edited' : change === 'none' ? 'cute' : 'original');
f.document.querySelector('.anomalous-translator-close')?.click();
f.app.canvas.selected_nodes = { 1: f.node };
f.node.widgets[0].value = 'original';
(await f.module('translation_service.js')).clearTranslationCache();
clean();
}
// Verify extractPromptFromNode with Chinese localized widget names & custom nodes
const chineseNode = {
id: 2,
type: 'CLIPTextEncode',
widgets: [{ name: '文本', type: 'text', value: '1girl, sakura, masterpiece' }],
widgets_values: ['1girl, sakura, masterpiece'],
};
assert.equal(translator.extractPromptFromNode(chineseNode)?.text, '1girl, sakura, masterpiece');
// Verify auto-load on open with selected node
f.app.canvas.selected_nodes = { 2: chineseNode };
translator.openPromptTranslator(owner);
const sourceArea = f.document.querySelector('.anomalous-translator-textarea');
assert.equal(sourceArea.value, '1girl, sakura, masterpiece');
// Verify live canvas selection sync while translator is open
const node3 = {
id: 3,
type: 'CLIPTextEncode',
widgets: [{ name: 'text', type: 'customtext', value: 'cyberpunk street, night' }],
};
f.app.canvas.selected_nodes = { 3: node3 };
f.app.canvas.onNodeSelected(node3);
assert.equal(sourceArea.value, 'cyberpunk street, night');
// Verify manual Read Node button click
sourceArea.value = '';
await f.button(f.document.body, 'Read Node').click();
assert.equal(sourceArea.value, 'cyberpunk street, night');
f.document.querySelector('.anomalous-translator-close').click();
f.app.canvas.selected_nodes = { 1: f.node };
clean();
assert.deepEqual(f.errors, []);
console.log('prompt UI: reopen/close, Escape nesting, resize cleanup, real card insertion, delayed node writes and live selection sync passed');
-30
View File
@@ -1,30 +0,0 @@
import assert from 'node:assert/strict';
import { all, fixture, Element } from './ui_fixture.mjs';
const f = fixture();
const recipes = await f.module('ui_recipes.js');
const good = { filename: 'sample.json', data: { name: 'Visible recipe', params: { baseModel: '<Model>' } } };
const broken = { filename: 'bad.json', data: { name: 'Broken card', get params() { throw new Error('test-only invalid card'); } } };
const owner = {
recipeListContainer: new Element('div'),
refreshRecipes: async () => {},
};
const errors = [];
const originalConsoleError = console.error;
console.error = (...args) => errors.push(args);
try {
recipes.renderRecipeList.call(owner, [good]);
assert.equal(errors.length, 0, 'Model metadata must not make a recipe disappear');
assert.equal(owner.recipeListContainer.children[0].className, 'anomalous-recipe-card');
assert.ok(all(owner.recipeListContainer).some(element => element.tagName === 'span' && element.textContent === '<Model>'));
recipes.renderRecipeList.call(owner, [broken, good]);
assert.equal(errors.length, 1);
assert.equal(owner.recipeListContainer.children[0].attrs.role, 'alert');
assert.equal(owner.recipeListContainer.children[1].className, 'anomalous-recipe-card');
} finally {
console.error = originalConsoleError;
}
console.log('PASS recipe model label stays text; failed card shows an alert and does not hide other recipes.');
-72
View File
@@ -1,72 +0,0 @@
import assert from 'node:assert/strict';
import { all, fixture, Element } from './ui_fixture.mjs';
const f = fixture();
const versions = await f.module('ui_recipe_versions.js');
const gallery = await f.module('ui_recipe_gallery.js');
const overview = await f.module('ui_recipe_overview.js');
const parameters = await f.module('ui_recipe_parameters.js');
let refreshed = 0;
let finishReason = '';
const versionOwner = {
recipeDetailFilename: 'example.json',
async refreshRecipes() { refreshed += 1; },
};
const versionContent = new Element('div');
versions.renderVersions(
versionContent,
versionOwner,
{ timestamp: 1, workflow_fingerprint: { value: 'abcdef1234567890' } },
[{ version: 'v1.json', name: 'Version 1', timestamp: 1, workflow_fingerprint: { value: '1234567890abcdef' } }],
reason => { finishReason = reason; },
);
const restore = all(versionContent).find(element => element.classList.contains('anomalous-btn-danger'));
assert.ok(restore, 'version history renders a restore action');
await restore.click();
await f.flush();
assert.equal(refreshed, 1);
assert.equal(finishReason, 'restored');
const restoreRequest = f.requests.find(([url, options]) => url === '/anomalous/restore_recipe_version' && options.method === 'POST');
assert.deepEqual(JSON.parse(restoreRequest[1].body), { filename: 'example.json', version: 'v1.json' });
let openedDetail = null;
const galleryOwner = {
recipeDetailFilename: 'example.json',
showGalleryViewer() {},
showImageWorkbench(sourceImage, imageUrl, options) { openedDetail = { sourceImage, imageUrl, options }; },
};
const galleryContent = new Element('div');
const images = [{ filename: 'image.png', subfolder: 'outputs', type: 'output' }];
gallery.renderRecipeGallery(galleryContent, galleryOwner, {}, { status: 'ready', scanned: 1, images }, () => {});
const details = all(galleryContent).find(element => element.tagName === 'button' && element.classList.contains('anomalous-btn-primary'));
assert.ok(details, 'gallery renders the image detail action');
await details.click();
assert.equal(openedDetail.options.items.length, 1);
assert.equal(openedDetail.options.items[0].sourceImage.filename, 'image.png');
assert.match(openedDetail.imageUrl, /^\/view\?/);
const overviewContent = new Element('div');
overview.renderOverview(overviewContent, { recipeDetailFilename: 'example.json' }, {
name: 'Overview recipe',
workflow_scope: 'complete',
workflow: { nodes: [] },
params: { steps: 24, cfg: 7 },
tags: ['portrait'],
}, [], () => {}, {
async applyRecipeToCanvas() { return true; },
promptValues() { return { entries: [] }; },
renderModelComposition(container) { container.appendChild(new Element('div')); },
});
assert.ok(all(overviewContent).some(element => element.classList.contains('anomalous-recipe-detail-hero')));
assert.ok(overviewContent.textContent.includes('Overview recipe'));
const promptSource = {
params: { nodes: [{ id: 1, type: 'CLIPTextEncode', widgets: [{ name: 'text', index: 0, value: 'cinematic portrait' }] }] },
workflow: { nodes: [{ id: 1, type: 'CLIPTextEncode', widgets_values: ['cinematic portrait'] }] },
};
const promptSummary = parameters.promptValues(promptSource, promptSource);
assert.equal(promptSummary.entries.length, 1);
assert.equal(promptSummary.entries[0].text, 'cinematic portrait');
console.log('Recipe detail subviews: version restore and gallery workbench handoff passed.');
-49
View File
@@ -1,49 +0,0 @@
import assert from 'node:assert/strict';
import { fixture, Element } from './ui_fixture.mjs';
const f = fixture();
const matching = await f.module('ui_recipe_model_matching.js');
let refreshCount = 0;
f.fetch = async url => {
if (String(url).includes('/update_recipe')) {
return { ok: true, json: async () => ({ status: 'success' }) };
}
throw new Error(`Unexpected request: ${url}`);
};
const reference = {
node_id: 1,
node_type: 'CheckpointLoaderSimple',
widget_index: 0,
widget_name: 'ckpt_name',
saved_value: 'old.safetensors',
category: 'checkpoint',
identity: { status: 'unverified' },
localMatch: {
filename: 'new.safetensors',
identity: { status: 'verified', sha256: 'a'.repeat(64), size: 123 },
},
};
const recipe = {
workflow: { nodes: [{ id: 1, widgets_values: ['old.safetensors'] }], extra: {} },
params: { baseModel: 'old.safetensors', model_references: [{ ...reference, localMatch: undefined }] },
};
const owner = {
recipeDetailFilename: 'example.json',
async refreshRecipes() { refreshCount += 1; },
};
const status = new Element('span');
let rerenderCount = 0;
const applied = await matching.applyLocalModelMatch(owner, recipe, reference, status, () => { rerenderCount += 1; });
assert.equal(applied, true);
assert.equal(recipe.workflow.nodes[0].widgets_values[0], 'new.safetensors');
assert.equal(recipe.params.baseModel, 'new.safetensors');
assert.equal(reference.saved_value, 'new.safetensors');
assert.equal(reference.localMatch, null);
assert.equal(refreshCount, 1);
assert.equal(rerenderCount, 1);
const request = f.requests.find(([url]) => url === '/anomalous/update_recipe');
assert.ok(request, 'accepted local matches persist through the recipe metadata endpoint');
console.log('Recipe model matching: accepted replacement updates workflow, metadata, and live state.');
-26
View File
@@ -1,26 +0,0 @@
import assert from 'node:assert/strict';
import {
formatRecipeResolution,
isVolatileParameter,
parameterNodeOrder,
parseEditorValue,
topologicalSortNodes,
} from '../web/modules/ui_recipe_parameter_utils.js';
const nodes = [{ id: 3 }, { id: 1 }, { id: 2 }];
const links = [[10, 1, 0, 2, 0], [11, 2, 0, 3, 0]];
assert.deepEqual(topologicalSortNodes(nodes, links), ['1', '2', '3']);
const ordered = parameterNodeOrder({
workflow: { nodes, links },
params: { nodes: [{ id: 3, type: 'Output' }, { id: 1, type: 'Input' }, { id: 2, type: 'Middle' }] },
});
assert.deepEqual(ordered.map(item => String(item.summary.id)), ['1', '2', '3']);
assert.equal(isVolatileParameter({ type: 'KSampler' }, { name: 'control_after_generate' }, 0), true);
assert.equal(isVolatileParameter({ type: 'Other' }, { name: 'steps' }, 0), false);
assert.equal(parseEditorValue('12.5', 1), 12.5);
assert.equal(parseEditorValue('false', true), false);
assert.deepEqual(parseEditorValue('{"a":2}', { a: 1 }), { a: 2 });
assert.equal(formatRecipeResolution({ width: 1024, height: 768 }), '1024x768');
console.log('Recipe parameter utils: topology, volatility, parsing, and resolution formatting passed.');
-74
View File
@@ -1,74 +0,0 @@
import assert from 'node:assert/strict';
import { fixture } from './ui_fixture.mjs';
const storageMap = new Map();
const mockStorage = {
getItem: (k) => storageMap.has(k) ? storageMap.get(k) : null,
setItem: (k, v) => storageMap.set(k, String(v)),
removeItem: (k) => storageMap.delete(k),
};
const f = fixture({ storageOverride: mockStorage });
const layoutMod = await f.module('shortcut_layout.js');
const registryMod = await f.module('tool_registry.js');
const eq = (a, b) => assert.deepEqual([...a], [...b]);
// 1. Initial state without storage: returns DEFAULT_PINNED_SHORTCUTS
eq(layoutMod.loadShortcutLayout(mockStorage), ['scan', 'doctor', 'assistant', 'materials']);
// 2. Capacity limit check: already has 4 items, cannot pin another unpinned tool
assert.equal(layoutMod.isToolPinned('model-sources', mockStorage), false);
const fullRes = layoutMod.pinTool('model-sources', -1, mockStorage);
assert.equal(fullRes.success, false);
assert.equal(fullRes.reason, 'capacity_full');
assert.equal(layoutMod.loadShortcutLayout(mockStorage).length, 4);
// 3. Reordering existing pinned tool does not trigger capacity full
const reorderRes = layoutMod.pinTool('materials', 0, mockStorage);
assert.equal(reorderRes.success, true);
assert.equal(reorderRes.reordered, true);
eq(layoutMod.loadShortcutLayout(mockStorage), ['materials', 'scan', 'doctor', 'assistant']);
// 4. Unpin tool
const unpinRes = layoutMod.unpinTool('doctor', mockStorage);
assert.equal(unpinRes.success, true);
assert.equal(unpinRes.changed, true);
eq(layoutMod.loadShortcutLayout(mockStorage), ['materials', 'scan', 'assistant']);
// 5. Pin new tool when space is available
const pinRes = layoutMod.pinTool('model-sources', 1, mockStorage);
assert.equal(pinRes.success, true);
eq(layoutMod.loadShortcutLayout(mockStorage), ['materials', 'model-sources', 'scan', 'assistant']);
// 6. Direct reorder
layoutMod.reorderShortcut(1, 3, mockStorage);
eq(layoutMod.loadShortcutLayout(mockStorage), ['materials', 'scan', 'assistant', 'model-sources']);
// 7. Reset to defaults
layoutMod.resetShortcutLayout(mockStorage);
eq(layoutMod.loadShortcutLayout(mockStorage), ['scan', 'doctor', 'assistant', 'materials']);
// 8. Explicit empty array is valid and preserved
layoutMod.saveShortcutLayout([], mockStorage);
eq(layoutMod.loadShortcutLayout(mockStorage), []);
// 9. Unknown/corrupted storage handling: resets to defaults
mockStorage.setItem(layoutMod.SHORTCUT_STORAGE_KEY, '{"schemaVersion": 999, "pinned": ["invalid"]}');
eq(layoutMod.loadShortcutLayout(mockStorage), ['scan', 'doctor', 'assistant', 'materials']);
mockStorage.setItem(layoutMod.SHORTCUT_STORAGE_KEY, 'not-json');
eq(layoutMod.loadShortcutLayout(mockStorage), ['scan', 'doctor', 'assistant', 'materials']);
// 10. Storage failure fallback to memory
const failingStorage = {
getItem() { throw new Error('Blocked'); },
setItem() { throw new Error('Blocked'); },
};
const memLayout = layoutMod.loadShortcutLayout(failingStorage);
eq(memLayout, ['scan', 'doctor', 'assistant', 'materials']);
const memSave = layoutMod.saveShortcutLayout(['doctor', 'materials'], failingStorage);
assert.equal(memSave.success, true);
assert.equal(memSave.memoryOnly, true);
console.log('shortcut_layout: default, capacity, unpin, pin, reorder, reset, empty, corrupted, and error fallback passed.');
-61
View File
@@ -1,61 +0,0 @@
import assert from 'node:assert/strict';
import { fixture, Element } from './ui_fixture.mjs';
const storageMap = new Map();
const mockStorage = {
getItem: (k) => storageMap.has(k) ? storageMap.get(k) : null,
setItem: (k, v) => storageMap.set(k, String(v)),
removeItem: (k) => storageMap.delete(k),
};
const f = fixture({ storageOverride: mockStorage });
const organizer = await f.module('ui_shortcut_organizer.js');
const layoutMod = await f.module('shortcut_layout.js');
const registryMod = await f.module('tool_registry.js');
// 1. Module loaded successfully
assert.ok(organizer.bindDraggableTool);
assert.ok(organizer.openShortcutButtonMenu);
assert.ok(organizer.openToolboxCardMenu);
// 2. Click vs Drag deadzone contract
let clicked = false;
const btn = new Element('button');
btn.id = 'test-btn';
btn.getBoundingClientRect = () => ({ left: 100, top: 200, width: 32, height: 32, right: 132, bottom: 232 });
organizer.bindDraggableTool(btn, {
toolId: 'assistant',
source: 'shortcut',
onToolClick: () => { clicked = true; }
});
// Pointer down and up at same spot (0px movement <= 6px) -> regular click
btn.dispatch('pointerdown', { clientX: 100, clientY: 200, button: 0, pointerType: 'mouse' });
f.document.dispatch('pointerup', { clientX: 100, clientY: 200, preventDefault() {}, stopPropagation() {} });
assert.equal(clicked, true, 'Click should trigger when pointer moves <= 6px');
// Reset and move > 6px (7px dx)
clicked = false;
let layoutChanged = false;
const card = new Element('div');
card.id = 'test-card';
card.getBoundingClientRect = () => ({ left: 10, top: 10, width: 100, height: 50, right: 110, bottom: 60 });
organizer.bindDraggableTool(card, {
toolId: 'workflow-transfer',
source: 'toolbox',
onLayoutChange: () => { layoutChanged = true; },
onToolClick: () => { clicked = true; }
});
card.dispatch('pointerdown', { clientX: 10, clientY: 10, button: 0, pointerType: 'mouse' });
f.document.dispatch('pointermove', { clientX: 20, clientY: 10 }); // dx = 10 > 6px
assert.equal(organizer.isOrganizingDragActive(), true, 'Drag should activate past 6px threshold');
// Escape should cancel drag
f.document.dispatch('keydown', { key: 'Escape' });
assert.equal(organizer.isOrganizingDragActive(), false, 'Escape should cancel active drag');
assert.equal(clicked, false, 'Cancelled drag must not fire click');
console.log('ui_shortcut_organizer: click deadzone, threshold activation, and Escape cancellation passed.');
-312
View File
@@ -1,312 +0,0 @@
import assert from 'node:assert/strict';
import { all, fixture, Element } from './ui_fixture.mjs';
const f = fixture();
f.document.getElementById = id => all(f.document.body).find(element => element.id === id) || null;
Element.prototype.removeChild = function(child) {
if (child?.parentNode !== this) throw new Error('NotFoundError');
child.remove();
return child;
};
Object.defineProperty(Element.prototype, 'lastChild', {
configurable: true,
get() { return this.children.at(-1) || null; },
});
const container = new Element('div');
container.id = 'anomalous-container';
f.document.body.append(container);
const sidebar = await f.module('ui_sidebar.js');
const grid = await f.module('ui_grid.js');
const navigation = await f.module('ui_browser_navigation.js');
const browser = await f.module('browser.js');
assert.equal(typeof sidebar.createDOM, 'function');
assert.equal(typeof grid.loadModels, 'function');
assert.equal(typeof browser.AnomalousBrowser, 'function');
const browserShell = Object.create(browser.AnomalousBrowser.prototype);
browserShell.triggerButton = new Element('button');
browserShell.entryMode = 'floating';
browserShell.setTriggerVisible(true);
assert.equal(browserShell.triggerButton.classList.contains('anomalous-trigger-hidden'), false);
browserShell.entryMode = 'menu';
browserShell.setTriggerVisible(true);
assert.equal(browserShell.triggerButton.classList.contains('anomalous-trigger-hidden'), true, 'non-floating entry modes keep the trigger hidden');
const navigationOwner = {
grid: new Element('div'),
detailPanel: new Element('div'),
galleryPanel: new Element('div'),
nbPanel: new Element('div'),
doctorPanel: new Element('div'),
assistantPanel: new Element('div'),
};
navigation.hideAllPanels.call(navigationOwner);
assert.ok(Object.values(navigationOwner).filter(value => value instanceof Element).every(panel => panel.style.display === 'none'));
const restoredGrid = new Element('div');
restoredGrid.style.display = 'none';
const restoredDetail = new Element('div');
restoredDetail.style.display = 'block';
const workspaceOwner = {
grid: restoredGrid,
detailPanel: restoredDetail,
nbPanel: new Element('div'),
workspaceReturnState: { grid: 'grid', detail: 'none' },
recipeModelReturn: null,
};
navigation.closeWorkspace.call(workspaceOwner);
assert.equal(workspaceOwner.nbPanel.style.display, 'none');
assert.equal(restoredGrid.style.display, 'grid', 'workspace close restores the shared navigation state');
assert.equal(restoredDetail.style.display, 'none');
const utilityContainer = new Element('div');
const sidebarActions = new Element('div');
const sidebarWrapper = new Element('div');
sidebarWrapper.append(sidebarActions);
utilityContainer.append(sidebarWrapper);
f.document.body.append(utilityContainer);
const utilityOwner = {
sidebarActions,
sidebarWrapper,
energySaving: true,
cardThumbnailMode: 'balanced',
detailPanel: new Element('div'),
currentDetailModel: null,
renderCount: 0,
loadCount: 0,
renderSidebar() { this.renderCount += 1; },
loadModels() { this.loadCount += 1; },
showHelp() {},
openFolderManager() {},
};
const toolboxModule = await f.module('ui_toolbox.js');
const settingsModule = await f.module('ui_settings_hub.js');
let settingsControl;
const toolboxControl = toolboxModule.createToolbox(utilityOwner, {
container: utilityContainer,
menuBtn: new Element('button'),
toolboxIcon: 'tools',
isScanning: () => false,
getSettingsButton: () => settingsControl.button,
onBeforeOpen: () => settingsControl?.close(),
});
const control = () => new Element('button');
settingsControl = settingsModule.createSettingsHub(utilityOwner, {
container: utilityContainer,
savedScale: '1',
savedBgOpacity: '0.2',
updateLangClass() {},
modelsBtn: control(),
galleryBtn: control(),
toolboxBtn: toolboxControl.button,
nbBtn: control(),
dockBtn: control(),
updateNoticeBtn: control(),
icons: { MODELS: 'models', GALLERY: 'gallery', HELP: 'help', RECIPES: 'recipes', SETTINGS: 'settings' },
onBeforeOpen: () => toolboxControl.close(),
});
toolboxControl.mount();
assert.equal(sidebarActions.children.at(0), toolboxControl.button);
assert.equal(sidebarActions.children.at(-1), settingsControl.button);
const toolboxToolIds = all(toolboxControl.modal).map(element => element.getAttribute('data-tool-id')).filter(Boolean);
assert.ok(toolboxToolIds.includes('prompt-studio'));
assert.ok(!toolboxToolIds.includes('scan'), 'fixed shortcuts stay out of the toolbox catalog');
await toolboxControl.button.click();
assert.equal(toolboxControl.modal.style.display, 'flex');
await settingsControl.button.click();
assert.equal(toolboxControl.modal.style.display, 'none', 'settings closes the toolbox');
assert.equal(settingsControl.modal.style.display, 'flex');
await toolboxControl.button.click();
assert.equal(settingsControl.modal.style.display, 'none', 'toolbox closes settings');
settingsControl.close();
const languageButton = all(settingsControl.modal).find(element => element.classList.contains('anomalous-lang-btn'));
await languageButton.click();
await f.flush();
assert.ok(utilityOwner.renderCount > 0 && utilityOwner.loadCount > 0, 'language refresh updates live browser views');
const help = await f.module('ui_help.js');
const helpOwner = { modal: container };
help.showHelp.call(helpOwner);
assert.ok(helpOwner.helpModal?.isConnected, 'help opens inside the browser container');
await f.button(helpOwner.helpModal, 'Close').click();
assert.equal(helpOwner.helpModal.isConnected, false, 'help close removes the dialog');
let loadFoldersCount = 0;
f.fetch = async url => {
if (String(url).includes('/all_folder_types')) {
return { ok: true, json: async () => ({ folder_types: [{ type: 'checkpoints', visible: true }], folder_view_mode: 'abstract' }) };
}
return { ok: true, json: async () => ({ status: 'ok' }) };
};
const folders = await f.module('ui_folder_manager.js');
const folderOwner = {
firstLoadDone: true,
expandedFolders: new Set(['checkpoints']),
async loadFolders() { loadFoldersCount += 1; },
};
await folders.openFolderManager.call(folderOwner);
await f.flush();
let folderModal = f.document.getElementById('anomalous-folder-manager-modal');
await f.button(folderModal, 'Cancel').click();
assert.equal(folderModal.isConnected, false, 'folder manager cancel only closes the dialog');
assert.equal(f.requests.filter(([, options]) => options.method === 'POST').length, 0, 'cancel does not write configuration');
await folders.openFolderManager.call(folderOwner);
await f.flush();
folderModal = f.document.getElementById('anomalous-folder-manager-modal');
await f.button(folderModal, 'Save & Reload').click();
assert.equal(loadFoldersCount, 1, 'saving refreshes the sidebar folder data');
assert.equal(folderOwner.firstLoadDone, false);
assert.equal(folderOwner.expandedFolders.size, 0);
assert.ok(f.requests.some(([url, options]) => url === '/anomalous/save_config' && options.method === 'POST'));
const wizard = await f.module('ui_scan_wizard.js');
const scanOwner = {
currentType: 'checkpoints',
currentPathIdx: 0,
currentSubfolder: '/',
loadModels() {},
};
wizard.openScanWizard.call(scanOwner, { isGlobal: true });
let wizardModal = f.document.getElementById('anomalous-wizard-modal');
await f.button(wizardModal, 'Cancel').click();
assert.equal(wizardModal.isConnected, false, 'closing the wizard does not launch a scan');
// Test backdrop click dismissal
wizard.openScanWizard.call(scanOwner, { isGlobal: true });
wizardModal = f.document.getElementById('anomalous-wizard-modal');
wizardModal.onclick({ target: wizardModal });
assert.equal(wizardModal.isConnected, false, 'clicking backdrop dismisses wizard');
// Test Escape key dismissal
wizard.openScanWizard.call(scanOwner, { isGlobal: true });
wizardModal = f.document.getElementById('anomalous-wizard-modal');
f.document.dispatch('keydown', { key: 'Escape' });
assert.equal(wizardModal.isConnected, false, 'pressing Escape dismisses wizard');
wizard.openScanWizard.call(scanOwner, { targetFiles: 'model.safetensors' });
wizardModal = f.document.getElementById('anomalous-wizard-modal');
await f.button(wizardModal, 'Execute').click();
await f.flush();
const scanRequest = f.requests.find(([url, options]) => String(url).startsWith('/anomalous/scan?') && options.method === 'POST');
assert.ok(scanRequest, 'single-model scan uses the scoped scan endpoint');
assert.deepEqual(JSON.parse(scanRequest[1].body).target_files, ['model.safetensors']);
assert.equal(wizardModal.isConnected, false, 'launching a scan closes only the wizard UI');
assert.deepEqual(f.errors, [], 'feature flows complete without user-facing errors');
// Direct single-model precision scan without opening wizard modal
let directScanRefreshed = false;
let hashesReloaded = false;
let combosRefreshed = false;
f.window.anomalous_reload_hashes = async () => { hashesReloaded = true; };
f.app.refreshComboInNodes = async () => { combosRefreshed = true; };
const directScanOwner = {
currentType: 'loras',
currentPathIdx: 0,
currentSubfolder: '/',
loadModels() { directScanRefreshed = true; },
};
const dummyBtn = f.document.createElement('button');
const dummySvg = f.document.createElement('svg');
dummyBtn.appendChild(dummySvg);
f.fetch = async (url) => {
if (String(url).startsWith('/anomalous/scan?')) {
return { ok: true, json: async () => ({ status: 'ok' }) };
}
if (String(url).startsWith('/anomalous/scan_status?')) {
return { ok: true, json: async () => ({ scanning: false, interrupted: false }) };
}
return { ok: true, json: async () => ({}) };
};
await wizard.triggerDirectModelScan.call(directScanOwner, { filename: 'my_lora.safetensors', name: 'My Lora' }, dummyBtn);
assert.equal(dummyBtn.classList.contains('anomalous-radar-spinning'), true, 'button spins immediately');
f.runTimers();
await f.flush();
const directScanReq = f.requests.find(([url, options]) => String(url).includes('type=loras') && options.method === 'POST');
assert.ok(directScanReq, 'direct scan triggers POST /anomalous/scan');
assert.deepEqual(JSON.parse(directScanReq[1].body).target_files, ['my_lora.safetensors']);
assert.equal(JSON.parse(directScanReq[1].body).skip_rename, true, 'direct single model scan preserves physical file name');
assert.equal(directScanRefreshed, true, 'browser.loadModels called after scan completes');
assert.equal(hashesReloaded, true, 'anomalous_reload_hashes called after direct scan');
assert.equal(combosRefreshed, true, 'app.refreshComboInNodes called after direct scan');
assert.equal(dummyBtn.classList.contains('anomalous-radar-spinning'), false, 'radar spinning class removed after completion');
const scanProgressPanel = f.document.getElementById('anomalous-scan-progress');
assert.ok(scanProgressPanel, 'scan progress panel displayed in DOM');
assert.equal(scanProgressPanel.classList.contains('is-complete'), true, 'scan progress panel marked complete');
assert.equal(f.errors.length, 0, 'no alert or errors triggered on scan completion');
// Verification of factual scan completion feedback (strictly factual, zero speculative guidance)
f.window.anomalous_browser_lang = 'zh';
// 1. Civitai with cover
f.fetch = async () => ({
ok: true,
json: async () => ({
metadata: { id: 12345, modelId: 67890 },
preview_url: '/api/view?filename=cover.png'
})
});
let toastMsg = await wizard.formatScanCompletionToast({ filename: 'civitai_model.safetensors' });
assert.equal(toastMsg.includes('已从 Civitai 获取封面与模型信息'), true, 'reports civitai cover & info');
assert.equal(toastMsg.includes('编辑') || toastMsg.includes('右键'), false, 'no speculative advice');
// 2. Civitai without cover
f.fetch = async () => ({
ok: true,
json: async () => ({
metadata: { id: 12345, modelId: 67890 },
preview_url: null
})
});
toastMsg = await wizard.formatScanCompletionToast({ filename: 'civitai_no_cover.safetensors' });
assert.equal(toastMsg.includes('已匹配到 Civitai 信息(线上未提供封面)'), true, 'reports civitai without cover');
assert.equal(toastMsg.includes('编辑') || toastMsg.includes('右键'), false, 'no speculative advice');
// 3. Non-Civitai with baseModel
f.fetch = async () => ({
ok: true,
json: async () => ({
metadata: { id: -1, baseModel: 'FLUX.1-D' },
preview_url: null
})
});
toastMsg = await wizard.formatScanCompletionToast({ filename: 'flux_dev.safetensors' });
assert.equal(toastMsg, 'ℹ️ 非 Civitai 模型:已识别底模为 [FLUX.1-D]', 'factual non-civitai base model info');
assert.equal(toastMsg.includes('编辑') || toastMsg.includes('右键'), false, 'no speculative advice');
// 4. Non-Civitai without baseModel
f.fetch = async () => ({
ok: true,
json: async () => ({
metadata: { id: -1 },
preview_url: null
})
});
toastMsg = await wizard.formatScanCompletionToast({ filename: 'unknown.safetensors' });
assert.equal(toastMsg, 'ℹ️ 未在 Civitai 匹配到此模型', 'factual unmatched message');
assert.equal(toastMsg.includes('编辑') || toastMsg.includes('右键'), false, 'no speculative advice');
// 5. Realistic backend response format with model wrapper and python metadata fields
f.fetch = async () => ({
ok: true,
json: async () => ({
status: 'success',
model: {
filename: 'flux1-dev-fp8_flux1-dev-fp8.safetensors',
metadata: {
name: 'flux1-dev-fp8',
baseModel: 'Flux.1 D',
civitai_url: '',
hash: '8e91b68084b53a7fc44ed2a3756d821e355ac1a7b6fe29be760c1db532f3d88a'
},
preview_url: ''
}
})
});
toastMsg = await wizard.formatScanCompletionToast({ filename: 'flux1-dev-fp8_flux1-dev-fp8.safetensors' });
assert.equal(toastMsg, 'ℹ️ 非 Civitai 模型:已识别底模为 [Flux.1 D]', 'unpacks real backend model wrapper');
console.log('Sidebar feature modules: help close, folder cancel/save, and scan wizard launch passed.');
-111
View File
@@ -1,111 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import { materialPromptText, loadMaterialPrompts } from '../web/modules/material_prompt_data.js';
import { composePromptPlan, joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan } from '../web/modules/prompt_composition.js';
import { translate } from '../web/modules/locales.js';
assert.deepEqual(materialPromptText({ data: { kind: 'prompt_text', note: { promptEn: ' exact ', promptZh: 'translation' } } }), { positive: ' exact ', negative: '' });
assert.deepEqual(materialPromptText({ data: { kind: 'prompt_plan', plan: { parts: [{ positive: 'base', negative: 'bad' }, { positive: 'skip', enabled: false }], positive: 'scene' } } }), { positive: 'base\nscene', negative: 'bad' });
assert.deepEqual(materialPromptText({ data: { kind: 'image_node_selection' }, prompt_groups: { positive: ['yes'], negative: ['no'] }, node_blocks: [{ type: 'CheckpointLoaderSimple', widgets_values: ['not-prompt'] }] }), { positive: 'yes', negative: 'no' });
assert.deepEqual(materialPromptText({ data: {}, node_blocks: [{ widgets_values: ['unknown role'] }] }), { positive: '', negative: '' });
let lastUrl;
globalThis.fetch = async url => { lastUrl = url; return { ok: true, json: async () => ({ status: 'success', data: { kind: 'prompt_note_bundle', note: { promptEn: 'real detail' } } }) }; };
assert.equal((await loadMaterialPrompts('note.json')).positive, 'real detail'); assert.ok(lastUrl.includes('include_workflow=0'));
class Element {
constructor(tag) { this.tagName = tag; this.children = []; this.style = {}; this.value = ''; this.attrs = {}; this.className = ''; this.classList = { add() {}, remove() {}, toggle() {} }; this.dataset = {}; }
appendChild(child) { child.parent = this; this.children.push(child); return child; }
append(...children) { children.forEach(c => this.appendChild(c)); }
replaceChildren() { this.children.forEach(c => c.parent = null); this.children = []; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(c => c !== this); this.parent = null; }
setAttribute(k, v) { this.attrs[k] = v; }
addEventListener(key, fn) { (this.listeners ||= {})[key] = fn; }
focus() {}
get isConnected() { return this.tagName === 'body' || !!this.parent?.isConnected; }
}
const document = { body: new Element('body'), createElement: tag => new Element(tag), addEventListener: (key, fn) => events[key] = fn };
const text = (parent, tag, value, cls = '') => { const el = document.createElement(tag); el.textContent = value; el.className = cls; parent.appendChild(el); return el; };
const all = parent => [parent, ...parent.children.flatMap(all)];
const events = {};
let sent;
const context = vm.createContext({ console, document, app: {}, translate, t: translate, text, appendText: text, composePromptPlan, joinPromptText,
categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan,
window: { addEventListener: (key, fn) => events[key] = fn, removeEventListener: (key, fn) => { if (events[key] === fn) delete events[key]; } },
selectedMaterialNode: () => null, promptWidgetTargets: () => [], bindMaterialDrag() {},
anomalousConfirm: async () => true, anomalousAlert: async msg => { throw new Error(msg); }, showMaterialSaved() {},
jsonResponse: response => response.json(), loadMaterialPrompts: async () => ({ positive: 'imported', negative: '' }),
URL, URLSearchParams, AbortController, setTimeout, clearTimeout,
fetch: async (url, options) => {
sent = [url, options];
return { ok: true, json: async () => url.includes('save_prompt_plan') ? { status: 'success' } : { status: 'success', materials: [{ filename: 'p.json', name: 'Real prompt' }], total: 49, page: 1, pages: 2 } };
},
});
async function moduleFor(file, extra) {
const source = fs.readFileSync(`web/modules/${file}`, 'utf8').replace(/^import[\s\S]*?;\s*/gm, '');
const mod = new vm.SourceTextModule(source + `\nexport { ${extra} };`, {
context,
identifier: `file:///${process.cwd().replace(/\\/g, '/')}/web/modules/${file}`,
initializeImportMeta(meta, module) { meta.url = module.identifier; }
}); await mod.link(() => { throw new Error('unexpected import'); }); await mod.evaluate(); return mod.namespace;
}
const recipeCatalog = await moduleFor('ui_recipe_catalog.js', '');
const recipeCards = await moduleFor('ui_recipe_cards.js', '');
assert.equal(recipeCatalog.recipeMatchesFilter({ name: 'cat', workflow_scope: 'partial', tags: ['A'] }, 'cat', new Set(['a']), 'complete'), false);
assert.equal(recipeCatalog.recipeMatchesFilter({ name: 'cat', workflow_scope: 'partial', tags: ['A'] }, 'cat', new Set(['a']), 'partial'), true);
assert.equal(recipeCatalog.recipeMatchesFilter({ name: 'old' }, '', new Set(), 'complete'), true);
const recipeOwner = { recipeTagSelect: new Element('select'), recipeSelectedTags: new Set(['B']) };
recipeCatalog.updateRecipeFilterControls(recipeOwner, [{ data: { tags: ['A', 'B'] } }]);
assert.equal(recipeOwner.recipeTagSelect.children.length, 3); assert.equal(recipeOwner.recipeTagSelect.value, 'B');
assert.equal(recipeCards.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' }, currentAvailability: 'missing' }] } }).status, 'missing');
assert.equal(recipeCards.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' } }] } }).status, 'warning');
assert.equal(recipeCards.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' }, currentAvailability: 'available' }] } }).status, 'ready');
assert.equal(recipeCards.getRecipeReadiness({ params: { model_references: [{ localMatch: { filename: 'sub.safetensors' } }] } }).status, 'warning');
const drags = await moduleFor('material_drag.js', '');
const graph = { getNodeOnPos: () => null };
const surface = new Element('canvas'); surface.getBoundingClientRect = () => ({ left: 0, top: 0, right: 100, bottom: 100 });
context.app.graph = graph; context.app.canvas = { graph, canvas: surface, convertEventToCanvasOffset: () => [50, 50] };
const source = new Element('article'); let drops = 0;
drags.bindMaterialDrag(source, {}, { payload: () => ({}), dropOnCanvas: () => { drops++; } });
const event = target => ({ target, clientX: 50, clientY: 50, dataTransfer: { setData() {} }, preventDefault() {}, stopPropagation() {}, stopImmediatePropagation() {} });
source.listeners.dragstart(event(source)); await events.drop(event(new Element('button'))); assert.equal(drops, 0);
source.listeners.dragstart(event(source)); context.app.graph = {}; await events.drop(event(surface)); assert.equal(drops, 0); context.app.graph = graph;
source.listeners.dragstart(event(source)); await events.drop(event(surface)); assert.equal(drops, 1); assert.equal(events.drop, undefined);
// Verify node drop takes priority over canvas drop
let nodeDrops = 0; let canvasDrops = 0;
const testNode = { id: 99, type: 'Any' };
graph.getNodeOnPos = () => testNode;
graph.getNodeById = id => id === 99 ? testNode : null;
drags.bindMaterialDrag(source, {}, {
payload: () => ({}),
accepts: () => true,
drop: () => { nodeDrops++; },
dropOnCanvas: () => { canvasDrops++; }
});
source.listeners.dragstart(event(source));
await events.drop(event(surface));
assert.equal(nodeDrops, 1);
assert.equal(canvasDrops, 0);
// Verify fallback to canvas drop when node does not accept
nodeDrops = 0; canvasDrops = 0;
drags.bindMaterialDrag(source, {}, {
payload: () => ({}),
accepts: () => false,
drop: () => { nodeDrops++; },
dropOnCanvas: () => { canvasDrops++; }
});
source.listeners.dragstart(event(source));
await events.drop(event(surface));
assert.equal(nodeDrops, 0);
assert.equal(canvasDrops, 1);
const detail = await moduleFor('ui_material_detail.js', '');
const negativePromptMat = { name: 'Dark scenery [negative]', tags: ['negative'], note: { promptEn: 'ugly, deformed' } };
const promptInfo = detail.getMaterialPromptInfo(negativePromptMat);
assert.equal(promptInfo.text, 'ugly, deformed');
assert.equal(promptInfo.role, 'negative');
console.log('studio contracts: recipe scope/tags/readiness, prompt detail shapes, active material drag cleanup and node priority passed');
-45
View File
@@ -1,45 +0,0 @@
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
PLUGIN_DIR = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN_DIR), str(PLUGIN_DIR.parents[1])]
from api import recipe_packages
class ExportAvailabilityTests(unittest.IsolatedAsyncioTestCase):
async def test_import_inspection_and_commit_rejected_before_read_or_write(self):
class UnreadRequest:
@property
def content(self):
raise AssertionError('Disabled import must not read upload bytes')
async def json(self):
raise AssertionError('Disabled import must not read request data')
pending = {'existing-token': {'created': 123}}
with patch.object(recipe_packages, '_INSPECTIONS', pending), \
patch.object(recipe_packages, '_commit_import', side_effect=AssertionError('No file writes')):
for handler in (recipe_packages.api_import_recipe_package_inspect,
recipe_packages.api_import_recipe_package_commit):
response = await handler(UnreadRequest())
self.assertEqual(response.status, 503)
self.assertEqual(json.loads(response.text)['code'], 'recipe_import_disabled')
self.assertEqual(pending, {'existing-token': {'created': 123}})
async def test_export_rejected_before_request_or_files_are_read(self):
class UnreadRequest:
async def json(self):
raise AssertionError('Disabled export must not read request data')
with patch.object(recipe_packages, 'get_recipes_dir', side_effect=AssertionError('No filesystem access')):
response = await recipe_packages.api_export_recipe_package(UnreadRequest())
self.assertEqual(response.status, 503)
self.assertEqual(json.loads(response.text)['code'], 'recipe_export_disabled')
self.assertNotIn('Content-Disposition', response.headers)
if __name__ == '__main__':
unittest.main()
-186
View File
@@ -1,186 +0,0 @@
import os
import sys
import unittest
import tempfile
import json
import hashlib
from pathlib import Path
from unittest import mock
PLUGIN_DIR = Path(__file__).resolve().parents[1]
COMFY_ROOT = PLUGIN_DIR.parents[1]
sys.path.insert(0, str(COMFY_ROOT))
sys.path.insert(0, str(PLUGIN_DIR))
from api import models
from api import utils
class ModelDoctorResolutionTests(unittest.TestCase):
def test_default_folder_types_contains_unet(self):
paths = utils.get_active_scan_paths()
# In current physical mode or default abstract mode, scan paths must be non-empty
self.assertTrue(isinstance(paths, list))
self.assertIn("unet", models.RESOLVABLE_MODEL_TYPES)
def test_foundation_component_types_are_resolvable(self):
for folder_type in ("vae", "vae_approx", "clip", "text_encoders", "clip_vision"):
self.assertIn(folder_type, models.RESOLVABLE_MODEL_TYPES)
def test_dynamic_hash_resolution_for_unindexed_model(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_content = b"TEST_MODEL_SAFESENSOR_CONTENT_DATA_123456"
model_size = len(model_content)
model_hash = hashlib.sha256(model_content).hexdigest().upper()
model_file = os.path.join(temp_dir, "renamed_model.safetensors")
with open(model_file, "wb") as f:
f.write(model_content)
candidate = {
"type": "unet",
"filename": "renamed_model.safetensors",
"path": model_file,
"size": model_size
}
# Candidates have no pre-existing hash metadata
candidates = [candidate]
# Resolve using target_hash and target_size
result = models._resolve_from_candidates(candidates, target_hash=model_hash, target_size=model_size)
self.assertTrue(result.get("found"))
self.assertEqual(result.get("filename"), "renamed_model.safetensors")
self.assertTrue(result.get("matched_by_hash"))
self.assertTrue(result.get("matched_by_size"))
# Check that fallback info was created
info_file = os.path.join(temp_dir, "renamed_model.info")
self.assertTrue(os.path.exists(info_file))
with open(info_file, "r", encoding="utf-8") as f:
data = json.load(f)
self.assertEqual(data["files"][0]["hashes"]["SHA256"], model_hash.lower())
def test_dynamic_hash_mismatch_is_rejected(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_content = b"ANOTHER_MODEL_CONTENT_WITH_SAME_SIZE_BYTES"
model_size = len(model_content)
model_file = os.path.join(temp_dir, "different_model.safetensors")
with open(model_file, "wb") as f:
f.write(model_content)
candidate = {
"type": "unet",
"filename": "different_model.safetensors",
"path": model_file,
"size": model_size
}
fake_target_hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
result = models._resolve_from_candidates([candidate], target_hash=fake_target_hash, target_size=model_size)
self.assertFalse(result.get("found"))
self.assertTrue(result.get("identity_conflict"))
def test_absent_model_with_hash_and_size_returns_not_found_without_conflict(self):
# When model does not exist locally (no candidates at all, or no matching size/hash)
result = models._resolve_from_candidates([], target_hash="A" * 64, target_size=123456)
self.assertFalse(result.get("found"))
self.assertFalse(result.get("identity_conflict"))
# When candidates exist but have completely different size and different hash
unrelated_candidate = {
"type": "unet",
"filename": "other.safetensors",
"path": "/fake/other.safetensors",
"size": 999999,
"hashes": {"B" * 64},
}
result2 = models._resolve_from_candidates([unrelated_candidate], target_hash="A" * 64, target_size=123456)
self.assertFalse(result2.get("found"))
self.assertFalse(result2.get("identity_conflict"))
def test_hash_match_with_size_mismatch_returns_identity_conflict(self):
# Candidate has matching hash but differing size -> true identity conflict
candidate = {
"type": "unet",
"filename": "model.safetensors",
"path": "/fake/model.safetensors",
"size": 1000,
"hashes": {"A" * 64},
}
result = models._resolve_from_candidates([candidate], target_hash="A" * 64, target_size=2000)
self.assertFalse(result.get("found"))
self.assertTrue(result.get("identity_conflict"))
def test_unique_size_match_requires_manual_confirmation(self):
candidate = {
"type": "loras",
"filename": "renamed_lora.safetensors",
"path": __file__,
"size": 1234,
}
result = models._resolve_from_candidates([candidate], target_size=1234)
self.assertFalse(result.get("found"))
self.assertTrue(result.get("confirmation_required"))
self.assertTrue(result.get("matched_by_size"))
self.assertEqual(result.get("filename"), "renamed_lora.safetensors")
def test_foundation_component_offers_size_candidate_without_auto_recovery(self):
candidate = {
"type": "vae",
"filename": "renamed_vae.safetensors",
"path": __file__,
"size": 1234,
}
result = models._resolve_from_candidates(
[candidate],
target_size=1234,
require_hash=True,
)
self.assertFalse(result.get("found"))
self.assertTrue(result.get("hash_required"))
self.assertTrue(result.get("confirmation_required"))
self.assertEqual(result.get("filename"), "renamed_vae.safetensors")
def test_foundation_component_recovers_different_name_by_hash(self):
candidate = {
"type": "text_encoders",
"filename": "new_clip_name.safetensors",
"path": __file__,
"size": 4321,
"hashes": {"A" * 64},
}
result = models._resolve_from_candidates(
[candidate],
target_hash="A" * 64,
require_hash=True,
)
self.assertTrue(result.get("found"))
self.assertEqual(result.get("filename"), "new_clip_name.safetensors")
self.assertTrue(result.get("matched_by_hash"))
def test_model_search_falls_back_to_virtual_display_name(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_file = os.path.join(temp_dir, "opaque_name.safetensors")
with open(model_file, "wb") as handle:
handle.write(b"model")
with open(os.path.splitext(model_file)[0] + ".civitai.info", "w", encoding="utf-8") as handle:
json.dump({"anomalous_custom_name": "Readable Qwen VAE"}, handle)
with mock.patch.object(models.folder_paths, "folder_names_and_paths", {"vae": object()}), mock.patch.object(
models.folder_paths,
"get_folder_paths",
return_value=[temp_dir],
):
result = models._find_model_sync("readable qwen")
self.assertIsNotNone(result)
self.assertEqual(result["filename"], "opaque_name.safetensors")
self.assertEqual(result["file_path"], os.path.abspath(model_file))
if __name__ == "__main__":
unittest.main()
-208
View File
@@ -1,208 +0,0 @@
import importlib.util
import json
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
def load_folder_types(fake_folder_paths):
sys.modules["folder_paths"] = fake_folder_paths
spec = importlib.util.spec_from_file_location("amb_folder_types_test", ROOT / "api" / "folder_types.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def load_model_catalog(fake_folder_paths, metadata_reader):
package = types.ModuleType("amb_catalog_test")
package.__path__ = [str(ROOT)]
api_package = types.ModuleType("amb_catalog_test.api")
api_package.__path__ = [str(ROOT / "api")]
sys.modules["amb_catalog_test"] = package
sys.modules["amb_catalog_test.api"] = api_package
sys.modules["folder_paths"] = fake_folder_paths
dependencies = {
"metadata": {"get_metadata": metadata_reader},
"model_constants": {
"MEDIA_EXTENSIONS": [], "MODEL_EXTENSIONS": [], "PREVIEW_SUFFIXES": [],
},
"model_media": {
"_cache_token": lambda path: "0", "_preview_url_for_model": lambda *args: "",
},
"path_utils": {"resolve_folder_subdir": lambda *args: None},
"folder_types": {
"get_active_folder_types": lambda: [],
"get_active_model_roots": lambda: [],
"get_active_physical_basenames": lambda: [],
"get_folder_view_mode": lambda: "abstract",
},
}
for name, attrs in dependencies.items():
module = types.ModuleType(f"amb_catalog_test.api.{name}")
for attr_name, value in attrs.items():
setattr(module, attr_name, value)
sys.modules[module.__name__] = module
spec = importlib.util.spec_from_file_location(
"amb_catalog_test.api.model_catalog", ROOT / "api" / "model_catalog.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def load_metadata():
package = types.ModuleType("amb_metadata_test")
package.__path__ = [str(ROOT)]
api_package = types.ModuleType("amb_metadata_test.api")
api_package.__path__ = [str(ROOT / "api")]
identity = types.ModuleType("amb_metadata_test.model_identity")
identity.sidecar_file_hash = lambda data, file_path, selected_file: ("", "")
sys.modules[package.__name__] = package
sys.modules[api_package.__name__] = api_package
sys.modules[identity.__name__] = identity
sys.modules.setdefault("folder_paths", types.SimpleNamespace())
spec = importlib.util.spec_from_file_location(
"amb_metadata_test.api.metadata", ROOT / "api" / "metadata.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class ModelSourceRootTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
root = Path(self.temp.name)
self.checkpoints_a = root / "checkpoints-a"
self.checkpoints_b = root / "checkpoints-b"
self.clip = root / "clip"
for path in (self.checkpoints_a, self.checkpoints_b, self.clip):
path.mkdir()
self.fake = types.SimpleNamespace(
folder_names_and_paths={"checkpoints": object(), "clip": object()},
get_folder_paths=lambda kind: {
"checkpoints": [str(self.checkpoints_a), str(self.checkpoints_b)],
"clip": [str(self.clip)],
}[kind],
)
self.module = load_folder_types(self.fake)
def tearDown(self):
self.temp.cleanup()
def test_abstract_mode_keeps_only_enabled_categories_and_locators(self):
with mock.patch.object(self.module, "get_folder_view_mode", return_value="abstract"), \
mock.patch.object(self.module, "get_active_folder_types", return_value=["checkpoints"]):
roots = self.module.get_active_model_roots()
self.assertEqual([item["path_idx"] for item in roots], [0, 1])
self.assertEqual({item["type"] for item in roots}, {"checkpoints"})
self.assertNotIn(os.path.realpath(self.clip), {item["base_dir"] for item in roots})
def test_physical_mode_checks_each_registered_root(self):
with mock.patch.object(self.module, "get_folder_view_mode", return_value="physical"), \
mock.patch.object(self.module, "get_active_physical_basenames", return_value=["checkpoints-b"]):
roots = self.module.get_active_model_roots()
self.assertEqual(roots, [{
"type": "checkpoints",
"path_idx": 1,
"base_dir": os.path.realpath(self.checkpoints_b),
}])
def test_realpath_aliases_are_deduplicated(self):
self.fake.get_folder_paths = lambda kind: [str(self.checkpoints_a)]
with mock.patch.object(self.module, "get_folder_view_mode", return_value="abstract"), \
mock.patch.object(self.module, "get_active_folder_types", return_value=["checkpoints", "clip"]):
roots = self.module.get_active_model_roots()
self.assertEqual(len(roots), 1)
self.assertEqual((roots[0]["type"], roots[0]["path_idx"]), ("checkpoints", 0))
def test_physical_defaults_do_not_auto_enable_clip(self):
with mock.patch.object(self.module, "get_all_physical_basenames", return_value=["checkpoints", "clip"]), \
mock.patch.object(self.module.os.path, "exists", return_value=False):
active = self.module.get_active_physical_basenames()
self.assertEqual(active, ["checkpoints"])
class ModelSourceCatalogTests(unittest.TestCase):
def test_lists_gguf_and_pth_case_insensitively_without_touching_hidden_root(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
visible = root / "visible"
hidden = root / "hidden"
visible.mkdir()
hidden.mkdir()
for filename in ("A.GGUF", "b.pTh", "c.safetensors", "ignore.png"):
(visible / filename).write_bytes(b"x")
(hidden / "secret.GGUF").write_bytes(b"x")
metadata_reads = []
catalog = load_model_catalog(
types.SimpleNamespace(folder_names_and_paths={}),
lambda path: metadata_reads.append(path) or {},
)
catalog.get_active_model_roots = lambda: [{
"type": "text_encoders", "path_idx": 2, "base_dir": str(visible),
}]
walked = []
real_walk = os.walk
with mock.patch.object(catalog.os, "walk", side_effect=lambda path: walked.append(path) or real_walk(path)):
payload = catalog._collect_all_scan_models(1, 0)
self.assertEqual({item["filename"] for item in payload["models"]}, {"A.GGUF", "b.pTh", "c.safetensors"})
self.assertEqual({item["path_idx"] for item in payload["models"]}, {2})
self.assertEqual(walked, [str(visible)])
self.assertTrue(metadata_reads)
self.assertFalse(any(str(hidden) in path for path in metadata_reads))
def test_same_filename_in_distinct_roots_remains_distinct(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
roots = [root / "one", root / "two"]
for directory in roots:
directory.mkdir()
(directory / "same.PTH").write_bytes(b"x")
catalog = load_model_catalog(types.SimpleNamespace(folder_names_and_paths={}), lambda path: {})
catalog.get_active_model_roots = lambda: [
{"type": "clip", "path_idx": index, "base_dir": str(directory)}
for index, directory in enumerate(roots)
]
payload = catalog._collect_all_scan_models(1, 0)
self.assertEqual(payload["total"], 2)
self.assertEqual([item["path_idx"] for item in payload["models"]], [0, 1])
class ModelSourceMetadataTests(unittest.TestCase):
def test_offline_sentinel_ids_do_not_become_civitai_sources(self):
metadata = load_metadata()
with tempfile.TemporaryDirectory() as directory:
path = Path(directory, "offline.safetensors")
path.write_bytes(b"payload")
path.with_suffix(".info").write_text(json.dumps({"id": -1, "modelId": -1}))
result = metadata.get_metadata(str(path))
self.assertEqual(result["civitai_url"], "")
self.assertNotIn("model_id", result)
self.assertNotIn("version_id", result)
def test_positive_civitai_ids_still_build_release_url(self):
metadata = load_metadata()
with tempfile.TemporaryDirectory() as directory:
path = Path(directory, "online.safetensors")
path.write_bytes(b"payload")
path.with_suffix(".info").write_text(json.dumps({"id": "456", "modelId": "123"}))
result = metadata.get_metadata(str(path))
self.assertEqual(result["civitai_url"], "https://civitai.com/models/123?modelVersionId=456")
self.assertEqual(result["model_id"], 123)
self.assertEqual(result["version_id"], 456)
if __name__ == "__main__":
unittest.main()
-101
View File
@@ -1,101 +0,0 @@
import ast
from pathlib import Path
import unittest
API_INIT = Path(__file__).resolve().parents[1] / "api" / "__init__.py"
EXPECTED_ROUTES = [
("GET", "/anomalous/folders"),
("GET", "/anomalous/all_folder_types"),
("GET", "/anomalous/models"),
("GET", "/anomalous/all_scan_models"),
("GET", "/anomalous/batch_select"),
("GET", "/anomalous/image"),
("POST", "/anomalous/scan"),
("GET", "/anomalous/scan_status"),
("GET", "/anomalous/find_model"),
("GET", "/anomalous/config"),
("POST", "/anomalous/save_config"),
("POST", "/anomalous/delete_model"),
("POST", "/anomalous/clean_civitai_info"),
("GET", "/anomalous/compatible_models"),
("GET", "/anomalous/notebooks"),
("POST", "/anomalous/save_notebook"),
("POST", "/anomalous/delete_notebook"),
("GET", "/anomalous/parameters"),
("GET", "/anomalous/parameters/by_node_type"),
("POST", "/anomalous/save_parameter"),
("POST", "/anomalous/rename_parameter"),
("POST", "/anomalous/delete_parameter"),
("GET", "/anomalous/parameter_gallery"),
("GET", "/anomalous/recipes"),
("GET", "/anomalous/recipe_full"),
("GET", "/anomalous/recipe_asset"),
("GET", "/anomalous/recipe_gallery"),
("GET", "/anomalous/recipe_parameter_gallery"),
("GET", "/anomalous/recipe_gallery_compare"),
("POST", "/anomalous/save_recipe"),
("POST", "/anomalous/update_recipe"),
("POST", "/anomalous/set_recipe_gallery_cover"),
("POST", "/anomalous/delete_recipe"),
("GET", "/anomalous/recipe_history"),
("GET", "/anomalous/recipe_version"),
("POST", "/anomalous/restore_recipe_version"),
("POST", "/anomalous/refresh_recipe_identity"),
("POST", "/anomalous/export_recipe_package"),
("POST", "/anomalous/import_recipe_package_inspect"),
("POST", "/anomalous/import_recipe_package_commit"),
("GET", "/anomalous/materials"),
("GET", "/anomalous/material_full"),
("GET", "/anomalous/material_asset"),
("GET", "/anomalous/materials/by_node_type"),
("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"),
("POST", "/anomalous/delete_material"),
("POST", "/anomalous/update_material"),
("POST", "/anomalous/translate"),
("GET", "/anomalous/base_models"),
("GET", "/anomalous/gallery_images"),
("POST", "/anomalous/delete_gallery_image"),
("GET", "/anomalous/resolve_hash"),
("POST", "/anomalous/resolve_hash_batch"),
("GET", "/anomalous/all_hashes"),
("POST", "/anomalous/scan_all"),
("GET", "/anomalous/global_scan_status"),
("GET", "/anomalous/scan_missing_models_status"),
("POST", "/anomalous/clear_cache"),
("POST", "/anomalous/update_metadata"),
("POST", "/anomalous/set_custom_cover"),
("POST", "/anomalous/upload_custom_cover"),
("GET", "/anomalous/model_images"),
("POST", "/anomalous/resolve_paths_to_previews"),
("POST", "/anomalous/scan_missing_models"),
]
class RouteManifestTests(unittest.TestCase):
def test_http_method_and_path_manifest_is_stable(self):
tree = ast.parse(API_INIT.read_text(encoding="utf-8"))
setup = next(
node for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "setup_routes"
)
actual = []
for statement in setup.body:
if not isinstance(statement, ast.Expr) or not isinstance(statement.value, ast.Call):
continue
function = statement.value.func
if not isinstance(function, ast.Attribute) or not function.attr.startswith("add_"):
continue
actual.append((function.attr.removeprefix("add_").upper(), ast.literal_eval(statement.value.args[0])))
self.assertEqual(actual, EXPECTED_ROUTES)
self.assertEqual(len(actual), len(set(actual)), "route method/path pairs must be unique")
if __name__ == "__main__":
unittest.main()
-86
View File
@@ -1,86 +0,0 @@
import assert from 'node:assert/strict';
import { hasChinese, splitPromptTags, normalizePromptFormatting, translatePromptText } from '../web/modules/translation_service.js';
// 1. Test hasChinese
assert.equal(hasChinese(''), false);
assert.equal(hasChinese(null), false);
assert.equal(hasChinese('masterpiece, 1girl, highly detailed'), false);
assert.equal(hasChinese('masterpiece, 1girl, 杰作, 高画质'), true);
assert.equal(hasChinese('仅中文提示词'), true);
// 2. Test splitPromptTags with commas, enumeration marks (顿号), semicolons, pipes
const sample = '六个手指、低分辨率、糟糕的解剖结构;1girl,赛博朋克风格|neon lighting\ncinematic shadows';
const tags = splitPromptTags(sample);
assert.deepEqual(tags, [
'六个手指',
'低分辨率',
'糟糕的解剖结构',
'1girl',
'赛博朋克风格',
'neon lighting',
'cinematic shadows',
]);
// 2b. Test normalizePromptFormatting
const normalized = normalizePromptFormatting('1boy、2boys、男性;肌肉发达,丑陋男人');
assert.equal(normalized, '1boy, 2boys, 男性, 肌肉发达, 丑陋男人');
// 3. Test translatePromptText with mocked fetch
let fetchCalls = 0;
let lastRequestBody = null;
globalThis.fetch = async (url, options = {}) => {
fetchCalls++;
lastRequestBody = JSON.parse(options.body || '{}');
if (lastRequestBody.text === 'error_trigger') {
return {
ok: false,
status: 500,
json: async () => ({ error: 'Internal Server Error' }),
};
}
return {
ok: true,
json: async () => ({
status: 'success',
translated: lastRequestBody.target_lang === 'en' ? 'masterpiece, 1girl' : '杰作,女孩',
}),
};
};
// Case 3a: Chinese input auto-selects 'en'
const res1 = await translatePromptText('杰作, 女孩');
assert.equal(res1.ok, true);
assert.equal(res1.targetLang, 'en');
assert.equal(res1.translated, 'masterpiece, 1girl');
assert.equal(fetchCalls, 1);
// Case 3b: Cached call (identical prompt & target) uses cache, does not hit fetch
const res1Cached = await translatePromptText('杰作, 女孩');
assert.equal(res1Cached.ok, true);
assert.equal(res1Cached.fromCache, true);
assert.equal(fetchCalls, 1); // No new network call
// Case 3c: English input auto-selects 'zh-CN'
const res2 = await translatePromptText('masterpiece, 1girl');
assert.equal(res2.ok, true);
assert.equal(res2.targetLang, 'zh-CN');
assert.equal(res2.translated, '杰作,女孩');
assert.equal(fetchCalls, 2);
// Case 3d: Error recovery - returns original text gracefully with ok: false on HTTP failure
const resError = await translatePromptText('error_trigger');
assert.equal(resError.ok, false);
assert.equal(resError.translated, 'error_trigger');
// Case 3e: Error recovery - backend returns status: 'error' JSON payload
globalThis.fetch = async () => ({
ok: true,
json: async () => ({ status: 'error', error: 'All translation providers failed', translated: 'untranslated_raw' }),
});
const resStatusError = await translatePromptText('untranslated_raw', { bypassCache: true });
assert.equal(resStatusError.ok, false);
assert.equal(resStatusError.error, 'All translation providers failed');
assert.equal(resStatusError.translated, 'untranslated_raw');
console.log('translation service contracts: hasChinese, splitPromptTags, auto language detection, LRU cache & error resilience passed');
-121
View File
@@ -1,121 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath, pathToFileURL } from 'node:url';
export const all = node => [node, ...node.children.flatMap(all)];
export class Events {
listeners = new Map();
addEventListener(type, fn) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(fn); }
removeEventListener(type, fn) { this.listeners.get(type)?.delete(fn); }
dispatch(type, event = {}) { for (const fn of [...(this.listeners.get(type) || [])]) fn(event); }
dispatchEvent(event) { this.dispatch(event.type, event); return true; }
listenerCount() { return [...this.listeners.values()].reduce((sum, set) => sum + set.size, 0); }
}
export class Element extends Events {
constructor(tag) {
super();
this.tagName = tag; this.children = []; this.dataset = {}; this.attrs = {};
this.className = ''; this.value = ''; this._text = ''; this.disabled = false;
this.style = { setProperty(key, value) { this[key] = value; }, removeProperty(key) { delete this[key]; } };
this.classList = {
contains: name => this.className.split(/\s+/).includes(name),
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
remove: (...names) => { this.className = this.className.split(/\s+/).filter(name => !names.includes(name)).join(' '); },
toggle: (name, force) => { const enabled = force ?? !this.classList.contains(name); this.classList[enabled ? 'add' : 'remove'](name); return enabled; },
};
}
set textContent(value) { this.replaceChildren(); this._text = String(value); }
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set innerHTML(value) { this.textContent = value; }
get innerHTML() { return this.textContent; }
appendChild(child) { child.remove(); child.parentNode = this; this.children.push(child); return child; }
append(...children) { children.forEach(child => this.appendChild(child)); }
insertBefore(child, before) { if (!before) return this.appendChild(child); child.remove(); child.parentNode = this; this.children.splice(this.children.indexOf(before), 0, child); return child; }
prepend(child) { child.remove(); child.parentNode = this; this.children.unshift(child); }
replaceChildren(...children) { for (const child of this.children) child.parentNode = null; this.children = []; this._text = ''; this.append(...children); }
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
get parentElement() { return this.parentNode; }
get isConnected() { return this.tagName === 'body' || !!this.parentNode?.isConnected; }
get firstChild() { return this.children[0] || null; }
get options() { return this.children; }
cloneNode(deep = false) {
const copy = new Element(this.tagName);
copy.className = this.className; copy.value = this.value; copy._text = this._text;
copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
if (deep) copy.append(...this.children.map(child => child.cloneNode(true)));
return copy;
}
setAttribute(name, value) { this.attrs[name] = value; }
removeAttribute(name) { delete this.attrs[name]; }
getAttribute(name) { return this.attrs[name] ?? null; }
matches(selector) { return selector.startsWith('#') ? this.id === selector.slice(1) : selector.startsWith('.') ? this.classList.contains(selector.slice(1)) : this.tagName === selector; }
querySelectorAll(selector) { return all(this).slice(1).filter(child => selector.split(',').some(part => child.matches(part.trim()))); }
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
closest(selector) { return this.matches(selector) ? this : this.parentNode?.closest(selector) || null; }
contains(child) { return all(this).includes(child); }
hasChildNodes() { return !!this.children.length; }
getBoundingClientRect() { return { width: parseFloat(this.style.width || this.style['--amb-translator-width']) || 580, height: 600, top: 0, left: 0, right: 580, bottom: 600 }; }
click() { if (!this.disabled) return this.onclick?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
showModal() { this.open = true; }
close() { this.open = false; this.onclose?.(); }
focus() {}
scrollIntoView() {}
}
export function fixture({ storage = new Map(), storageOverride } = {}) {
const document = new Events();
document.body = new Element('body');
document.head = new Element('head');
document.documentElement = new Element('html');
document.createElement = tag => new Element(tag);
document.createDocumentFragment = () => new Element('fragment');
document.querySelector = selector => document.body.querySelector(selector);
document.querySelectorAll = selector => document.body.querySelectorAll(selector);
const window = new Events();
Object.assign(window, { innerWidth: 1440, innerHeight: 900, anomalous_browser_lang: 'en' });
const timers = new Map();
let timerId = 0;
const errors = [], requests = [], clipboard = [];
const node = { id: 1, type: 'CLIPTextEncode', widgets: [{ name: 'text', type: 'customtext', value: 'original' }], widgets_values: ['original'] };
const app = { graph: { getNodeById: id => id === node.id ? node : null }, canvas: { selected_nodes: { 1: node } } };
const state = {
app, node, window, document, errors, requests, clipboard,
async fetch(url) { return { ok: true, json: async () => String(url).includes('/materials?') ? { status: 'success', materials: [], pages: 1 } : { status: 'success', translated: 'translated' } }; },
};
const context = vm.createContext({ console, URL, URLSearchParams, Blob, AbortController, DOMException, structuredClone,
CustomEvent: class CustomEvent { constructor(type, options = {}) { this.type = type; this.detail = options.detail; } },
Option: class OptionElement extends Element { constructor(text = '', value = '') { super('option'); this.textContent = text; this.value = value; } },
document, window, app, errors, anomalous_browser_lang: 'en',
setTimeout: callback => { timers.set(++timerId, callback); return timerId; }, clearTimeout: id => timers.delete(id),
setInterval: callback => { timers.set(++timerId, callback); return timerId; }, clearInterval: id => timers.delete(id),
requestAnimationFrame: callback => callback(), queueMicrotask,
localStorage: storageOverride || { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key) },
navigator: { clipboard: { async writeText(text) { clipboard.push(text); } } }, confirm: () => true,
alert: message => errors.push(String(message)), prompt: () => null,
fetch: async (url, options = {}) => { requests.push([url, options]); return state.fetch(url, options); },
});
const modules = new Map();
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web/modules');
const load = filename => {
if (modules.has(filename)) return modules.get(filename);
let source;
if (filename.endsWith('/scripts/app.js')) source = 'export const app = globalThis.app;';
else if (filename.endsWith('/ui_dialog.js')) source = 'export async function anomalousAlert(message) { errors.push(message); } export async function anomalousConfirm() { return true; } export async function anomalousPrompt() { return null; }';
else if (filename.endsWith('/material_feedback.js')) source = 'export function showMaterialSaved() {}';
else if (filename.endsWith('/ui_gallery_detail.js')) source = 'export function showImageWorkbench(owner, sourceImage, imageUrl, options) { return owner?.showImageWorkbench?.(sourceImage, imageUrl, options); }';
else source = fs.readFileSync(filename, 'utf8');
const mod = new vm.SourceTextModule(source, { context, identifier: filename, initializeImportMeta: meta => { meta.url = pathToFileURL(filename).href; } });
modules.set(filename, mod); return mod;
};
state.module = async name => {
const mod = load(path.resolve(root, name).replaceAll('\\', '/'));
if (mod.status === 'unlinked') await mod.link((specifier, parent) => load(path.resolve(path.dirname(parent.identifier), specifier).replaceAll('\\', '/')));
if (mod.status === 'linked') await mod.evaluate();
return mod.namespace;
};
state.button = (rootNode, label) => all(rootNode).find(el => el.tagName === 'button' && el.textContent.includes(label));
state.flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
state.runTimers = () => { const pending = [...timers.values()]; timers.clear(); pending.forEach(callback => callback()); };
return state;
}
-92
View File
@@ -1,92 +0,0 @@
import assert from 'node:assert/strict';
import { fixture, Element } from './ui_fixture.mjs';
const storage = new Map();
const f = fixture({ storage });
const data = await f.module('update_guide_data.js');
const { i18n } = await f.module('locales.js');
assert.equal(data.validateUpdateGuide(data.CURRENT_UPDATE_GUIDE, i18n), true);
assert.equal(data.validateUpdateGuide({ ...data.CURRENT_UPDATE_GUIDE, steps: [] }, i18n), false);
assert.equal(data.validateUpdateGuide({ ...data.CURRENT_UPDATE_GUIDE, steps: [data.CURRENT_UPDATE_GUIDE.steps[0], data.CURRENT_UPDATE_GUIDE.steps[0]] }, i18n), false);
assert.equal(data.validateUpdateGuide(data.CURRENT_UPDATE_GUIDE, { zh: i18n.zh, en: {} }), false);
const guide = await f.module('ui_update_guide.js');
const owner = { modal: new Element('div') };
assert.equal(guide.showUpdateGuide(owner), false, 'never show over a closed browser');
owner.modal.classList.add('visible');
assert.equal(guide.showUpdateGuide(owner), true);
assert.equal(guide.showUpdateGuide(owner), false, 'no duplicate dialog');
let dialog = f.document.querySelector('dialog');
assert.equal(f.button(dialog, 'Back').disabled, true);
await f.button(dialog, 'Next').click();
assert.match(dialog.textContent, /Material Library/);
await f.button(dialog, 'Back').click();
assert.match(dialog.textContent, /Workflow Recipe Studio/);
await f.button(dialog, 'Next').click();
await f.button(dialog, 'Next').click();
await f.button(dialog, 'Next').click();
await f.button(dialog, 'Got it').click();
assert.equal(dialog.isConnected, false);
assert.equal(dialog.listenerCount(), 0);
assert.equal(guide.showUpdateGuide(owner), false);
assert.equal(guide.hasAcknowledged(data.CURRENT_UPDATE_GUIDE.id), true);
const fresh = fixture({ storage });
assert.equal((await fresh.module('ui_update_guide.js')).showUpdateGuide(owner), false, 'dismissal persists across page sessions');
assert.equal(guide.showUpdateGuide(owner, { force: true }), true);
dialog = f.document.querySelector('dialog');
let stopped = false, prevented = false;
dialog.dispatch('keydown', { key: 'Escape', stopPropagation() { stopped = true; }, preventDefault() { prevented = true; } });
assert.ok(stopped && prevented);
assert.equal(dialog.listenerCount(), 0);
assert.ok(owner.modal.classList.contains('visible'), 'Escape belongs only to the guide');
const nextGuide = { ...data.CURRENT_UPDATE_GUIDE, id: 'next-release' };
assert.equal(guide.showUpdateGuide(owner, { guide: nextGuide }), true);
guide.closeUpdateGuide(owner);
guide.closeUpdateGuide(owner);
assert.equal(guide.showUpdateGuide(owner, { guide: nextGuide }), true, 'parent close must not acknowledge unread content');
await f.button(f.document.querySelector('dialog'), 'Skip').click();
assert.equal(guide.showUpdateGuide(owner, { guide: nextGuide }), false);
assert.equal(guide.showUpdateGuide(owner, { guide: { ...nextGuide, steps: [] } }), false);
const unavailable = fixture({ storageOverride: { getItem() { throw new Error('Storage blocked'); }, setItem() { throw new Error('Storage blocked'); } } });
const unavailableGuide = await unavailable.module('ui_update_guide.js');
assert.equal(unavailableGuide.showUpdateGuide(owner), true);
unavailable.document.querySelector('dialog').dispatch('cancel', { preventDefault() {} });
assert.equal(unavailableGuide.showUpdateGuide(owner), false, 'blocked storage still prevents repeated prompts in this session');
console.log('Update guide: configuration, steps, persistence, replay, Escape, parent close and blocked storage passed.');
const actions = await f.module('sidebar_actions.js');
const root = new Element('div');
const scan = new Element('button'); scan.id = 'anomalous-scan-btn';
let clicks = 0; scan.onclick = () => clicks++;
root.append(scan);
actions.configureSidebarActions(root);
actions.configureSidebarActions(root);
assert.equal(scan.querySelectorAll('.anomalous-action-label').length, 1);
assert.equal(scan.querySelector('.anomalous-action-label').textContent, 'Scan');
await scan.click(); assert.equal(clicks, 1);
scan.innerHTML = '<svg></svg>'; // Scanner status refresh replaces its icon.
scan.classList.add('anomalous-radar-spinning');
actions.configureSidebarAction(scan);
assert.ok(scan.querySelector('.anomalous-action-label'));
assert.ok(scan.classList.contains('anomalous-radar-spinning'));
assert.match(scan.getAttribute('data-tooltip'), /metadata and previews/);
console.log('Sidebar labels: repeated setup, scanner icon replacement and click behavior passed.');
const tour = await f.module('ui_spotlight_tour.js');
const modelsBtn = new Element('button'); modelsBtn.id = 'anomalous-models-btn';
const scanBtn = new Element('button'); scanBtn.id = 'anomalous-scan-btn';
f.document.body.append(modelsBtn, scanBtn);
assert.equal(tour.isSpotlightTourActive(), false);
assert.equal(tour.startSpotlightTour(owner), true);
assert.equal(tour.isSpotlightTourActive(), true);
const overlay = f.document.querySelector('.anomalous-spotlight-overlay');
assert.ok(overlay);
assert.match(overlay.textContent, /Workspace Navigation/);
await f.button(overlay, 'Next ›').click();
assert.match(overlay.textContent, /Model Scanning/);
tour.closeSpotlightTour();
assert.equal(tour.isSpotlightTourActive(), false);
console.log('Spotlight tour: lifecycle, step advancement, and clean exit passed.');