dsh-comfyui-canvas 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +20 -8
- package/README.zh.md +22 -10
- package/comfyui-bridge/ComfyUI-DSH-Canvas/__init__.py +1 -1
- package/comfyui-bridge/ComfyUI-DSH-Canvas/entry/bridge.js +326 -14
- package/comfyui-bridge/ComfyUI-DSH-Canvas/version.json +1 -1
- package/lib/client.js +8 -0
- package/lib/index.js +89 -3
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
本仓库变更记录(与版本号对应)。
|
|
4
|
+
|
|
5
|
+
## [0.1.12] - 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- 工作流生命周期:`new_canvas`(调用前端"新建空白工作流"命令,无需清空/模拟点击)、`rename_workflow`(命名/重命名,无对话框)、`save_workflow`(直接持久化)
|
|
9
|
+
- 布局整理:`move_node`(绝对/偏移移动)、`align_nodes`(对齐 left/right/top/bottom/hcenter/vcenter、均匀分布 horizontal/vertical、网格重排 cols+gap)
|
|
10
|
+
- 通用前端命令桥接 `command`(执行任意注册命令,如 Comfy.SaveWorkflow)
|
|
11
|
+
- UI 探测 `ui_probe`(诊断 Vue/pinia 链路与界面元素)
|
|
12
|
+
- 组边界框顶部标题留白(50px),节点不再压盖组标题;移动/整理组内成员时组框自动重新贴合
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- host 工具新增 5 个:`comfyui_new_canvas` / `comfyui_rename_workflow` / `comfyui_save_workflow` / `comfyui_move_node` / `comfyui_align_nodes`
|
|
16
|
+
- 内置技能 SOP:新建工作流优先 `comfyui_new_canvas`,复杂工作流用 `group` + `align_nodes` 整理布局
|
|
17
|
+
- 版本对齐 0.1.12(插件 package.json + 桥接节点)
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
- `rename_workflow` 文件名安全校验(1-50 字符、拒绝路径/控制字符,防路径穿越)
|
|
21
|
+
- `move_node` 数字校验(NaN 防护)、`align_nodes` 参数规整(cols≥1、gap≥0)
|
|
22
|
+
- `set_param` 对前端回调容错(combo 选项异步初始化时不失败)
|
|
23
|
+
|
|
24
|
+
## [0.1.11] - 2026-09-28
|
|
25
|
+
|
|
26
|
+
> 覆盖 v0.1.10 发布(tag `333b009`)之后到 0.1.11 的全部更新:`0987ef5`(画布专注模式修复)+ `4a8871a`(分组功能)+ `9ca2f25`(CHANGELOG 纳入 npm files),均随 npm 0.1.11 发布。
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- 工作流分组:`comfyui_group` 创建组(可传 `bounding` 显式定位)、增删成员、组边界框按节点坐标自动贴合
|
|
30
|
+
- 组成员持久化:保存工作流时把成员 id(`nodes`)写入组对象 JSON,加载时自动重建成员关联(序列化补丁 + configure 钩子)——重开工作流分组原样恢复
|
|
31
|
+
- 画布专注模式(会话隔离,v0.1.10 标签后补的修复):独立画布标签写入 `activeViewBySession`,`comfyui_config` 可感知当前会话的画布专注状态
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- 修复分组成员此前静默失效的根因:LiteGraph 缺 `addNode` 方法且 `LGraphGroup.nodes` 为只读 getter,改为桥接自管成员(`_dshNodes`)
|
|
35
|
+
- 修复组边界框固定为默认值的问题:创建 / 增删成员时按节点坐标自动计算贴合
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
- 版本号对齐:插件 `package.json` 与桥接节点(`version.json` / `__version__`)统一为 `0.1.11`
|
|
39
|
+
- README 版本徽章更新为 0.1.11,并补充分组功能文档
|
|
40
|
+
- `CHANGELOG.md` 纳入 npm 发布文件清单(`files`),随下一版本进入 npm 包
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> [中文](README.zh.md) · English
|
|
4
4
|
|
|
5
5
|
[](LICENSE)
|
|
6
|
-
[](https://github.com/wbin0001/dsh-comfyui-canvas/releases)
|
|
7
7
|
[](https://github.com/wbin0001/dsh-comfyui-canvas)
|
|
8
8
|
[](https://github.com/DeepSeek-Harness/DSH)
|
|
9
9
|
[](https://github.com/comfyanonymous/ComfyUI)
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
**Keywords**: ComfyUI · Stable Diffusion · 文生图 text-to-image · 图生图 img2img · AI 绘画 AI art · workflow 工作流 · music 音乐 · video 视频 · 3D · DeepSeek Harness · DSH
|
|
13
13
|
|
|
14
14
|
> **✅ DSH version compatibility (since v0.1.4)**: the split-screen layout is **fully self-contained** in the plugin — it uses only official DSH slots (`conversation.session.header.utilities`) and DOM `data-*` anchors, with **zero core modifications**. Works on any official DSH **v0.1.x** (including v0.1.2+ with the breaking client refresh) without patches. The earlier split-rail implementation depended on private core patches; v0.1.4 removes that dependency entirely.
|
|
15
|
-
> - ❌ **Non-official desktop wrappers** (e.g. the community `dsh-desktop`) are not guaranteed compatible — they bundle an upstream version that may be ahead of or behind this plugin's baseline; rely on official DSH.
|
|
16
15
|
|
|
17
16
|

|
|
18
17
|
|
|
@@ -32,7 +31,7 @@ This package is the DSH-side plugin, and it ships the ComfyUI-side bridge node t
|
|
|
32
31
|
|---|---|
|
|
33
32
|
| **ComfyUI canvas split** | Clicking the **ComfyUI** button in the session header drops you straight into **canvas-on-the-left + chat-rail-on-the-right** split mode — the canvas embeds the ComfyUI frontend (local or cloud) alongside the official conversation rail, so you can chat with the agent while watching it drive the canvas. The iframe stays alive (no reload); click the button again to exit split. |
|
|
34
33
|
| **Visual canvas copilot** | The agent operates **the canvas you are looking at** — nodes appear, links wire, widgets change and runs trigger live on screen, so you watch every step instead of trusting an opaque JSON edit. Output images come back into the chat via `comfyui_get_outputs`. |
|
|
35
|
-
| **Canvas ops tools** | `comfyui_read_workflow`, `add_node`, `connect`, `set_param`, `remove_node`, `inject_text`, `load_workflow`, `run`, `debug`, `clear`, `group` — build and fix workflows on the live canvas; `
|
|
34
|
+
| **Canvas ops tools** | `comfyui_read_workflow`, `add_node`, `connect`, `set_param`, `remove_node`, `inject_text`, `load_workflow`, `run`, `debug`, `clear`, `group` — build and fix workflows on the live canvas; `group` organizes complex workflows into named groups (prompt / sampler / output areas) with auto-fitted boxes, title headroom and membership persistence. **Workflow lifecycle** (v0.1.12): `comfyui_new_canvas` starts a brand-new blank workflow through the frontend's own "New Blank Workflow" action (no clearing, no click), `comfyui_rename_workflow` names/renames it, `comfyui_save_workflow` persists it — all via the frontend stores, no dialogs. **Layout** (v0.1.12): `comfyui_move_node` (absolute/offset) and `comfyui_align_nodes` (align / distribute / grid) tidy the canvas, and group boxes re-fit automatically when a member moves. |
|
|
36
35
|
| **Production tools** | `comfyui_batch_run` sweeps a parameter matrix (seeds/prompts/strengths) in one go — either explicit `runs` or a declarative `matrix` (`zip` parallel slots / `product` cartesian expansion); `comfyui_get_outputs` pulls the resulting files back into the chat — images, videos, gifs, and audio — with optional `outputStem` auto-incrementing names (`stem.01.png`, never overwrites); `comfyui_attach_file` uploads any local file (image/audio/video/3D/text) into ComfyUI's input/ for the matching Load node; `comfyui_export_api` exports the live canvas as API-format workflow JSON for comfy-cli headless batch runs. |
|
|
37
36
|
| **One-click bridge install** | `comfyui_setup_bridge` detects / installs / updates the ComfyUI-side bridge node into `custom_nodes/` (idempotent, verifies the files, tells you when to restart) — no more manual copy step; `comfyui_config` reports `bridgeInstalled` / `bridgeVersion` / `bridgeUpToDate` at a glance. |
|
|
38
37
|
| **Projects & traceability** | Downloads default to the project directory (Settings → Project directory, default `<workspace>/projects`); every downloaded run appends `runs.json` (promptId / overrides / timestamp / files) so any output can be traced back to its parameters. Failed runs return a structured `executionError` (node id / node type / exception / message) instead of a raw JSON wall. |
|
|
@@ -137,7 +136,7 @@ Works on **Windows**, **macOS** and **Linux**. The agent tools talk to ComfyUI o
|
|
|
137
136
|
## Usage
|
|
138
137
|
|
|
139
138
|
1. Open a conversation and click the **ComfyUI** button in the session header — the canvas appears on the left with the Chat rail (messages + input) on the right, so you can instruct the agent while watching it work the canvas. Click the button again to exit split mode.
|
|
140
|
-
2. Ask the agent to do canvas work:
|
|
139
|
+
2. Ask the agent to do canvas work: "read the current workflow", "set KSampler seed to 42", "check the canvas for errors", "run it".
|
|
141
140
|
3. The agent reads `comfyui_config` first, so it knows it's on the canvas and stays focused on canvas operations.
|
|
142
141
|
|
|
143
142
|
### Conversation → canvas
|
|
@@ -152,7 +151,7 @@ Content the agent generates in the chat — images and text — can become Comfy
|
|
|
152
151
|
|
|
153
152
|
### Canvas + MCP — two driving modes of the same ComfyUI
|
|
154
153
|
|
|
155
|
-
The plugin is the **canvas driver**: it sees and edits the
|
|
154
|
+
The plugin is the **canvas driver**: it sees and edits the live canvas the user is looking at (add nodes, wire links, tweak widgets, run, fetch the run's output images via `comfyui_get_outputs`, sweep parameters via `comfyui_batch_run`). It never needs a saved workflow file.
|
|
156
155
|
|
|
157
156
|
For **pipeline-style / headless workloads**, ComfyUI's official **Comfy CLI** (`comfy-cli`, a standalone Python CLI that can also expose an **MCP server**) is the complementary executor. The two are not alternatives — they are two driving modes of the **same** ComfyUI instance, covering the full workflow lifecycle:
|
|
158
157
|
|
|
@@ -177,12 +176,24 @@ The built-in `comfyui-canvas-ops` skill instructs the agent to pick the right op
|
|
|
177
176
|
| Scenario | Mode |
|
|
178
177
|
|---|---|
|
|
179
178
|
| **Edit an existing workflow** | Incremental edits on the current canvas (`set_param` / `connect` / `add_node` / `remove_node` / `inject_text`) — never a whole-graph `load_workflow` that would wipe the unsaved state |
|
|
180
|
-
| **Start a new workflow** | `
|
|
181
|
-
| **Build a complex workflow** | `comfyui_group`
|
|
179
|
+
| **Start a new workflow** | `comfyui_new_canvas` (a brand-new blank canvas via the frontend's own New action) or `comfyui_clear` (blank the current canvas, `confirm`-guarded), then build up |
|
|
180
|
+
| **Build a complex workflow** | `comfyui_group` named groups (prompt / sampler / output areas) + `comfyui_align_nodes` / `comfyui_move_node` tidy layout |
|
|
182
181
|
| **Quick validation / batch / unattended** | **API/MCP path** — submit an API-format workflow directly (or via an MCP server), no need to visualize on the canvas |
|
|
183
182
|
|
|
184
183
|
The agent decides the path up front: visual canvas mode when the user is watching / tuning, API/MCP mode for fast validation, batch sweeps and unattended runs.
|
|
185
184
|
|
|
185
|
+
### Workflow groups (organize complex pipelines)
|
|
186
|
+
|
|
187
|
+
`comfyui_group` organizes a complex canvas into **named groups** that survive save/load:
|
|
188
|
+
|
|
189
|
+
| Action | Behavior |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `add` | Create a named group around the given nodes; the box auto-fits the nodes (an explicit `bounding` is honored when given) |
|
|
192
|
+
| `add_nodes` / `remove_nodes` | Add / remove member nodes; the box re-fits automatically |
|
|
193
|
+
| `rename` / `list` | Rename a group / list groups with their members |
|
|
194
|
+
|
|
195
|
+
**Membership persistence**: saving the workflow serializes each group's member ids (`nodes`) next to `title` / `bounding` / `color`, and loading re-attaches them — a saved workflow reopens with the same boxes and their memberships intact.
|
|
196
|
+
|
|
186
197
|
## Requirements
|
|
187
198
|
|
|
188
199
|
- DeepSeek Harness Web (DSH), Node `^22.19.0 || >=24`
|
|
@@ -207,7 +218,7 @@ dsh-comfyui-canvas/
|
|
|
207
218
|
│ ├── __init__.py # /dsh-bridge/* HTTP routes on the ComfyUI server
|
|
208
219
|
│ └── entry/bridge.js # injected frontend: reports graph + runs commands
|
|
209
220
|
├── lib/
|
|
210
|
-
│ ├── index.js # DSH host:
|
|
221
|
+
│ ├── index.js # DSH host: 26 canvas tools + 4 built-in skills
|
|
211
222
|
│ └── client.js # DSH web: split canvas (left) + chat rail (right) / settings
|
|
212
223
|
├── LICENSE
|
|
213
224
|
├── README.md
|
|
@@ -218,6 +229,7 @@ The **bridge** is the only ComfyUI-side dependency. It exposes `/dsh-bridge/work
|
|
|
218
229
|
|
|
219
230
|
## Known issues
|
|
220
231
|
|
|
232
|
+
- **Non-official desktop wrappers** are not guaranteed compatible — they bundle an upstream version that may be ahead of or behind this plugin's baseline; rely on official DSH.
|
|
221
233
|
- `comfyui_reload` currently supports **Windows only** (uses `netstat`/`taskkill`); on macOS/Linux it fails loud instead of pretending, restart ComfyUI manually there.
|
|
222
234
|
- The former "node previews missing after a run" is fixed in v0.1.1: removed the iframe's `referrerpolicy="no-referrer"` to match the native tab environment, and `bridge.js` now listens to ComfyUI's `executed` event to force a canvas redraw.
|
|
223
235
|
|
package/README.zh.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> 中文 · [English](README.md)
|
|
4
4
|
|
|
5
5
|
[](LICENSE)
|
|
6
|
-
[](https://github.com/wbin0001/dsh-comfyui-canvas/releases)
|
|
7
7
|
[](https://github.com/wbin0001/dsh-comfyui-canvas)
|
|
8
8
|
[](https://github.com/DeepSeek-Harness/DSH)
|
|
9
9
|
[](https://github.com/comfyanonymous/ComfyUI)
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
**关键词**:ComfyUI · Stable Diffusion · 文生图 · 图生图 · AI 绘画 · 工作流 workflow · 音乐 music · 视频 video · 3D · DeepSeek Harness · DSH
|
|
13
13
|
|
|
14
14
|
> **✅ DSH 版本兼容(v0.1.4 起)**:分屏布局已完全**自包含**在插件内——只用官方 DSH 插槽(`conversation.session.header.utilities`)与 DOM `data-*` 锚点,**零核心改动**。任何官方 DSH **v0.1.x**(含带破坏性 client 更新的 v0.1.2+)都开箱即用、无需补丁。此前分屏 rail 依赖 DSH 私有核心补丁,v0.1.4 已彻底移除该依赖。
|
|
15
|
-
> - ❌ **非官方桌面封装**(如 `dsh-desktop` 社区版)不保证兼容——它内部跑的上游版本可能超前/滞后于本插件基线,请以官方 DSH 为准。
|
|
16
15
|
|
|
17
16
|

|
|
18
17
|
|
|
@@ -33,7 +32,7 @@
|
|
|
33
32
|
| **画布分屏入口** | 点会话标头右侧的 **ComfyUI** 按钮进入**画布左 + 对话 rail 右**的分屏形态——画布内嵌 ComfyUI 前端(本地/云端),右侧是官方对话 rail,边看画布边发消息让 agent 操控。iframe 常驻不重载,再点按钮即关闭分屏。 |
|
|
34
33
|
| **分屏布局(自包含)** | ComfyUI 按钮 = 画布左 + 对话右,状态按会话隔离。只用官方插槽(`conversation.session.header.utilities`)+ DOM `data-*` 锚点与 CSS 变量,**零核心改动**,上游样式变化也不受影响。 |
|
|
35
34
|
| **可视化画布副驾** | agent 操作**你正在看的画布**——节点出现、连线接上、参数变化、运行触发,全部实时显示在屏幕上,每一步都看得见,而不是黑盒改 JSON。出图经 `comfyui_get_outputs` 直接带回对话。 |
|
|
36
|
-
| **画布操作工具** | `comfyui_read_workflow` / `add_node` / `connect` / `set_param` / `remove_node` / `inject_text` / `load_workflow` / `run` / `debug` / `clear` / `group`——在活画布上搭建与修复工作流;`
|
|
35
|
+
| **画布操作工具** | `comfyui_read_workflow` / `add_node` / `connect` / `set_param` / `remove_node` / `inject_text` / `load_workflow` / `run` / `debug` / `clear` / `group`——在活画布上搭建与修复工作流;`group` 把复杂工作流分成具名组(提示词区 / 采样区 / 输出区),组框自动贴合、预留标题行、成员随保存持久化。**工作流生命周期**(v0.1.12):`comfyui_new_canvas` 直接调用前端"新建空白工作流"命令开新画布(不用清空、不模拟点击)、`comfyui_rename_workflow` 命名/重命名、`comfyui_save_workflow` 保存——全部走前端 store、无对话框。**布局整理**(v0.1.12):`comfyui_move_node`(绝对/偏移移动)、`comfyui_align_nodes`(对齐 / 均匀分布 / 网格重排)整理画布,组内成员移动时组框自动重新贴合。 |
|
|
37
36
|
| **生产工具** | `comfyui_batch_run` 一次扫参数矩阵(seed / prompt / 强度)——支持显式 `runs` 或声明式 `matrix`(`zip` 并行槽位 / `product` 笛卡尔展开);`comfyui_get_outputs` 把产物直接带回对话——图像、视频、GIF、音频都支持,可传 `outputStem` 自动编号(`stem.01.png`,永不覆盖);`comfyui_attach_file` 把本机任意文件(图片/音频/视频/3D/**文本**)上传进 ComfyUI `input/` 供对应 Load 节点使用;`comfyui_export_api` 把当前画布导出为 API 格式工作流,供 comfy-cli 无人值守批量。 |
|
|
38
37
|
| **桥接节点一键安装** | `comfyui_setup_bridge` 检测 / 安装 / 更新 ComfyUI 侧桥接节点到 `custom_nodes/`(幂等、校验文件、提示何时重启)——不再需要手动复制;`comfyui_config` 一眼报告 `bridgeInstalled` / `bridgeVersion` / `bridgeUpToDate`。 |
|
|
39
38
|
| **项目目录与溯源** | 下载默认落「项目目录」(设置页可改,默认 `<工作区>/projects`);每次下载把 `runs.json`(promptId / overrides / 时间戳 / 文件)追加进项目目录,任意一张图都能追溯回它的生成参数。运行失败返回结构化 `executionError`(节点 id / 节点类型 / 异常类型 / 信息),不再是一堵 JSON 墙。 |
|
|
@@ -148,10 +147,10 @@ mv comfyui-bridge/ComfyUI-DSH-Canvas ../ComfyUI-DSH-Canvas && cd .. && rm -rf ds
|
|
|
148
147
|
|
|
149
148
|
1. 打开一个会话,点会话标头右侧的 **ComfyUI** 按钮——进入**画布左 + 对话 rail 右**的分屏形态(再点一次即关闭分屏,回到纯对话)。
|
|
150
149
|
2. 直接让 agent 干画布活:
|
|
151
|
-
-
|
|
152
|
-
-
|
|
153
|
-
-
|
|
154
|
-
-
|
|
150
|
+
- 「读取当前工作流」
|
|
151
|
+
- 「给 KSampler 设 seed 为 42」
|
|
152
|
+
- 「检查画布有没有报错」
|
|
153
|
+
- 「运行一次」
|
|
155
154
|
3. agent 会先读 `comfyui_config` 确认当前在画布模式,然后专注画布操作。
|
|
156
155
|
|
|
157
156
|
### 对话产物 → 画布
|
|
@@ -190,12 +189,24 @@ pip install comfy-cli # 独立 CLI,非 DSH 插件 —— 见 https://github.
|
|
|
190
189
|
| 场景 | 模式 |
|
|
191
190
|
|---|---|
|
|
192
191
|
| **修改现有工作流** | 在当前画布上**增量编辑**(`set_param` / `connect` / `add_node` / `remove_node` / `inject_text`)——绝不用整图 `load_workflow` 冲掉未保存现场 |
|
|
193
|
-
| **新建工作流** | `comfyui_clear
|
|
194
|
-
| **搭建复杂工作流** | `comfyui_group
|
|
192
|
+
| **新建工作流** | `comfyui_new_canvas`(新建空白画布)或 `comfyui_clear`(清空当前画布,带 `confirm` 防误清)再逐步搭 |
|
|
193
|
+
| **搭建复杂工作流** | `comfyui_group` 分具名组(提示词区 / 采样区 / 输出区)+ `comfyui_align_nodes` / `comfyui_move_node` 整理布局 |
|
|
195
194
|
| **快速验证 / 批量 / 无人值守** | **API/MCP 路径**——直接提交 API 格式工作流(或经 MCP server),不必可视化画布 |
|
|
196
195
|
|
|
197
196
|
agent 动手前先判断场景:用户在实时看画布 / 需要调优 → 画布路径;快速验证、批量扫参、无人值守 → API/MCP 路径。
|
|
198
197
|
|
|
198
|
+
### 工作流分组(组织复杂流水线)
|
|
199
|
+
|
|
200
|
+
`comfyui_group` 把复杂画布分成**具名组**,且分组随工作流保存/重载完整恢复:
|
|
201
|
+
|
|
202
|
+
| 命令 | 行为 |
|
|
203
|
+
|---|---|
|
|
204
|
+
| `add` | 围绕指定节点创建具名组,组框**自动贴合**节点(也可传 `bounding` 精确定位) |
|
|
205
|
+
| `add_nodes` / `remove_nodes` | 增删组成员,组框自动重新贴合 |
|
|
206
|
+
| `rename` / `list` | 重命名 / 列出组及成员 |
|
|
207
|
+
|
|
208
|
+
**成员持久化**:保存工作流时把每个组的成员 id(`nodes`)与 `title` / `bounding` / `color` 一并写入 JSON,加载时自动重建成员关联——重开工作流后,组框和成员关系都原样保留。
|
|
209
|
+
|
|
199
210
|
---
|
|
200
211
|
|
|
201
212
|
## 环境要求
|
|
@@ -226,7 +237,7 @@ dsh-comfyui-canvas/
|
|
|
226
237
|
│ ├── __init__.py # ComfyUI 服务端 /dsh-bridge/* 路由
|
|
227
238
|
│ └── entry/bridge.js # 注入画布前端:上报画布 + 执行命令
|
|
228
239
|
├── lib/
|
|
229
|
-
│ ├── index.js # DSH host:
|
|
240
|
+
│ ├── index.js # DSH host:26 个画布工具 + 会话隔离模式
|
|
230
241
|
│ └── client.js # DSH web:分屏画布(画布左+对话右)/ 设置页
|
|
231
242
|
├── LICENSE
|
|
232
243
|
├── README.md
|
|
@@ -240,6 +251,7 @@ dsh-comfyui-canvas/
|
|
|
240
251
|
|
|
241
252
|
## 已知问题
|
|
242
253
|
|
|
254
|
+
- **非官方桌面封装**不保证兼容——它内部跑的上游版本可能超前/滞后于本插件基线,请以官方 DSH 为准。
|
|
243
255
|
- `comfyui_reload` 目前**仅支持 Windows**(依赖 `netstat`/`taskkill`);macOS/Linux 上会明确报错而非假装成功,需手动重启 ComfyUI。
|
|
244
256
|
- 此前"画布运行后节点预览不显示"已在 v0.1.1 修复:移除 iframe 的 `referrerpolicy="no-referrer"` 消除环境差异,并在 `bridge.js` 监听 ComfyUI `executed` 事件强制重绘画布。
|
|
245
257
|
|
|
@@ -94,11 +94,61 @@ function applyWidgetValue(node, widget, value) {
|
|
|
94
94
|
throw new Error(`value ${JSON.stringify(value)} is not a valid option for ${widget.name} (${labels.join(", ")})`);
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
try {
|
|
98
|
+
widget.callback?.(widget.value, widget);
|
|
99
|
+
node.onWidgetChanged?.(widget, widget.value, null, null);
|
|
100
|
+
} catch {
|
|
101
|
+
// The widget value is still applied; the frontend callback may not be
|
|
102
|
+
// ready (e.g. a combo's options initialize asynchronously right after the
|
|
103
|
+
// node is created), so a callback hiccup must not fail the set_param.
|
|
104
|
+
}
|
|
99
105
|
node.setDirtyCanvas?.(true, true);
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
// v0.1.12: some LiteGraph builds ship LGraphGroup without addNode /
|
|
109
|
+
// computeBounding, so the bridge owns group membership and the bounding box
|
|
110
|
+
// instead of relying on those methods. `bounding` stays pos.concat(size), so
|
|
111
|
+
// saved workflows render the same box on load. The top edge keeps extra room
|
|
112
|
+
// (`titlePad`) so the group's title row never sits under a node.
|
|
113
|
+
function groupBounds(nodeObjs, pad = 20, titlePad = 50) {
|
|
114
|
+
if (!nodeObjs.length) return null;
|
|
115
|
+
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
|
|
116
|
+
for (const n of nodeObjs) {
|
|
117
|
+
const [x, y] = n.pos;
|
|
118
|
+
const [w, h] = n.size;
|
|
119
|
+
x1 = Math.min(x1, x);
|
|
120
|
+
y1 = Math.min(y1, y);
|
|
121
|
+
x2 = Math.max(x2, x + w);
|
|
122
|
+
y2 = Math.max(y2, y + h);
|
|
123
|
+
}
|
|
124
|
+
return [x1 - pad, y1 - pad - titlePad, x2 - x1 + 2 * pad, y2 - y1 + 2 * pad + titlePad];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function groupSetNodes(group, nodeObjs, keepBounds = false) {
|
|
128
|
+
// LGraphGroup.nodes is a read-only getter in some frontends, so membership
|
|
129
|
+
// lives on a bridge-owned property instead of the group's own array.
|
|
130
|
+
group._dshNodes = Array.isArray(nodeObjs) ? nodeObjs.slice() : [];
|
|
131
|
+
if (keepBounds) return;
|
|
132
|
+
const bb = groupBounds(group._dshNodes);
|
|
133
|
+
if (bb) {
|
|
134
|
+
group.pos = [bb[0], bb[1]];
|
|
135
|
+
group.size = [bb[2], bb[3]];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// v0.1.12: re-fit the bounding box of every group a moved node belongs to.
|
|
140
|
+
function refitNodeGroups(graph, node) {
|
|
141
|
+
for (const g of graph?._groups ?? []) {
|
|
142
|
+
if (Array.isArray(g._dshNodes) && g._dshNodes.includes(node)) {
|
|
143
|
+
const bb = groupBounds(g._dshNodes);
|
|
144
|
+
if (bb) {
|
|
145
|
+
g.pos = [bb[0], bb[1]];
|
|
146
|
+
g.size = [bb[2], bb[3]];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
102
152
|
async function executeCommand(cmd, payload) {
|
|
103
153
|
const graph = app.graph;
|
|
104
154
|
const LiteGraph = await resolveLiteGraph();
|
|
@@ -147,6 +197,88 @@ async function executeCommand(cmd, payload) {
|
|
|
147
197
|
return { removed: String(nodeId) };
|
|
148
198
|
}
|
|
149
199
|
|
|
200
|
+
// v0.1.12: move a node (absolute `pos` or relative `offset`) and re-fit
|
|
201
|
+
// the bounding boxes of any groups the node belongs to.
|
|
202
|
+
case "move_node": {
|
|
203
|
+
const { nodeId, pos, offset } = payload;
|
|
204
|
+
const node = graph.getNodeById(+nodeId);
|
|
205
|
+
if (!node) throw new Error(`node not found: ${nodeId}`);
|
|
206
|
+
if (Array.isArray(pos) && pos.length >= 2 && isFinite(+pos[0]) && isFinite(+pos[1])) {
|
|
207
|
+
node.pos = [+pos[0], +pos[1]];
|
|
208
|
+
} else if (Array.isArray(offset) && offset.length >= 2 && isFinite(+offset[0]) && isFinite(+offset[1])) {
|
|
209
|
+
node.pos = [node.pos[0] + +offset[0], node.pos[1] + +offset[1]];
|
|
210
|
+
} else {
|
|
211
|
+
throw new Error("move_node requires payload.pos [x,y] or payload.offset [dx,dy] as numbers");
|
|
212
|
+
}
|
|
213
|
+
refitNodeGroups(graph, node);
|
|
214
|
+
scheduleReport();
|
|
215
|
+
return { nodeId: String(node.id), pos: node.pos.slice() };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// v0.1.12: tidy a set of nodes — align edges/centers, distribute evenly
|
|
219
|
+
// along an axis, or reflow into a grid. Re-fits the groups the nodes
|
|
220
|
+
// belong to, so aligned group members stay wrapped by their group box.
|
|
221
|
+
case "align_nodes": {
|
|
222
|
+
const { nodeIds, mode = "align", edge = "left", axis = "horizontal" } = payload ?? {};
|
|
223
|
+
const cols = Math.max(1, Math.floor(+payload?.cols || 2));
|
|
224
|
+
const gap = Math.max(0, +payload?.gap || 40);
|
|
225
|
+
const nodes = (Array.isArray(nodeIds) ? nodeIds : []).map((id) => graph.getNodeById(+id)).filter(Boolean);
|
|
226
|
+
if (!nodes.length) throw new Error("align_nodes requires payload.nodeIds");
|
|
227
|
+
const x1 = Math.min(...nodes.map((n) => n.pos[0]));
|
|
228
|
+
const x2 = Math.max(...nodes.map((n) => n.pos[0] + n.size[0]));
|
|
229
|
+
const y1 = Math.min(...nodes.map((n) => n.pos[1]));
|
|
230
|
+
const y2 = Math.max(...nodes.map((n) => n.pos[1] + n.size[1]));
|
|
231
|
+
const cx = (x1 + x2) / 2;
|
|
232
|
+
const cy = (y1 + y2) / 2;
|
|
233
|
+
if (mode === "grid") {
|
|
234
|
+
const colW = Math.max(...nodes.map((n) => n.size[0])) + gap;
|
|
235
|
+
const rowH = Math.max(...nodes.map((n) => n.size[1])) + gap;
|
|
236
|
+
nodes.forEach((n, i) => {
|
|
237
|
+
n.pos = [x1 + (i % cols) * colW, y1 + Math.floor(i / cols) * rowH];
|
|
238
|
+
});
|
|
239
|
+
} else if (mode === "distribute") {
|
|
240
|
+
const sorted = [...nodes].sort((a, b) => axis === "vertical"
|
|
241
|
+
? (a.pos[1] + a.size[1] / 2) - (b.pos[1] + b.size[1] / 2)
|
|
242
|
+
: (a.pos[0] + a.size[0] / 2) - (b.pos[0] + b.size[0] / 2));
|
|
243
|
+
if (sorted.length < 3) {
|
|
244
|
+
const mid = axis === "vertical" ? cy : cx;
|
|
245
|
+
sorted.forEach((n) => {
|
|
246
|
+
if (axis === "vertical") n.pos[1] = mid - n.size[1] / 2;
|
|
247
|
+
else n.pos[0] = mid - n.size[0] / 2;
|
|
248
|
+
});
|
|
249
|
+
} else {
|
|
250
|
+
const first = sorted[0];
|
|
251
|
+
const last = sorted[sorted.length - 1];
|
|
252
|
+
const from = axis === "vertical" ? first.pos[1] + first.size[1] / 2 : first.pos[0] + first.size[0] / 2;
|
|
253
|
+
const to = axis === "vertical" ? last.pos[1] + last.size[1] / 2 : last.pos[0] + last.size[0] / 2;
|
|
254
|
+
const step = (to - from) / (sorted.length - 1);
|
|
255
|
+
sorted.forEach((n, i) => {
|
|
256
|
+
const c = from + i * step;
|
|
257
|
+
if (axis === "vertical") n.pos[1] = c - n.size[1] / 2;
|
|
258
|
+
else n.pos[0] = c - n.size[0] / 2;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
nodes.forEach((n) => {
|
|
263
|
+
if (edge === "left") n.pos[0] = x1;
|
|
264
|
+
else if (edge === "right") n.pos[0] = x2 - n.size[0];
|
|
265
|
+
else if (edge === "top") n.pos[1] = y1;
|
|
266
|
+
else if (edge === "bottom") n.pos[1] = y2 - n.size[1];
|
|
267
|
+
else if (edge === "hcenter") n.pos[0] = cx - n.size[0] / 2;
|
|
268
|
+
else if (edge === "vcenter") n.pos[1] = cy - n.size[1] / 2;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
for (const n of nodes) refitNodeGroups(graph, n);
|
|
272
|
+
scheduleReport();
|
|
273
|
+
return {
|
|
274
|
+
moved: nodes.map((n) => String(n.id)),
|
|
275
|
+
mode,
|
|
276
|
+
edge: mode === "align" ? edge : null,
|
|
277
|
+
axis: mode === "distribute" ? axis : null,
|
|
278
|
+
cols: mode === "grid" ? cols : null,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
150
282
|
// M2: load a whole workflow back onto the canvas (rewrite loop).
|
|
151
283
|
case "load_workflow": {
|
|
152
284
|
const data = payload.workflow;
|
|
@@ -331,10 +463,146 @@ async function executeCommand(cmd, payload) {
|
|
|
331
463
|
return { cleared: true, nodes: 0 };
|
|
332
464
|
}
|
|
333
465
|
|
|
334
|
-
// v0.1.
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
466
|
+
// v0.1.12: probe which UI affordances exist so the agent can pick the
|
|
467
|
+
// right element to drive (e.g. the New-workflow button may live in the
|
|
468
|
+
// topbar, sidebar or menu depending on the frontend's current view).
|
|
469
|
+
case "ui_probe": {
|
|
470
|
+
const selectors = Array.isArray(payload.selectors) ? payload.selectors : [
|
|
471
|
+
".new-blank-workflow-button",
|
|
472
|
+
"[aria-label*='new']",
|
|
473
|
+
"[aria-label*='New']",
|
|
474
|
+
"[data-testid*='workflow']",
|
|
475
|
+
"[data-testid*='Workflow']",
|
|
476
|
+
"button:has(> i.pi-plus)",
|
|
477
|
+
];
|
|
478
|
+
const found = [];
|
|
479
|
+
for (const sel of selectors) {
|
|
480
|
+
try {
|
|
481
|
+
const els = document.querySelectorAll(sel);
|
|
482
|
+
if (els.length) {
|
|
483
|
+
const first = els[0];
|
|
484
|
+
found.push({
|
|
485
|
+
selector: sel,
|
|
486
|
+
count: els.length,
|
|
487
|
+
tag: first.tagName,
|
|
488
|
+
cls: String(first.className || "").slice(0, 80),
|
|
489
|
+
aria: first.getAttribute("aria-label") || "",
|
|
490
|
+
text: (first.textContent || "").trim().slice(0, 30),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
} catch {
|
|
494
|
+
/* invalid selector */
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// Vue app chain diagnostic (which hop breaks the pinia command store).
|
|
498
|
+
const rootEl = document.querySelector("#vue-app") ?? document.querySelector("#app") ?? document.body;
|
|
499
|
+
const vueApp = rootEl?.__vue_app__ ?? null;
|
|
500
|
+
const pinia = vueApp?.config?.globalProperties?.$pinia ?? null;
|
|
501
|
+
const vue = {
|
|
502
|
+
appElId: rootEl.id || rootEl.tagName,
|
|
503
|
+
hasVueApp: Boolean(vueApp),
|
|
504
|
+
hasPinia: Boolean(pinia),
|
|
505
|
+
storeIds: pinia?._s ? Array.from(pinia._s.keys()).slice(0, 50) : null,
|
|
506
|
+
};
|
|
507
|
+
return { probed: selectors, found, vue };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// v0.1.12: start a brand-new workflow through the frontend's own command
|
|
511
|
+
// (Comfy.NewBlankWorkflow — the sidebar/topbar "新建" action), invoked via
|
|
512
|
+
// the pinia command store instead of a DOM click. Falls back to clicking
|
|
513
|
+
// the topbar button when the store is unreachable.
|
|
514
|
+
case "new_canvas": {
|
|
515
|
+
const rootEl = document.querySelector("#vue-app") ?? document.querySelector("#app") ?? document.body;
|
|
516
|
+
const pinia = rootEl.__vue_app__?.config?.globalProperties?.$pinia;
|
|
517
|
+
const store = pinia?._s?.get("command");
|
|
518
|
+
if (store && typeof store.execute === "function") {
|
|
519
|
+
await store.execute("Comfy.NewBlankWorkflow");
|
|
520
|
+
scheduleReport();
|
|
521
|
+
return { triggered: true, via: "command-store" };
|
|
522
|
+
}
|
|
523
|
+
const btn = document.querySelector(".new-blank-workflow-button");
|
|
524
|
+
if (btn && typeof btn.click === "function") {
|
|
525
|
+
btn.click();
|
|
526
|
+
scheduleReport();
|
|
527
|
+
return { triggered: true, via: "topbar-new-button" };
|
|
528
|
+
}
|
|
529
|
+
throw new Error("new-blank-workflow not reachable (no command store, no button)");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// v0.1.12: name / rename the active workflow directly through the
|
|
533
|
+
// workflow store (no dialog). Without payload.name it reports the current
|
|
534
|
+
// workflow name, path and persistence state.
|
|
535
|
+
case "rename_workflow": {
|
|
536
|
+
const rootEl = document.querySelector("#vue-app") ?? document.querySelector("#app") ?? document.body;
|
|
537
|
+
const pinia = rootEl?.__vue_app__?.config?.globalProperties?.$pinia;
|
|
538
|
+
const workflowStore = pinia?._s?.get("workflow");
|
|
539
|
+
const workflow = workflowStore?.activeWorkflow ?? null;
|
|
540
|
+
if (!workflow) throw new Error("no active workflow");
|
|
541
|
+
const name = typeof payload.name === "string" ? payload.name.trim() : "";
|
|
542
|
+
if (!name) {
|
|
543
|
+
return {
|
|
544
|
+
filename: workflow.filename ?? "",
|
|
545
|
+
path: workflow.path ?? "",
|
|
546
|
+
isPersisted: Boolean(workflow.isPersisted),
|
|
547
|
+
isTemporary: Boolean(workflow.isTemporary),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
// Filename safety: cap length and reject path/control characters so a
|
|
551
|
+
// crafted name cannot escape the workflow directory (mirrors the
|
|
552
|
+
// frontend's own 1-50 char filename rule).
|
|
553
|
+
if (name.length > 50 || /[\\/:*?"<>|\u0000-\u001f]/.test(name)) {
|
|
554
|
+
throw new Error(`invalid workflow name: 1-50 chars, no path or control characters (${JSON.stringify(name)})`);
|
|
555
|
+
}
|
|
556
|
+
if (typeof workflowStore.renameWorkflow !== "function") {
|
|
557
|
+
throw new Error("workflow store lacks renameWorkflow");
|
|
558
|
+
}
|
|
559
|
+
const suffix = workflow.suffix || "json";
|
|
560
|
+
const finalName = /\.\w+$/.test(name) ? name : `${name}.${suffix}`;
|
|
561
|
+
const newPath = workflow.directory
|
|
562
|
+
? `${workflow.directory}/${finalName}`
|
|
563
|
+
: finalName;
|
|
564
|
+
await workflowStore.renameWorkflow(workflow, newPath);
|
|
565
|
+
scheduleReport();
|
|
566
|
+
return { renamed: true, filename: finalName, path: newPath };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// v0.1.12: execute any frontend command by id through the command store
|
|
570
|
+
// (e.g. Comfy.SaveWorkflow / Comfy.SaveWorkflowAs), no dialog, no click.
|
|
571
|
+
case "command": {
|
|
572
|
+
const rootEl = document.querySelector("#vue-app") ?? document.querySelector("#app") ?? document.body;
|
|
573
|
+
const pinia = rootEl?.__vue_app__?.config?.globalProperties?.$pinia;
|
|
574
|
+
const store = pinia?._s?.get("command");
|
|
575
|
+
const id = typeof payload.id === "string" ? payload.id : "";
|
|
576
|
+
if (!id) throw new Error("command requires payload.id");
|
|
577
|
+
if (!store || typeof store.execute !== "function") {
|
|
578
|
+
throw new Error("command store not reachable");
|
|
579
|
+
}
|
|
580
|
+
await store.execute(id, payload.options);
|
|
581
|
+
scheduleReport();
|
|
582
|
+
return { executed: id };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// v0.1.12: persist the active workflow directly through the workflow
|
|
586
|
+
// store (no dialog) — the reliable no-UI equivalent of Comfy.SaveWorkflow.
|
|
587
|
+
case "save_workflow": {
|
|
588
|
+
const rootEl = document.querySelector("#vue-app") ?? document.querySelector("#app") ?? document.body;
|
|
589
|
+
const pinia = rootEl?.__vue_app__?.config?.globalProperties?.$pinia;
|
|
590
|
+
const workflowStore = pinia?._s?.get("workflow");
|
|
591
|
+
const workflow = workflowStore?.activeWorkflow ?? null;
|
|
592
|
+
if (!workflow) throw new Error("no active workflow");
|
|
593
|
+
if (typeof workflowStore.saveWorkflow !== "function") {
|
|
594
|
+
throw new Error("workflow store lacks saveWorkflow");
|
|
595
|
+
}
|
|
596
|
+
await workflowStore.saveWorkflow(workflow);
|
|
597
|
+
scheduleReport();
|
|
598
|
+
return { saved: true, filename: workflow.filename ?? "", path: workflow.path ?? "" };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// v0.1.8: group nodes into named groups (complex-workflow organization).
|
|
602
|
+
// The bridge manages membership and the bounding box itself (groupSetNodes)
|
|
603
|
+
// because not every LiteGraph build ships LGraphGroup.addNode. Availability
|
|
604
|
+
// varies across custom frontends, so `list` reports groupingAvailable so
|
|
605
|
+
// the agent can degrade gracefully instead of hard-failing.
|
|
338
606
|
case "group": {
|
|
339
607
|
const { action, title, id, nodeIds } = payload ?? {};
|
|
340
608
|
const groups = graph?._groups ?? null;
|
|
@@ -349,10 +617,14 @@ async function executeCommand(cmd, payload) {
|
|
|
349
617
|
if (!title || typeof title !== "string") throw new Error("group add requires payload.title");
|
|
350
618
|
const nodeObjs = (Array.isArray(nodeIds) ? nodeIds : []).map((n) => graph.getNodeById(+n)).filter(Boolean);
|
|
351
619
|
const group = new LiteGraph.LGraphGroup(title);
|
|
352
|
-
|
|
620
|
+
if (Array.isArray(payload.bounding)) {
|
|
621
|
+
group.pos = [payload.bounding[0], payload.bounding[1]];
|
|
622
|
+
group.size = [payload.bounding[2], payload.bounding[3]];
|
|
623
|
+
}
|
|
624
|
+
groupSetNodes(group, nodeObjs, Array.isArray(payload.bounding));
|
|
353
625
|
groups.push(group);
|
|
354
626
|
scheduleReport();
|
|
355
|
-
return { groupingAvailable: true, id: groups.indexOf(group), title: group.title, nodes:
|
|
627
|
+
return { groupingAvailable: true, id: groups.indexOf(group), title: group.title, nodes: group._dshNodes.map((n) => String(n.id)) };
|
|
356
628
|
}
|
|
357
629
|
case "rename": {
|
|
358
630
|
const g = groups[+id];
|
|
@@ -364,22 +636,32 @@ async function executeCommand(cmd, payload) {
|
|
|
364
636
|
case "add_nodes": {
|
|
365
637
|
const g = groups[+id];
|
|
366
638
|
if (!g) throw new Error(`group not found: ${id}`);
|
|
639
|
+
const seen = new Set((g._dshNodes ?? []).map((n) => n.id));
|
|
640
|
+
const merged = (g._dshNodes ?? []).slice();
|
|
367
641
|
const added = [];
|
|
368
642
|
for (const n of Array.isArray(nodeIds) ? nodeIds : []) {
|
|
369
643
|
const node = graph.getNodeById(+n);
|
|
370
|
-
if (
|
|
644
|
+
if (!node || seen.has(node.id)) continue;
|
|
645
|
+
seen.add(node.id);
|
|
646
|
+
merged.push(node);
|
|
647
|
+
added.push(String(n));
|
|
371
648
|
}
|
|
649
|
+
groupSetNodes(g, merged);
|
|
372
650
|
scheduleReport();
|
|
373
651
|
return { groupingAvailable: true, id: +id, added };
|
|
374
652
|
}
|
|
375
653
|
case "remove_nodes": {
|
|
376
654
|
const g = groups[+id];
|
|
377
655
|
if (!g) throw new Error(`group not found: ${id}`);
|
|
656
|
+
const ids = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((n) => String(n)));
|
|
378
657
|
const removed = [];
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
658
|
+
groupSetNodes(g, (g._dshNodes ?? []).filter((n) => {
|
|
659
|
+
if (ids.has(String(n.id))) {
|
|
660
|
+
removed.push(String(n.id));
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
return true;
|
|
664
|
+
}));
|
|
383
665
|
scheduleReport();
|
|
384
666
|
return { groupingAvailable: true, id: +id, removed };
|
|
385
667
|
}
|
|
@@ -389,7 +671,7 @@ async function executeCommand(cmd, payload) {
|
|
|
389
671
|
groups: groups.map((g, i) => ({
|
|
390
672
|
id: i,
|
|
391
673
|
title: g.title ?? "",
|
|
392
|
-
nodes: (g.
|
|
674
|
+
nodes: (g._dshNodes ?? []).map((n) => String(n.id)),
|
|
393
675
|
})),
|
|
394
676
|
};
|
|
395
677
|
}
|
|
@@ -449,10 +731,40 @@ app.registerExtension({
|
|
|
449
731
|
graph.configure = function (...args) {
|
|
450
732
|
const out = prevGraphConfigure.apply(this, args);
|
|
451
733
|
scheduleReport();
|
|
734
|
+
// v0.1.11: re-attach persisted group membership after a graph load so
|
|
735
|
+
// saved workflows restore which nodes belong to each group.
|
|
736
|
+
const data = args[0];
|
|
737
|
+
const groupsData = Array.isArray(data?.groups) ? data.groups : null;
|
|
738
|
+
const groups = this?._groups ?? null;
|
|
739
|
+
if (groupsData && Array.isArray(groups)) {
|
|
740
|
+
for (let i = 0; i < groups.length && i < groupsData.length; i++) {
|
|
741
|
+
const ids = groupsData[i]?.nodes;
|
|
742
|
+
if (!Array.isArray(ids)) continue;
|
|
743
|
+
const nodeObjs = ids.map((id) => this.getNodeById(+id)).filter(Boolean);
|
|
744
|
+
groupSetNodes(groups[i], nodeObjs, true);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
452
747
|
return out;
|
|
453
748
|
};
|
|
454
749
|
}
|
|
455
750
|
|
|
751
|
+
// v0.1.11: persist group membership in saved workflows — patch the group
|
|
752
|
+
// serializer so member ids land in the workflow JSON next to
|
|
753
|
+
// title/bounding/color, and the configure hook above restores them on load.
|
|
754
|
+
resolveLiteGraph().then((LG) => {
|
|
755
|
+
const proto = LG?.LGraphGroup?.prototype;
|
|
756
|
+
const orig = proto?.serialize;
|
|
757
|
+
if (!proto || typeof orig !== "function" || proto._dshSerializePatched) return;
|
|
758
|
+
proto._dshSerializePatched = true;
|
|
759
|
+
proto.serialize = function () {
|
|
760
|
+
const data = orig.call(this) ?? {};
|
|
761
|
+
if (Array.isArray(this._dshNodes) && this._dshNodes.length) {
|
|
762
|
+
data.nodes = this._dshNodes.map((n) => n.id);
|
|
763
|
+
}
|
|
764
|
+
return data;
|
|
765
|
+
};
|
|
766
|
+
});
|
|
767
|
+
|
|
456
768
|
// M1: listen for write commands pushed over the WebSocket.
|
|
457
769
|
// Per-tab targeting (M3): when the backend names a target clientId (the
|
|
458
770
|
// last one that reported), only that tab executes — several open ComfyUI
|
package/lib/client.js
CHANGED
|
@@ -1086,6 +1086,11 @@ function CanvasTab(props) {
|
|
|
1086
1086
|
};
|
|
1087
1087
|
fit();
|
|
1088
1088
|
outer.style.display = "flex";
|
|
1089
|
+
// 画布专注模式:告知 host 本会话正在画布标签(comfyui_config 按会话读取,
|
|
1090
|
+
// agent 据此知道你在画布开工;按会话隔离,卸载时写回 chat)。
|
|
1091
|
+
if (scope && typeof scope.set === "function" && props.sessionId) {
|
|
1092
|
+
setSessionView(scope, String(props.sessionId), "canvas");
|
|
1093
|
+
}
|
|
1089
1094
|
var sync = function () {
|
|
1090
1095
|
// frame.src 常带尾斜杠而 activeBase 不带——裸 !== 会让每次挂载都重置 src
|
|
1091
1096
|
// 导致 ComfyUI 重载。归一化后仅当地址真正变化才更新。
|
|
@@ -1104,6 +1109,9 @@ function CanvasTab(props) {
|
|
|
1104
1109
|
if (ro) ro.disconnect();
|
|
1105
1110
|
window.removeEventListener("resize", fit);
|
|
1106
1111
|
document.documentElement.removeAttribute("data-dsh-comfyui-fullscreen");
|
|
1112
|
+
if (scope && typeof scope.set === "function" && props.sessionId) {
|
|
1113
|
+
setSessionView(scope, String(props.sessionId), "chat");
|
|
1114
|
+
}
|
|
1107
1115
|
// 仅隐藏,不摘除:iframe 浏览上下文保活,切回秒开。
|
|
1108
1116
|
outer.style.display = "none";
|
|
1109
1117
|
};
|
package/lib/index.js
CHANGED
|
@@ -1582,6 +1582,92 @@ export function apply(ctx) {
|
|
|
1582
1582
|
},
|
|
1583
1583
|
}))
|
|
1584
1584
|
|
|
1585
|
+
// v0.1.12: workflow lifecycle + node layout tools — new canvas, name/rename,
|
|
1586
|
+
// save, move, align — driven through the frontend's own stores (no dialog,
|
|
1587
|
+
// no DOM click), so they work in any view of the ComfyUI page.
|
|
1588
|
+
ctx.tools.register(defineTool({
|
|
1589
|
+
name: 'comfyui_new_canvas',
|
|
1590
|
+
description: 'Start a brand-new blank workflow on the live ComfyUI canvas through the frontend\'s own ' +
|
|
1591
|
+
'"New Blank Workflow" action (Comfy.NewBlankWorkflow) instead of clearing the current graph. ' +
|
|
1592
|
+
'Matches the sidebar 新建画布. The previous workflow view is replaced — read/export it first if needed.',
|
|
1593
|
+
parameters: {},
|
|
1594
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
1595
|
+
async execute(_args, exec) {
|
|
1596
|
+
return sendCommand(baseUrl(), bridgeToken(), 'new_canvas', {}, exec.signal, { timeoutMs: 10000 })
|
|
1597
|
+
},
|
|
1598
|
+
}))
|
|
1599
|
+
|
|
1600
|
+
ctx.tools.register(defineTool({
|
|
1601
|
+
name: 'comfyui_rename_workflow',
|
|
1602
|
+
description: 'Name or rename the active ComfyUI workflow directly (no dialog). Without `name` it reports the ' +
|
|
1603
|
+
'current workflow filename / path / persistence state. Names are capped at 50 chars and reject path and ' +
|
|
1604
|
+
'control characters.',
|
|
1605
|
+
parameters: {
|
|
1606
|
+
name: { type: 'string', description: 'New workflow name (omit to only query the current name/state).' },
|
|
1607
|
+
},
|
|
1608
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
1609
|
+
async execute(args, exec) {
|
|
1610
|
+
return sendCommand(baseUrl(), bridgeToken(), 'rename_workflow', { name: args?.name }, exec.signal, { timeoutMs: 10000 })
|
|
1611
|
+
},
|
|
1612
|
+
}))
|
|
1613
|
+
|
|
1614
|
+
ctx.tools.register(defineTool({
|
|
1615
|
+
name: 'comfyui_save_workflow',
|
|
1616
|
+
description: 'Persist the active ComfyUI workflow through the frontend workflow store (no dialog) — the ' +
|
|
1617
|
+
'reliable no-UI equivalent of Comfy.SaveWorkflow. Use after building/editing so the workflow survives reload.',
|
|
1618
|
+
parameters: {},
|
|
1619
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
1620
|
+
async execute(_args, exec) {
|
|
1621
|
+
return sendCommand(baseUrl(), bridgeToken(), 'save_workflow', {}, exec.signal, { timeoutMs: 10000 })
|
|
1622
|
+
},
|
|
1623
|
+
}))
|
|
1624
|
+
|
|
1625
|
+
ctx.tools.register(defineTool({
|
|
1626
|
+
name: 'comfyui_move_node',
|
|
1627
|
+
description: 'Move a node on the live ComfyUI canvas by absolute `pos` [x,y] or relative `offset` [dx,dy]. ' +
|
|
1628
|
+
'Group boxes containing the node are re-fitted automatically, so moving a group member keeps its box hugging it.',
|
|
1629
|
+
parameters: {
|
|
1630
|
+
nodeId: { type: 'number', required: true, description: 'Node id to move.' },
|
|
1631
|
+
pos: { type: 'array', description: 'Absolute canvas position [x, y].' },
|
|
1632
|
+
offset: { type: 'array', description: 'Relative offset [dx, dy] added to the current position.' },
|
|
1633
|
+
},
|
|
1634
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
1635
|
+
async execute(args, exec) {
|
|
1636
|
+
return sendCommand(baseUrl(), bridgeToken(), 'move_node', {
|
|
1637
|
+
nodeId: args?.nodeId,
|
|
1638
|
+
pos: args?.pos,
|
|
1639
|
+
offset: args?.offset,
|
|
1640
|
+
}, exec.signal, { timeoutMs: 8000 })
|
|
1641
|
+
},
|
|
1642
|
+
}))
|
|
1643
|
+
|
|
1644
|
+
ctx.tools.register(defineTool({
|
|
1645
|
+
name: 'comfyui_align_nodes',
|
|
1646
|
+
description: 'Tidy a set of nodes on the live ComfyUI canvas: align edges/centers (`mode=align`, ' +
|
|
1647
|
+
'`edge=left|right|top|bottom|hcenter|vcenter`), distribute evenly (`mode=distribute`, ' +
|
|
1648
|
+
'`axis=horizontal|vertical`), or reflow into a grid (`mode=grid`, `cols`, `gap`). Group boxes of the ' +
|
|
1649
|
+
'moved nodes are re-fitted automatically.',
|
|
1650
|
+
parameters: {
|
|
1651
|
+
nodeIds: { type: 'array', required: true, description: 'Node ids to arrange.' },
|
|
1652
|
+
mode: { type: 'string', enum: ['align', 'distribute', 'grid'], description: 'Arrangement mode (default align).' },
|
|
1653
|
+
edge: { type: 'string', enum: ['left', 'right', 'top', 'bottom', 'hcenter', 'vcenter'], description: 'Alignment edge for mode=align.' },
|
|
1654
|
+
axis: { type: 'string', enum: ['horizontal', 'vertical'], description: 'Distribution axis for mode=distribute.' },
|
|
1655
|
+
cols: { type: 'number', description: 'Column count for mode=grid (default 2).' },
|
|
1656
|
+
gap: { type: 'number', description: 'Gap between cells for mode=grid (default 40).' },
|
|
1657
|
+
},
|
|
1658
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
1659
|
+
async execute(args, exec) {
|
|
1660
|
+
return sendCommand(baseUrl(), bridgeToken(), 'align_nodes', {
|
|
1661
|
+
nodeIds: args?.nodeIds,
|
|
1662
|
+
mode: args?.mode,
|
|
1663
|
+
edge: args?.edge,
|
|
1664
|
+
axis: args?.axis,
|
|
1665
|
+
cols: args?.cols,
|
|
1666
|
+
gap: args?.gap,
|
|
1667
|
+
}, exec.signal, { timeoutMs: 8000 })
|
|
1668
|
+
},
|
|
1669
|
+
}))
|
|
1670
|
+
|
|
1585
1671
|
// v0.1.2 C: node development/debug tools — read / edit custom-node source and
|
|
1586
1672
|
// reload ComfyUI so the change takes effect, all from the conversation. The
|
|
1587
1673
|
// canvas doubles as the observation window: edit → reload → run a minimal
|
|
@@ -1753,8 +1839,8 @@ export function apply(ctx) {
|
|
|
1753
1839
|
'| 场景 | 做法 | 禁止 |',
|
|
1754
1840
|
'|---|---|---|',
|
|
1755
1841
|
'| 修改现有工作流 | 当前画布直接增量编辑(set_param/connect/add_node/remove_node/inject_text) | 不要 load_workflow 整图替换(冲掉未保存现场) |',
|
|
1756
|
-
'| 新建工作流 | 先 comfyui_clear
|
|
1757
|
-
'| 搭建复杂工作流 |
|
|
1842
|
+
'| 新建工作流 | 先 comfyui_new_canvas 新建空白画布(或 comfyui_clear 清空当前画布)再搭 | 不要在有内容/运行记录的画布上直接覆盖 |',
|
|
1843
|
+
'| 搭建复杂工作流 | comfyui_group 分组 + comfyui_align_nodes / comfyui_move_node 整理布局(提示词区/采样区/输出区…) | 不要所有节点平铺一团 |',
|
|
1758
1844
|
'| 快速验证/批量/无人值守 | 走 API/MCP 路径(mcp-comfyui 或原生 /prompt 直接提交),不必可视化 | 不要为展示而无谓地在画布上一步步搭 |',
|
|
1759
1845
|
'',
|
|
1760
1846
|
'双路径选择:可视调优/用户在看画布 → 画布路径(bridge 命令);快速验证/批量/用户未开画布 → API/MCP 路径(导出 workflow 直交,或 mcp-comfyui 工具)。',
|
|
@@ -1769,7 +1855,7 @@ export function apply(ctx) {
|
|
|
1769
1855
|
'6. comfyui_get_outputs 取回出图(支持 images/videos/gifs/audio)',
|
|
1770
1856
|
'7. 用 vision 工具自检出图质量 → 不达标回 step 3 迭代;达标再交付',
|
|
1771
1857
|
'',
|
|
1772
|
-
'
|
|
1858
|
+
'安全约定:读取永远只读;写操作(改节点/运行/替换图/清空画布/新建/重命名/保存)先向用户确认;批量用 comfyui_batch_run;清空画布(comfyui_clear)或新建画布(comfyui_new_canvas)前先 comfyui_read_workflow 留存现场,搭建完成后用 comfyui_save_workflow 保存。',
|
|
1773
1859
|
].join('\n'),
|
|
1774
1860
|
})
|
|
1775
1861
|
skills.register({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-comfyui-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Give DeepSeek Harness agents live control of your ComfyUI canvas (local or cloud): embed it as a split-screen tab, then read/edit/run workflows, debug errors, fetch output images, and batch-sweep parameters straight from the chat. Ships the ComfyUI-side bridge node.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"LICENSE",
|
|
15
15
|
"README.md",
|
|
16
16
|
"README.zh.md",
|
|
17
|
+
"CHANGELOG.md",
|
|
17
18
|
"cordis.patch.yml",
|
|
18
19
|
"lib",
|
|
19
20
|
"comfyui-bridge",
|