Skip to content

dsh-mask-editor-plugin

Verified

dsh-mask-editor-plugin · v0.1.1 · MIT · Web UI

DeepSeek Harness 画布插件:canvas_editor —— 调用后在网页端弹出画笔画布(参考 ComfyUI 掩码编辑器),用户亲手涂。画布上有两个互相独立的笔迹层:「掩码」层产出与原图同尺寸的黑白掩码 PNG,「笔迹」层产出把笔迹烧进原图的合成 PNG;画了就是要、没画就是不要,所以两层都画就两个文件都落盘、只画一层就只落那一个、一层都没画就点保存=放弃这次编辑。工具栏可把一层复制追加到另一层(省得画两遍),画完还能补一句给模型的话。结果自动落盘(路径、尺寸、笔数与覆盖率随工具结果返回

Install

dsh plugin add dsh-mask-editor-plugin

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Published to npm without a public repository. Inspect the package contents before installing.

Tags

Readme

dsh-mask-editor-plugin —— 画布编辑器(工具 canvas_editor)

包名还是 dsh-mask-editor-plugin(它是插件在 profile 里的身份,改它要动 profile 的依赖声明), 但工具名叫 canvas_editor:它现在既出掩码也出「被编辑的图像」,mask_editor 这个名字只承诺 了其中一半。

让用户亲手画,再把结果交给下游。一个工具 canvas_editor:调用后网页端在这条工具调用 的卡片里弹出画笔画布(参考 ComfyUI 掩码编辑器的交互),用户在图上涂抹/标注,点「完成并保存」, 结果自动落盘,路径、尺寸、笔数、涂抹覆盖率随工具结果返回给模型,可直接交给别的图像工具用。

原图是必填参数:两个笔迹层都以它为底。


装法

工作区里 pnpm install && pnpm --filter dsh-mask-editor-plugin run build 之后,用 Plugins → Add plugin 以绝对路径(C:\DSH\dsh-mask-editor-plugin)安装本 bundle。

依赖只有两处,都有兜底:

服务 必要性 缺席时
tools 硬依赖(inject) 插件不激活
connection 机会性(ctx.inject) 插件照常激活,调用 canvas_editor 时报错说明「需要一个打开的网页端界面」
attachments 可选 结果里的预览图块缺席,落盘的文件不受影响

核心:两个笔迹层,两种输出

画布上有两个互相独立的笔迹层,工具栏「正在画」在两者之间切换:

层 画出来是什么 产出什么文件
掩码 恒为白(只看形状,笔刷颜色与它无关) 与原图同尺寸的掩码 PNG
笔迹 笔刷颜色(默认 #ff3b30) 被编辑的图像:把这一层的笔迹烧进原图的合成 PNG

产出什么完全由用户把笔迹画在哪一层决定——画了就是要,没画就是不要:

用户画了 落盘
只有掩码层 只落掩码 PNG
只有笔迹层 只落合成图 PNG
两层都画 两个文件都落
一层都没画就点保存 空保存=放弃这次编辑,不落任何文件(工具结果里说明,不是错误)

mode 参数因此退化成纯意向:它只决定编辑器打开时「正在画」停在哪一层、以及卡片上给 用户看的那句「模型想要…」,不决定产出。结果里的 outputs 才是实数。

工具栏还有「转成笔迹 / 转成掩码」:把当前这一层的笔迹复制追加到另一层(另一层原有的 笔迹保留),因此「画一遍、两层都要」只需画一次再点一下。这一步可用「撤销」整批退掉。

两个输出各自的格式

  • 掩码:24 位无 alpha 的 RGB PNG——掩码的形状写在 RGB 里,不靠透明通道,任何加载器 都不会看错。语义与 dsh-comfyui-studio-plugin 的 mask_from_transparency 一致—— 白 = 涂抹区,黑 = 没涂;半透明笔刷留下的是灰度(灰度值就是笔刷覆盖强度),所以硬笔刷 出来就是纯黑白,柔笔刷自带羽化。可直接当 edit_image 的 mask_image(白=重绘),或当 别的图像工具的选区输入。勾了「反相」就黑白对调(黑=涂抹区)。
  • 合成图:原图 + 笔迹层笔迹,保留 alpha——原图本身带透明(抠图)时,合成结果不该把 它抹平。适合让用户在图上圈重点、写字、标出错处。

工具 canvas_editor

参数 必填 说明
image_path ✅ 原图路径(绝对路径,或相对会话工作目录)。PNG / JPEG / WebP / GIF / BMP;TIFF 不行——浏览器显示不了,与其在画布上开天窗不如直接拒绝
mode 纯意向:mask(默认)/ paint / both。只决定初始「正在画」的层与「模型想要…」那句提示,不决定产出
output_path 落盘基名(不是最终文件名):两种输出各自在它后面加后缀(掩码 → <基名>_mask.png,被编辑的图像 → <基名>_paint.png)。省略则基名取原图路径去掉扩展名(落在原图旁边)
brush_size 笔刷初始直径(原图坐标系像素),默认 48(可配置)
brush_color 「笔迹」层的笔迹初始颜色(#RRGGBB),默认 #ff3b30
opacity 笔刷初始不透明度 0–1,默认 1
hardness 笔刷初始硬度 0–1(1 = 硬边,越小边缘越柔),默认 1
invert 掩码输出保存时黑白对调
note 显示在编辑器顶部、给用户看的一句话(例如「请把主体之外的区域涂满」)

结果字段

ok / cancelled / mode(你给的意向,原样回报)/ outputs(实际落盘了哪些: ['mask'] / ['paint'] / ['mask','paint'] / [])/ mask 与 paint 两格 (各含 path / width / height / bytes / strokes / coverage)/ detail(给模型看的 结论)/ mask_image 与 paint_image(结果图的附件引用,用户也能看到,两张都产出时两张都附), 外加可选的 user_message:用户在编辑器里补写的那句话(见下节「捎一句话」)。

产出与意向不一致时,结论里会点明(你这次想要掩码,但用户没有在对应的层上画… / 另外还多产出了合成图:用户在你没要的那层上也画了)。

等待、取消与中断

调用会一直等用户在网页上画完——默认上限 30 分钟(waitTimeoutMs,设 0 就是不限时)。 用户点「取消」、或等待超时、或这一轮被停止、或空保存,都会让工具正常返回一个「这次没有生成 任何文件」的结果(不是错误),模型据此决定下一步。


前端界面

画布出现在那条工具调用自己的卡片里(tool.call.toolview,键 canvas_editor,与 Host 注册的工具名逐字一致)——模型一调用, 用户就在对话流的原位看到它;用户画完点保存,工具返回,卡片自己切换成结果行(文字结论 + 保存过的预览缩略图,两张就两张都显示)。

控件 说明
画笔 / 橡皮 / 移动 橡皮只擦当前层已画的部分;移动是抓手(画布放大后拖视图),也可以随时用鼠标中键拖
正在画:笔迹 / 掩码 新笔迹落在哪一层。两层的位图同时显示,非当前层调淡到 35%,所以「刚才那几笔在哪层」一眼能看出来。默认取 Host 给的初始层
转成笔迹 / 转成掩码 把当前层的笔迹复制追加到另一层(画一遍、两层都要);可撤销
笔刷 直径 1–400 px(原图坐标系),[ ] 快捷键微调
不透明度 5%–100%,半透明在掩码里就是灰度
硬度 0%–100%(Chromium 的 canvas filter 实现柔边;不支持的环境自动退回硬边)
颜色 只在「笔迹」层出现(掩码层恒为白)
撤销 / 清空本层 撤销走一个操作栈(落笔 / 转层追加 / 清空),所以「撤销一次转层」是整批退掉,清空之后也还能撤销回来;只影响当前层
反相 掩码输出保存时黑白对调;开着时状态行会说明
只看掩码 把照片与笔迹层藏掉、底色转黑,所见即掩码层本身
缩小 / 放大 / 适应 / 1:1 适应 = 整图放进视口;1:1 = 一个原图像素对一个屏幕像素;滚轮也能缩放
捎一句话 可选的留言框:留了话就随保存一起交给模型,作为 user_message 回来——模型把它当作你对下一步的直接要求,所以「按这个位置放角色,别改背景」这种话写在这里最省事
完成并保存 / 取消 保存后按钮锁住,等工具返回。悬停保存按钮能看见这次会写到哪几个文件

状态行实时显示尺寸、两层各自的笔数与覆盖率、以及保存结果;Ctrl/⌘+Z 撤销,[ ] 调笔刷。 两层都还是空的时候,动作区上方会明说「现在点保存=放弃这次编辑,不会生成任何文件」。 原图超过 2500 万像素时会提示「可能有点卡」,但涂抹本身仍按原始分辨率记录。

模型的 note 只出现在卡片顶部那个提示盒子(pre-wrap,多行也放得下);动作行左边始终是 「怎么画」的通用提示,不会把 note 再重复一遍。

卡片会自己展开,会话信息会自己等

模型在等用户动手的时候,界面不该让用户先点一下才发现。两处专门为此调整:

  • 自动展开:当前的工作详情模式(transcriptView: standard → stepGrouping: "collapsed")会把工具 卡片折进「正在调用工具: …」那一行,只露出标题——「模型卡在等人」这件事就变得很隐蔽。现在卡片 一挂载就会把这层分组展开(用 harness 自己写在 DOM 上的 data-step-process 容器与头部按钮 aria-expanded 契约,外加 slot 交给我们的 useDisclosure),并把卡片滚进视野。只做一次, 之后用户自己收起来就不再打扰。

  • 会话信息轮询着问:tool/call 事件比 Host 的 execute() 更早到浏览器(agent loop 先广播这次 调用,再 prepare → dispatch 才真正执行),卡片会在 Host 登记之前就来问「这次要画什么」。所以 Host 改成先登记、后校验(登记时 ready: false,原图校验完再翻真),这期间 session 路由回 409 + pending: true。前端分两种失败对待:

    情况 判据 处理
    软等待 我们那条路由答了话(JSON),只是这次「还没登记 / 已经结束」 按 0.2s→2.5s 的阶梯重试约 14 秒;阶梯跑完就换成每 5 秒问一次、显示「还在等」的黄色提示与「重试」按钮——永远不糊红字,工具一结束卡片会自己卸载
    硬失败 拿不到我们的 JSON(路由没挂上 / 网关报错 / 网络不通) 同样走阶梯,连续 6 次仍如此才给红字(这种情况等下去也没用)

配置(cordis.patch.yml 里覆盖同名行即可)

键 默认 说明
defaultBrushSize 48 笔刷初始直径
defaultBrushColor #ff3b30 「笔迹」层的笔迹初始颜色
defaultOpacity 1 笔刷初始不透明度
defaultHardness 1 笔刷初始硬度
maskSuffix _mask 掩码输出加在基名后的后缀
paintSuffix _paint 合成图输出加在基名后的后缀(两个后缀不能相同,否则登记前就报错)
waitTimeoutMs 1800000 等用户画完的上限(毫秒),0 = 不限时
maxImageBytes 67108864 愿意发给前端当底图的原图字节上限
maxOutputBytes 67108864 单个输出的写盘上限;整包提交的上限是它的两倍
attachResultImage true 是否把结果图作为图像块附在工具结果里(两张都产出就附两张)
allowOverwriteSource false 是否允许算出来的输出路径指向原图本身

实现要点

  • 四条路由(挂在 Connection 的 Fetch 注册表上,浏览器会话 cookie 鉴权、与传输无关):

    方法 路径 作用
    GET /api/dsh-canvas-editor/session 这次要画什么:模型意向、初始层、原图、默认笔刷、两个候选落盘路径、提示语;原图还没校验完时回 409 + pending: true
    GET /api/dsh-canvas-editor/image 原图字节
    POST /api/dsh-canvas-editor/submit 用户画完提交:multipart/form-data,mask / paint 两段 PNG(缺哪层就不带哪段)+ meta 一段 JSON(尺寸、两层的笔数与覆盖率、留言)
    POST /api/dsh-canvas-editor/cancel 用户点了取消

    前端只用「文档相对」URL(api/dsh-canvas-editor/...),所以挂在任意前缀下都对得上。 一次提交把两段字节一起交上来,所以「用户画完了」是原子的——不会出现「掩码写完了、合成图还没来」 的中间态。Host 用标准 request.formData() 解析,不引入任何依赖。

  • 谁在等谁:工具的第一件事就是把这次绘图登记进一张待办表(键是 exec.callId,另外留着 「该会话唯一待办」的兜底查找)——任何 await 之前,这样前端卡片再早也问得到;原图校验完 才把 ready 翻真。然后 await;前端按 callId / sessionId 拉到会话信息、画完把两段 PNG POST 回来,Host 落盘后工具才返回。前端拿不到任何 Host 没登记过的东西。

  • 合成在浏览器,落盘在 Host:两张输出都是浏览器 canvas 画好的(掩码 = 黑底 + 掩码层笔迹, 合成图 = 原图 + 笔迹层笔迹),Host 只校验 PNG 头(用 IHDR 复核宽高,报告里的尺寸以文件为准)、 写文件。Host 不引入任何图像解码依赖。

  • 落盘路径先算好:output_path 是基名,Host 在登记前就把两个候选路径算好发给前端,提交 时按实际产出的那一层直接写。两条候选路径都与原图路径比对过,撞上就直接报错(除非把 allowOverwriteSource 打开);两个后缀相同时也在登记前报错。

  • 两条半边的开发节奏不同:改 client.js 由客户端 HMR 热更新(浏览器里刷新页面就生效);改 Host 半边(src/mask-editor.ts → lib/mask-editor.js)需要重启 dsh 才会加载新的 lib/mask-editor.js——Loaders 按模块 URL 缓存,同一个文件名改了内容不会重载(install_bundle 对它会报 ambiguous-install,因为它在 profile 里是 link 依赖)。


自测

npm test 一次跑完下面全部(三个都不需要浏览器):

npm test                      # = 两道类型检查 + 两个 node 脚本
tsc -p tsconfig.typecheck.json   # Host 半边(src/*.ts)
tsc -p tsconfig.client.json      # Client 半边(client.js,allowJs + checkJs)
node test\host-loop.mjs          # Host 半边:假 ctx 装插件,按前端的顺序跟四条路由对话
node test\client-wiring.mjs      # Client 半边:假 window/document/react 加载 client.js,验接线与协议名

host-loop.mjs 覆盖(62 项):挂载即问必须是 409 + pending(先登记后校验)、校验完拿到会话 (意向 / 初始层 / 两个候选路径 / 原图)、取原图、两层都画 → 两个文件都落盘、只画掩码层 → 只落掩码、只画笔迹层 → 只落合成图、两层都没画就保存=空保存(不落文件、结果说「放弃」)、 与意向不一致时结论点明、留言进结果、显式 output_path 是基名(两种输出各加后缀、基名自己不被 当成文件名写出来)、原图不存在时抛错且登记撤掉、非图片格式被拒、两个后缀相同与「算出来的路径 指向原图」在登记前被拒、坏提交(不是 multipart / 段里不是 PNG)被拒且不落盘、坏提交之后这张 待办还能正常提交。提交那段刻意自己拼 multipart 字节并自己带 boundary 头,与真实 carrier (缓冲成一个 Buffer 再 new Request)走同一条路。

client-wiring.mjs 覆盖(48 项):工厂能跑、apply() 注册了对的 slot 键与中英语言包、样式注入、 slot 键与 Host 注册的工具名逐字一致、两半边协议名对得上(路由名、multipart 的字段名 mask/paint/meta、session 的字段名)、提交只交「画过东西的那几层」、两层各有自己的位图与矢量 数组、非当前层被调淡、「正在画」切换、转成另一层是深拷贝追加、撤销是操作栈(清空之后还能撤销回来)、 笔迹颜色由层决定、产出跟着笔数实时算、两层都空时明说「保存=放弃」、掩码仍用无 alpha 画布 (colortype 2)、结果行能显示两张预览、软等待/硬失败两种策略都在、note 只渲染一处。

tsconfig.client.json 那道检查不是摆设:它抓出过一个 node --check 看不见的真错误——一个新加的 常量在用到的地方没写声明(Cannot find name),光跑语法检查会一路绿灯,直到用户在浏览器里遇到 会话拉取失败时才抛 ReferenceError。手写的 Client 半边没有构建步骤,这层静态检查就是它的门。

它测不了「画面好不好看、两层调淡的比例顺不顺眼、自动展开在你浏览器里是否真的展开」——那要人点。


验证状态(本机实机跑通到 2026-10-11;两层版是 2026-10-12 改的)

  • ✅ Host 半边:行 canvas-editor 已激活(fiberPhase: active),工具出现在列表里(改名前后都 复核过运行中的定义:cordis_inspect_query 的 Tool.listTools——改名必须重启 dsh 才会生效, 见下面「生效方式」)。
  • ✅ Client 半边:tool.call.toolview 的 canvas_editor 键已被本插件占用且 active。
  • ✅ tsc -p tsconfig.typecheck.json(Host)与 tsc -p tsconfig.client.json(Client)都无错; tsdown 构建通过。
  • ✅ test/host-loop.mjs、test/client-wiring.mjs 全绿(断言条数见「自测」一节)。
  • ✅ 两层版实机跑过一轮(2026-10-13,用户亲手画):两层各画一笔(「笔迹」层左上、取色正好 #ff3b30;「掩码」层右上、纯白),落盘 subject_mask.png(912×912、1 笔、1.8%、colorType 2) 与 subject_paint.png(912×912、1 笔、1.9%、colorType 6)。逐像素复核:掩码的白区与合成图 相对原图的差异区重合 0 像素(两份输出各自只由自己那一层生成),掩码文件在红笔那一片是纯黑、 合成图在掩码那一笔的位置读出来就是原图本身。
  • ⬜ 观感没人眼定过:两层调淡的比例、转层的手感、两张预览在结果行里的排布——这些要真人看过 才算数(功能性的那一轮已经跑通)。

旧版(单一模式)实机验证过的东西

  • ✅ mask 模式:落盘 test-subject_mask.png / test-subject_mask2.png,912×912, 覆盖率 2.3% / 16.5%、笔数 2 / 3;文件头 colorType 2(RGB、无 alpha)。
  • ✅ paint 模式:落盘 test-subject_paint.png,912×912、3 笔、9.6%。
  • ✅ 结果回流:结果作为图像块回到了模型与用户眼前(附件服务那一路也通)。
  • ✅ 输出模式切换(两个方向都试过,现在换成「正在画哪层」+ 转层,那个交互已不存在)。
  • ✅ 自动展开;note 只渲染一处;留言框 → user_message;原图不被碰。

历史缺陷(已修,改动后仍然防着)

  1. 浏览器默认的 canvas 带 alpha 通道,toBlob('image/png') 于是写出 colorType 6(RGBA)—— 掩码的形状明明在 RGB 里,却还挂着一条「处处 255」的 alpha,按 alpha 取掩码的加载器(ComfyUI 的 LoadImage 就是)会把整张图读成「全选」。修法是掩码画布改用 getContext('2d', { alpha: false }) (现在 client-wiring.mjs 钉着这一行)。合成图保留 alpha。
  2. 「拿不到这次编辑器会话:这次绘制已经结束(或不属于当前会话)」(第二次调用 canvas_editor 时 撞上,工具却还在等):tool/call 事件比 Host 的 execute() 早到浏览器。修法是 Host 先登记后 校验(ready: false → 校验完翻真,期间回 409 + pending),前端把「我们路由回的 JSON」都当 「再等等」并走阶梯重试,阶梯跑完改 5 秒一次继续等;只有「拿不到我们的 JSON」才在连续 6 次后判为 连线问题。
  3. note 在卡片顶部与底部各渲染了一遍:现在底部固定显示通用的「怎么画」提示,note 只在顶部 盒子出现一次。
  4. 一个没声明就被用到的常量(SESSION_SOFT_POLL_MS):为此加了 tsconfig.client.json (allowJs + checkJs),npm test 现在先跑它。

生效方式

  • 改 client.js:浏览器里刷新页面即可(客户端 bundle 直接读文件,有 HMR)。
  • 改 Host 半边(src/mask-editor.ts → lib/mask-editor.js):Loaders 按模块 URL 缓存,必须 重启 dsh 才会加载新的 lib/mask-editor.js;只刷新页面不算数。对它在 profile 里是 link 依赖, install_bundle 会回 ambiguous-install、plugin_manager set_plugin 要的是插件的入口行 id (这一行是 include:canvas-editor,不是 patch 里的 canvas-editor)。 实测过:plugin_manager set_plugin(把 include:canvas-editor 关掉再打开)不会重新导入同一个 模块 URL,改文件时间戳也没用——注册表里仍是旧定义。想免重启得让模块 URL 变(例如把入口文件改名), 这条路没试过。
  • 改工具名(canvas_editor ← mask_editor):Host 与 Client 必须一起改——卡片靠 slot 键认领 「哪条工具调用的卡片」,键与注册名不一致时画布不会出现,而两边都不报错(client-wiring.mjs 里 有一条断言逐字比对这两个名字)。副作用:旧会话里记录的 mask_editor 调用不再认领卡片,退回通用卡片。