DeepSeek Harness Plugin Hub

Publish and manage complete Harness Profiles. Discover Plugins for your next setup.

Explore

PluginsPresetsDocsNews

Community

Publish a pluginContactReport an issue

Resources

Plugin Hub on GitHubDeepSeek HarnessSystem statusPrivacy notice
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

Independent and unofficial. Not affiliated with, authorized by, or endorsed by DeepSeek.

Image Tool — DSH Plugin for DeepSeek Harness
DeepSeek Harness Plugin Hub
ProfilesPluginsCategoriesNewsDocsSign inManage Profiles
ProfilesPluginsCategoriesNewsDocsSign in
← Plugins
I

dsh-image-tool

Image Tool

DeepSeek Harness image generation and editing tool plugin: connects any OpenAI-compatible image generation/editing service as a platform tool, allowing models to generate or modify images directly in conversations. Images are output as native platform image blocks and displayed in the tool card (thu

The plugin will be installed here. Keep web if you are unsure.

npx -y @deepseek-ai/dsh plugin --profile web add github:lance808/dsh-image-tool#c7d11767a9de84a78c21d2b3c9d88ce6597da71d
READMECompatibilityVersions

Description

DeepSeek Harness image generation and editing tool plugin: connects any OpenAI-compatible image generation/editing service as a platform tool, allowing models to generate or modify images directly in conversations. Images are output as native platform image blocks and displayed in the tool card (thumbnail / full screen / download / open in workspace). The image generation API and conversation model API are completely independent, with optional confirmation before calls.

Compatibility and provenance

Image Tool is published as dsh-image-tool and currently resolves to version 0.2.0. The Hub verifies its manifest and preserves the exact installation source for reproducible installs.

DSH compatibility
*
Runtime surfaces
web
Release source
github
Registry updated
10/14/2026

Versions

0.2.0stable
10/14/2026

Related plugins

Loading related plugins…

Latest
0.2.0
DSH
*
HMR
Process restart
Tree shaking
Safe tree shaking not declared
Unpacked size
Unavailable
Files
Unavailable
Surface
web
License
MIT
Source
github
GitHub
★ 1
Weekly downloads
0
Last push
10/14/2026
View source ↗
README badge

Click the badge to copy Markdown for your README.

Do you maintain this Plugin?Claim benefit · Priority security scan

Verify the GitHub repository declared in package.json to manage this listing. After you claim it, Hub will prioritize a security scan of the current version and publish the result when it passes.

Claim this Plugin →
Report an issue

Related plugins

More verified plugins in vision-media.

Modlens@liustack/modlensPlug-in vision for text-only LLMs, powered by the free Antigravity CLITool Describe Image@linxin666/dsh-tool-describe-imageModel-facing describe_image tool for the dsh web GUI: gives a text-only model image understanding by asking a vision-language model at an OpenAI-compatible endpoint to describe one image (local path, http(s) URL, or attachment reference). Hot-pluggable — Vision Toolkit@anionex/dsh-vision-toolkitDeepSeek Harness-native integration for agent-vision-toolkit: image Q&A, OCR, grounding, UI restoration, pixel diff, Artifacts, and Web UI.Deepseek Ivideodeepseek-ivideoiPolloWork HyperFrames Video Studio and 27 editable video templates as a native DeepSeek Harness conversation view.

README

dsh-image-tool

给 DeepSeek Harness 补上生图与改图能力:把任意 OpenAI 兼容的生图服务接成平台工具, 模型在对话里说要画图或改图,图片就以平台原生图片卡出现在对话中。

An image generation & editing plugin for DeepSeek Harness — any OpenAI-compatible image API, exposed as native platform tools.

  • 生图 API 与对话模型 API 完全独立 —— 用 DeepSeek 聊天、用别处画图,各配各的密钥
  • 两个工具:generate_image 生成新图;image_edit 基于已有图做修改(局部重绘 / 换风格 / 扩图)
  • 连续改图:image_edit 不传原图就取本会话最近一张 —— 「把这张图改成…」可以直接连着说
  • 对话里就是图 —— 注册平台工具卡,缩略图、点击全屏、下载、在工作区打开展开即用
  • 不引入任何私有内容块 —— 只用平台自己的 image 块,纯文本模型也能安全经过
  • 调用前弹窗确认(生图与改图各自一个开关)—— 真实计费,不希望你不在场时被刷
  • 完整的设置页 —— DSH「设置 → 图像生成」里填接口、密钥、默认参数与保存目录
  • 一键识别上游模型 —— 设置页点「识别上游模型」即从 {apiUrl}/models 拉取列表并选择
  • 外部调用接口 —— /api/image-tool/*,不想经过模型时由别的程序直接调
  • 不依赖构建 —— 宿主半是普通 ESM,浏览器半是手写的 ModuleLoader bundle

目录

  • 1. 它解决了什么问题
  • 2. 安装
  • 3. 配置
  • 4. 使用
  • 5. 安全与隐私
  • 6. 关于"纯文本模型看到的占位文本"
  • 7. 项目结构
  • 8. 开发与测试
  • 9. 已知限制
  • 10. 许可证

1. 它解决了什么问题

DeepSeek Harness 里没有"生图协议"这条通道。LLM 适配器的契约是 stream() → 文本 + 工具调用,图片想进入对话,只有一条正路:

由工具结果携带平台的 { type: 'image', attachment } 内容块。

所以"补全生图接口"不是发明一个协议,而是把图片接进既有的内容块体系。 本插件做的就是这件事:注册一个工具,工具内部打生图接口,结果渲染成平台自己的图片块。

为什么不能用私有内容块:平台的消息适配器不会跳过未知块 —— 一个自定义块类型 会让整个回合以 UNSUPPORTED_CONTENT 失败,连文本都拿不到。用平台自己的 image 块, 纯文本模型与多模态模型就能共用同一条链路,无需任何配置分叉。

为什么还要自己画卡片:平台内置的图片卡只认 read_image 这一个工具名 (ui-tool 的 imageCardModel 写死了这一点)。别的工具落到通用卡片,而通用卡片会把 非文本内容块 JSON.stringify 之后显示 —— 也就是说,只把 image 块塞进结果、 不注册工具卡的话,用户在对话里看到的是一坨附件引用 JSON,而不是图片。 本插件注册 tool.call.toolview 的 generate_image / image_edit 两个 key,把图真正画出来 (缩略图、点击全屏、下载、在工作区打开),并且不声明 tool.call.images 子槽 —— 那个子槽已被内置的 read_image 声明,一个子槽只能由一个 entry 声明。

改图为什么是另一个工具:generate_image 与 image_edit 的必填项不同(改图必须有一张原图)、 上游端点不同(/images/generations 收 JSON,/images/edits 收 multipart/form-data)、 闸门也不同(改图确认框要写明改的是哪张图)。合成一个带 action 参数的工具有百害而无一利。


2. 安装

node scripts/install-into-profile.mjs desktop

参数是 profile 名(不传则读 DSH_PROFILE,再不然默认 web)。脚本做三件事,全部幂等:

  1. ~/.dsh/plugins/dsh-image-tool → 本目录(人工可读的"安装位")
  2. <profile>/node_modules/dsh-image-tool → ①(Node 的解析路径,缺了加载不起来)
  3. <profile>/package.json 的 dependencies + dsh.profile.bundles

装完 重启 DSH。

为什么不用 dsh plugin add:那条路依赖 dsh CLI 与 pnpm,很多只有桌面端的环境没有。 本插件是纯 JS、只有一个普通依赖,两层目录链接 + 一行 profile 声明就够了,不需要构建步骤。

脚本对已存在的链接不会静默覆盖:如果目标指向别处,它会报错并打印当前值与期望值, 让你自己决定 —— 默默覆盖等于替你做了一个你不知情的决定。


3. 配置

3.1 在 DSH 设置页里填(推荐)

设置 → 图像生成,一个完整表单:

分组内容
接口接口地址、API Key、生图模型、改图模型(可点「识别上游模型」拉取列表选择)
默认出图参数尺寸、改图尺寸(均为 1K / 2K / 4K 分组下拉)、张数
图片保存默认保存路径
调用前确认每次生图 / 改图前向我确认(两个独立开关)
高级超时、单张大小上限、四个可选透传项

它由插件的浏览器半(client.js)注册到 settings.section 槽位,数据走插件自己的 同源路由 /api/image-tool/config。

密钥永远不会回传到这个页面:只显示 已设置(…abcd),输入框留空即"不修改", 清除必须点专门的按钮。

浏览器半是手写的 ModuleLoader bundle,没有构建步骤 —— React 由外壳提供, 本插件不带任何前端依赖。

识别上游模型

「生图模型」右侧的 识别上游模型 按钮会拉取 {apiUrl}/models,把结果去重排序后 列成下拉框,选中即填入输入框(仍然要点「保存」才生效)。

几个刻意的选择:

  • 不自动请求:只在点按钮时才打上游。打开设置页就自动查,等于每次进设置都替你 发一次你没要求的网络请求,而且会在你还没填完 Key 的时候就报错。
  • 超时 20 秒:列表接口不该让人等几十秒;慢到这个程度就报错,让你手动填写。
  • 失败原因如实回报:分清"Key 被拒(HTTP 401)"和"这个上游根本不提供 /models",后者要明确提示手动填写。
  • 兼容多种回包形态:{data:[{id}]}、{data:["x"]}、{models:[...]}、裸数组, 以及用 name / model 代替 id 的网关。
  • 识别结果不落盘:它只是帮你把值填进输入框;配置以点「保存」写入的为准。
  • 改过接口地址或密钥后列表作废:保存成功会清空已识别的列表,避免选到一个不属于当前上游的模型。

3.2 用附带的 CLI

node scripts/configure.mjs                 # 交互式逐项询问
node scripts/configure.mjs --show          # 查看当前状态(含权威的 hasKey)
node scripts/configure.mjs --api-url=https://api.openai.com/v1 --api-key=sk-xxx --model=gpt-image-1
node scripts/configure.mjs --set count=2   # 改单个字段(可重复出现)
node scripts/configure.mjs --clear-key     # 清除已保存的密钥

适合脚本化或在没有浏览器时使用。它把 settings RPC(连同本机 Cookie 铸造)包成一条命令。

3.3 或者直接调 settings API

配置就是一个标准的 settings 命名空间 image-tool:

// POST /api/settings/update
{ "ns": "image-tool",
  "patch": { "apiUrl": "https://api.openai.com/v1", "apiKey": "sk-...", "model": "gpt-image-1" } }

⚠️ 服务方法签名是位置参数 update(ns, patch),与 RPC 端点的 { ns, patch } 信封不是一回事。

3.4 字段

字段默认说明
apiUrl''生图接口基址,如 https://api.openai.com/v1。允许本机(http://127.0.0.1:7860 这类本地服务是正当用法)
apiKey''生图服务密钥。与对话模型凭据无关
model''生图模型名;留空则不发送 model 字段,由上游默认。设置页可点「识别上游模型」从 {apiUrl}/models 拉取列表后选择
editModel''改图模型名;留空沿用 model。有些服务商的生图与改图是两个模型名,共用一个字段会让改图必然 404
size''默认尺寸。设置页是分组下拉:1K / 2K / 4K 三档,覆盖 1:1、4:3、3:2、16:10、16:9、21:9 电影,以及各自的竖屏方向(3:4 / 2:3 / 10:16 / 9:16 / 9:21),另附 OpenAI 官方固定尺寸。需要列表外的特殊尺寸时,在对话里让模型传 size 参数
editSize''改图尺寸;留空沿用 size,两处都留空则完全不发送 size(由上游按原图尺寸处理 —— 改图通常希望这样)
count1默认张数(1~8)
savePath''默认保存目录;留空保持当前会话的 generated-images/,支持绝对目录或相对会话工作目录的目录
confirmBeforeGeneratetrue生图前弹窗确认
confirmBeforeEdittrue改图前弹窗确认(独立开关;确认框里会写明改的是哪张图)
timeoutMs180000单次调用上限。生图是同步接口,实测几十秒很正常
maxImageBytes20971520单张图片字节上限(同时是改图读取原图的上限)
quality / style / background / outputFormat''可选透传项,只在你填了值时才发给上游;生图与改图都带

配置是 live 的:改完即时生效,不需要重启,下一次调用就用新值。

3.5 端点约定

生图打 {apiUrl}/images/generations(OpenAI 兼容;如果 apiUrl 已经写到这个端点上, 不会重复拼接):

{ "prompt": "...", "n": 1, "model": "...", "size": "...", "response_format": "b64_json" }

改图打 {apiUrl}/images/edits,multipart/form-data,字段为 model / prompt / size / n / response_format / image(二进制)/ mask(可选,二进制)。 把 apiUrl 填成生图端点也不会拼错 —— 解析时会先把 /images/generations 退掉。

为什么不走 generations + 参考图字段(如 input_image):那类字段各家网关支持不一, OpenAI 形态的中转会直接以 Unknown parameter: 'input_image' 拒绝; /images/edits 反而是每个 OpenAI 兼容中继都实现的 portable 路径。

两种请求的回包都支持三种现实形态:data[].b64_json、data[].url、data[].image_url.url。 媒体类型按魔数嗅探,不信任上游声明的字段。


4. 使用

4.1 对话工具 generate_image

模型在对话里调用即可出图:

用户:画一只在雨里等公交的猫
模型:→ generate_image({ prompt: "一只在雨里等公交的猫", size: "1024x1024" })

生成的图片以平台原生 image 块进入对话,并在界面上渲染成图片卡 (缩略图、点击全屏、下载、在工作区打开)。保存路径会立即显示在工具结果里, 不需要额外询问:

第 1 张已生成至 ./generated-images/generated-1730000000000-1.png
已保存到:D:\...\generated-images\generated-1730000000000-1.png

落盘位置规则:

  • savePath 留空 → 保存到当前会话工作目录的 generated-images/
  • savePath 填了 → 保存到该目录(不存在时自动创建)
  • 目录不可写 / 拿不到会话工作目录 → 只存附件库,并在结果里说明原因

尺寸:设置页的下拉覆盖常用档位与比例(含竖屏与电影画幅);需要列表外的特殊尺寸时, 在对话里直接说,模型会把 size 传给工具 ——

用户:用 3200x1440 画一张雪山日出
模型:→ generate_image({ prompt: "雪山日出", size: "3200x1440" })

这条路径不受下拉限制。若设置页里的当前值是手填的特殊尺寸,下拉会把它单独列成 「…(当前值,不在预设内)」,不会因为找不到匹配项而显示空白。

4.2 改图工具 image_edit

基于一张已有图片做修改,走 /images/edits(multipart 上传原图):

{ "prompt": "把背景换成夜晚,加几盏路灯", "image": "generated-images/generated-1730000000000-1.png" }

原图(image 参数)有三种写法,按这个顺序判定:

写法例子说明
省略{"prompt": "改成水彩风"}取本会话最近一张图 —— 连续改图就靠它
附件 ID{"image": "sha256:…"}指定会话里某一张(含你自己上传的图)
工作目录路径{"image": "generated-images/…png"}读磁盘上的文件,必须在当前会话工作目录内

"最近一张"同时覆盖两种情况:模型上一轮刚生成的图,以及你自己上传到对话里的参考图。 用了哪张会在结果里写明(原图来源:…),你可以核对模型改的是不是你以为的那张 —— 这是花钱之前最值得拦住的一种误解,所以确认弹窗里也会带上它。

局部重绘(mask):

{ "prompt": "把这个区域换成一片星空", "mask": "masks/sky.png" }

mask 与 image 一样支持附件 ID 或工作目录路径。约定是 PNG、与原图同尺寸、 透明处就是允许改动的范围;媒体类型不是 PNG 时放行但在结果里给一句提示 (有些中继接受别的写法,硬拦反而挡住了能用的路径)。

尺寸:editSize 与 size 都留空时完全不发送 size 字段,由上游按原图尺寸处理 —— 这通常是改图最不容易出错的行为。

连续改图的完整例子:

用户:画一只在雨里等公交的猫
模型:→ generate_image({ prompt: "一只在雨里等公交的猫" })
用户:把这张图改成晴天
模型:→ image_edit({ prompt: "改成晴天,阳光透过云层" })      # 没传 image → 取最近一张
用户:再给它加一把伞
模型:→ image_edit({ prompt: "给猫加一把黄色的伞" })          # 又取刚改出来的那张

4.3 另存工具 save_generated_image

把已生成的图片另存到别处,不重新生图、不计费:

{ "path": "images/天空之城.png" }

不传 attachmentId 时保存当前会话最近一张图片;指定时从生成结果里取 id:

{ "path": "images/另一份.png", "attachmentId": "sha256:..." }

从当前会话的图片块恢复附件引用,因此不依赖进程内索引,重启后仍可用。 只接受工作目录内部路径、不覆盖已有文件、不转换编码;省略扩展名时补上实际格式。

4.4 外部调用接口

供不需要经过模型的调用方使用:

# 配置状态(不回显密钥,只给 hasKey 与末四位指纹)
GET  /api/image-tool/status

# 读 / 写配置(设置页用的就是这两个)
GET  /api/image-tool/config
POST /api/image-tool/config      { "apiUrl": "...", "apiKey": "...", "clearApiKey": false, ... }

# 识别上游可用模型(设置页的「识别上游模型」按钮用的就是它)
GET  /api/image-tool/models      → { ok, models: ["..."], endpoint, count }

# 生图;includeData=1 时额外返回 base64 字节
POST /api/image-tool/generate
     { "prompt": "一只猫", "size": "1024x1024", "count": 1 }

# 改图;HTTP 调用必须传 cwd(路径边界),image 用工作目录内的相对路径
POST /api/image-tool/edit
     { "cwd": "D:\\work", "image": "input.png", "prompt": "改成夜景", "mask": "mask.png" }

# 读取本次挂载期间生成的附件字节(需要同一会话鉴权 Cookie)
GET /api/image-tool/image?id=<attachmentId>
GET /api/image-tool/image?id=<attachmentId>&download=1   # 带 Content-Disposition,可浏览器另存

生成与改图的响应形状完全一致:images[] 包含完整 ref、fetchUrl 与 downloadUrl; 改图额外带 origin(用了哪张原图)。外部调用方不需要为"这次是改图"换一套解析逻辑。

/image 的引用索引最多保存 200 条,插件重新挂载(含配置更新)或进程重启后失效。 长期保留字节请用 includeData=1 并自行保存。

外部 HTTP 调用没有会话工作目录,所以 /generate 不落盘;/edit 需要按路径读原图, 因此要在请求体里显式给出 cwd —— 我们既无法替你判断"这个路径在不在允许范围内", 也不该拿进程当前目录去猜。给了 cwd 就与工具路径一样落盘。

⚠️ 鉴权是自己做的,不是白捡的

这一点很容易想当然,而想错的后果是安全漏洞:

挂在 webServer 上的自定义 prefix 路由会抢在 /api 的会话鉴权之前 —— webServer 按最长前缀派发,本插件的前缀比 /api 更长。

所以这些端点不会自动继承 /api/* 的鉴权。本插件显式向 connection 服务要判定 (connection.requestRejection(request));拿不到 connection 时不假装安全: 只提供只读面,配置写入、生图与上游模型查询一律 403。

工具路径与 HTTP 路径共用同一份 runGenerate / runEdit,所以协议、校验、SSRF 关卡、 附件落地完全一致,不存在"两条路行为不同"。唯一差别是:确认弹窗只加在工具路径上 —— 那个闸门要防的是"模型自作主张花钱生图";HTTP 调用是调用方主动发起的明确意图, 再弹一次窗既问错了人,也会让自动化无法工作。

本插件不含任何聊天平台相关内容,是通用的生图工具。


5. 安全与隐私

⚠️ 密钥的落盘语义(请务必知道)

apiKey 标记为 role('secret'),它保证的是:

  • ✅ 不会回传到浏览器:平台的 settings 投影会把它结构化剥离,API 只报告 secrets: [{ path: ['apiKey'], set: true }],前端永远拿不到原值。
  • ❌ 不是加密存储:它会以明文写进 <profile>/cordis.patch.yml 里那条 entry 的 config 段。该文件的权限是默认 ACL(本机用户与管理员可读),不是 0600。

如果这个取舍不可接受:请只填权限受限的 Key,或改用 DSH 的凭据服务自行托管密钥。

清除密钥要用 unset,不是写空串

把 apiKey 写成 "" 不会清除它 —— secret 字段把空串当作"本次没有改动", 这是平台为避免"没渲染出来的密钥被空表单误删"而定的语义。正确做法:

node scripts/configure.mjs --clear-key
# 等价于 settings/mutate 的 { op: 'unset', path: ['apiKey'] }

两条地址的可信度是分开对待的

地址来源策略
apiUrl你自己填的允许本机/私网。本地生图服务(SD WebUI、ComfyUI、one-api…)是正当用法,拦掉等于把本地部署判死
回包里的图片 url上游给的过 SSRF 关卡:只允许 http/https,拒绝环回、私网、链路本地(含 169.254.169.254 云元数据)、.local、IPv6 的 fc/fd/fe80/::1 及 IPv4-mapped 形态

把这两条混成一条,不是把本地用户拦死,就是把内网暴露出去。

图片下载另有约束:禁止自动重定向(避免校验过的公网地址跳进内网)、 按流累计校验大小(不只是信任 Content-Length)、并接受调用方的取消信号。

其他

  • 没有显式 Key 时一个字节都不发(不会拿别处的凭据顶上)。
  • 回包正文有 32 MiB 上限,图片下载有 15 秒 / maxImageBytes 上限。
  • 不做自动重试:生图按张计费,盲目重试会在"其实已经出图、只是回包慢"时重复计费。 失败就把原因讲清楚,由你决定要不要再来一次。
  • 另存工具限定在工作目录内,拒绝覆盖已有文件,且会逐段拒绝符号链接目录。
  • 改图会把原图字节上传给上游(这正是 /images/edits 的工作方式), 而且它只读当前会话工作目录内的文件 —— 想改别处的图,先复制进来。

6. 关于"纯文本模型看到的占位文本"

如果当前对话模型只接受文本,平台会在派发前把每个 image 块替换成一段占位:

[image omitted because this model accepts text only; attachment sha256:...]

这是正常行为,不是错误 —— 图片已经生成并落进附件库,界面上照样显示图片卡 (卡片读的是工具结果的附件引用与 presentationMeta,与模型能不能看图无关), 只是这个模型看不到像素。换成多模态模型就能直接看到。

之所以要这么设计:平台的消息适配器不会跳过未知块,而用一个私有块去渲染图片会让 整个回合失败。用平台自己的 image 块,两类模型就能共用同一条链路,无需任何配置。 改图工具同理:把图交给外部服务改,根本不依赖当前模型有没有视觉能力。


7. 项目结构

dsh-image-tool/
├── package.json                dsh.bundle.patch + dsh.client(宿主半 + 浏览器半)
├── cordis.patch.yml            bundle patch;entry id 必须是 image-tool
├── client.js                   浏览器半:注册「图像生成」设置页(settings.section)
│                               + generate_image / image_edit 的工具卡(tool.call.toolview)
│                               手写 ModuleLoader bundle,无构建步骤
├── lib/
│   ├── index.js                入口:Config schema / apply(step() 逐步容错)/
│   │                           runGenerate + runEdit / imageToolOutput(render + presentationMeta)
│   │                           落盘:makeDiskSaver + cwdFromExecution + makeImageFetcher
│   ├── generate.js             协议核心:生图 JSON / 改图 multipart 两条请求的构造与发送、
│   │                           回包解析 / SSRF 关卡 / 附件与磁盘落地 / 上游模型列表
│   │                           (纯逻辑,不碰 ctx,可离线测)
│   ├── image-source.js         改图输入解析:会话最近一张 / 附件 ID / 工作目录路径(含越界与
│   │                           符号链接防护),mask 与警告
│   ├── routes.js               外部接口 + 鉴权 + /image 取字节 + /models 识别 + /edit
│   ├── save.js                 另存:会话图片恢复 / 路径限制 / 不覆盖写入
│   └── diag.js                 诊断日志
├── scripts/
│   ├── configure.mjs           配置读写 CLI(自包含 Cookie 铸造)
│   ├── install-into-profile.mjs 安装进 profile(幂等)
│   ├── mock-image-api.mjs      本地假服务(返回**真实编码**的 PNG;生图 + 改图两个端点)
│   ├── test-generate.mjs       协议:生图 JSON / 改图 multipart / 回包解析 / SSRF / 超时 / 模型列表
│   ├── test-image-source.mjs   原图解析:路径越界 / 符号链接 / 魔数判定 / 最近一张 / mask
│   ├── test-plugin.mjs         插件契约:命名空间 / volatile / secret / 确认闸门 /
│   │                           两个工具的形状 / 配置接口 / 落盘 / 改图与 /edit 路由
│   ├── test-client.mjs         浏览器半:ModuleLoader 加载 / 槽位 / 工具卡渲染(图片、下载、
│   │                           打开、错误态)/ 设置页 / 主题 / 不碰密钥
│   ├── test-save.mjs           另存:引用恢复 / 越界拒绝 / 不覆盖 / 扩展名校验
│   └── test-mock-api.mjs       用插件自己的解析器验证 mock 服务
├── README.md
└── LICENSE                     MIT

为什么 entry id 必须是 image-tool:DSH 的 settings 命名空间由 loader entry 的 id 推导,写成包名 dsh-image-tool 会让 settings.update({ns:'image-tool'}) 永远抛 No configurable plugin entry "image-tool",于是"在设置里填 API"这条路直接断掉。


8. 开发与测试

npm test                     # 五套全跑(不需要 DSH、不联网、不花钱)
npm run test:generate        # 协议:生图 JSON / 改图 multipart / 回包解析 / SSRF 关卡 / 超时
npm run test:image-source    # 改图原图解析:路径越界 / 符号链接 / 魔数 / 最近一张 / mask
npm run test:plugin          # 插件契约:命名空间名 / volatile / secret / render / 确认闸门 / 配置 / 落盘
npm run test:client          # 浏览器半:ModuleLoader 加载 / 槽位注册 / 工具卡渲染 / 不写死颜色 / 不碰密钥

# 端到端(用本地假服务,仍然不花钱)
npm run mock:api             # 另开一个终端:起假服务(默认 18577,返回真实合法 PNG)
npm run test:mock            # 用插件自己的解析器验证 mock 的输出,再走一遍完整链路

scripts/mock-image-api.mjs 返回的是真正编码出来的 PNG(按请求的 size 生成正确尺寸), 不是随机字节 —— 否则附件服务的魔数嗅探与解码校验根本不会被触发,验收结论也就不可信。 它同时实现 /images/generations(JSON)与 /images/edits(真的解析 multipart), 后者用另一套色板,于是"改图分支到底有没有生效"可以靠字节差异直接验出来。 提示词里含 __fail__ 会返回 401、含 __nolink__ 会返回空 data,用于验证错误路径。

诊断日志:$TMPDIR/dsh-image-tool.log(可用 DSH_IMAGE_TOOL_DIAG 覆盖;设为空串则关闭)。 DSH 本身没有独立日志目录,所以插件「启动失败」的原因只能靠它 —— 这正是 apply 里 做能力探测的动机。

关于 mock 的宽容度

测试里的 mock 必须如实抛错。项目早期因为这个吃过亏:mock 里 ctx.get('webServer') 返回 undefined 而不是抛错,于是"直接读未声明依赖"这个写法 一路溜进真实环境,把整个插件变成「启动失败」——而使用者只看到插件卡上那一个词。

只有能抓住 bug 的测试才算测试。验证修法有效的方式:临时把代码改回错误写法 → 测试应当 FAIL → 还原 → PASS。


9. 已知限制

  • 可选服务必须用 ctx.inject,不能直接 ctx.get('x') / ctx.x。 这是本项目踩过的最贵的坑:访问未在 inject 里声明的服务会抛 cannot get property "webServer" without inject,而这行如果在 apply 里, 整个插件的 entry 就直接变成「启动失败」—— 使用者只看到插件卡上那一个词, 看不到任何原因。只有 Web profile 才有 webServer,所以它天然是可选依赖:

    // ❌ 抛错:webServer 不在 inject 里
    const webServer = ctx.get('webServer') ?? ctx.webServer
    // ✅ 可选依赖的正确写法
    ctx.inject(['connection', 'webServer'], routeCtx => { const ws = routeCtx.get('webServer') })
    

    inject = [...](模块导出)是硬依赖:缺了插件就不激活。 ctx.inject([...], cb) 才是"可用时才执行"。另外 apply 里每一步都独立 try/catch (见 lib/index.js 的 step())—— 工具挂不上不该拖垮路由,反之亦然。

  • 改代码要重启 DSH;改配置不用。 这两条是真的不一样:

    • 配置走 settings.describe(),是每次调用现读的运行时值(applies: 'live'),写入即生效;
    • 代码是模块,Node 会缓存已加载的 ESM(浏览器半同理,由外壳的模块加载器持有)。 只改配置文件不会重新加载 lib/*.js 或 client.js。
    • 另外每次 volatile 更新都会重新执行 apply,所以诊断日志里会看到多次 apply: —— 这是平台的正常行为,不是重复装载(loader entry 只有一条)。 ⚠️ 正因如此,工具与路由的注册必须经 ctx.effect:同一层里重名注册会直接失败, 症状是插件卡显示「启动失败」,而且第一次是好的、动过配置之后才坏。
  • 浏览器半依赖外壳的槽位与主题契约(settings.section、tool.call.toolview、 --dsw-alias-*、宿主提供 react)。这些是平台内部约定,跨大版本升级时可能变; npm run test:client 会在加载层先把最典型的几种破裂挡下来。 全屏浮层还需要 react-dom 的 createPortal:拿不到时降级成就地 fixed 渲染 (功能可用,但浮层可能被祖先容器的 transform / overflow 裁切),不会让卡片崩掉 —— 卡片渲染抛错会被宿主永久弃用,代价远大于少一个浮层。

  • 确认通道依赖 userQuestions:没有它的 composition 里,开启确认会拒绝生图并说明原因 (fail-closed:宁可画不出来,也不要在"说了要问却没问"的情况下花钱)。

  • URL 过滤不是完整的 DNS 出站隔离:当前检查 URL 主机名与 IP 字面量, 不做 DNS 解析校验;不可信上游的域名解析仍需由部署环境的网络策略限制。

  • /image 引用索引不是持久存储:配置更新导致重新挂载或进程重启后, 旧 fetchUrl 会返回 404。长期保存请用 includeData=1。

  • 另存的是附件里的版本:图片附件可能经过平台规范化(缩放/重编码), 自动落盘保存的才是上游原图。


10. 许可证

MIT

  • 改图的原图只能来自会话或工作目录:不接受远程 URL(那等于让模型指挥服务端去请求 任意地址)。要改远程图,先把它下载进工作目录。 工作目录内的路径会做词法 + realpath 双重校验,符号链接/junction 指向盘外时会被拒绝。

  • 改图的 mask 只做软校验:OpenAI 约定 mask 必须是 PNG、与原图同尺寸、透明处为编辑区; 本插件在不是 PNG 时会给一句提示,但不会替你拦下 —— 不同中继的接受度差别很大, 硬拦反而挡住了能用的路径。另外原图与 mask 的尺寸一致性由上游判定, 这里不做像素级比对(那需要引入图像库)。

  • /edit 的 HTTP 调用必须自带 cwd:会话工作目录在 HTTP 路径上并不存在, 而"哪些路径允许读"必须有一个边界,插件不会拿进程当前目录去猜。

  • --set 的值里不能有前导 --:参数解析把 -- 开头当作下一个 flag。 值里真需要时用 --set key=--value 形式(带 = 的那种解析路径不受影响)。