dsh-image-generation

一个 DeepSeek Harness 插件包,做两件事:
- 给 Agent 注入一个
generate_image工具:给提示词就能出图,并落盘到本机。 - 在「设置」里加一页「图片生成」:模型、接口地址、密钥、输出格式等都在这里配。
请求走的是 OpenAI 兼容的 POST {apiUrl}/images/generations,参数名与 Dasipaint 的 openai.rs::build_request 一致(model / prompt / n / size / quality / output_format / background),所以官方 OpenAI、各种中转网关、自建反向代理都能用。
这个工具不挑模型类型:端点背后是自然语言驱动的自回归绘画模型时,提示词会原样透传,不做任何 GPT-image 风格的模板包装;端点背后是别的图片模型时,也照发不误。真正只有 OpenAI 图片接口才认的字段(moderation)默认就不发,还给了开关,见「设置项」。
目录
安装
dsh plugin add 会把包装进 profile,并对照包里的 dsh.bundle.patch 把它追加到 profile 的 bundle 栈上,不需要手改任何配置文件。
dsh plugin --profile web add dsh-image-gen-plugin
Harness 里也可以让 Agent 执行 plugin_manager 的 install_bundle,target 填包名 dsh-image-gen-plugin。
或者从源码安装 —— 这个目录本身就是完整的包,不需要构建,把上面的包名换成本目录的绝对路径即可:
dsh plugin --profile web add "D:\MyProject\deepseek-image-generation\dsh-image-gen-plugin"
装完刷新一下页面(http://127.0.0.1:3080)。升级:改完源码后重新跑一次 add 会覆盖安装;Host 侧的 JavaScript 需要重启 DSH 才会加载新的一代。
卸载:
dsh plugin --profile web remove dsh-image-gen-plugin
使用
- 设置 → 图片生成:填 API 地址、API 密钥,选模型。
- 新会话里对 Agent 说「画一只戴帽子的柯基」,它会调用
generate_image,图片落在输出目录里,聊天卡片上直接显示。 - 图生图就直接把源图路径给它,说清楚要改什么。
工具参数
generate_image 暴露 8 个参数:
| 参数 | 必填 | 说明 |
|---|---|---|
prompt |
是 | 提示词,用自然语言写。要求图里出现文字时,把文字原样引号写清楚,并说明位置和排法 |
resolution |
否 | 自动比例(auto,默认)或自定义分辨率。自定义写 宽x高,如 1536x864 |
quality |
否 | 画质:low / medium / high / xhigh / max,对应 低画质 / 标准画质 / 高画质 / 超高画质 / 顶级画质 |
count |
否 | 本次生成几张,1–10;不传就用设置里的默认值 |
background |
否 | auto / transparent / opaque |
location |
否 | 存储位置。不传就是会话工作区;相对路径相对工作区解析,绝对路径原样使用,目录不存在会自动创建 |
images |
否 | 图生图:源图路径数组。最多 16 张,每张 ≤50MB |
mask |
否 | 可选的 PNG 蒙版,标注要改的区域,必须恰好配 1 张源图。透明像素是允许重绘的区域,不透明像素保留 |
后几个不传时都取设置页里的默认值;画质不在设置里,它是纯调用参数,省略时按 high 出图。
没有 mode 参数:「生成」还是「编辑」由 images 有没有值决定——这一个开关就够了,多一个参数只会多一种自相矛盾的可能(声明 edit 却不给源图、声明 generate 却给了源图)。传 images 就是编辑,不传就是生成。
图生图
传 images 就把调用从「生成」变成「编辑」:
无 images(文生图) |
有 images(图生图) |
|
|---|---|---|
| 端点 | POST /images/generations |
POST /images/edits |
| 编码 | application/json |
multipart/form-data |
| 源图 | — | image 字段,可多张(API 硬上限 16 张) |
| 蒙版 | — | mask 字段,恰好 1 张 PNG,且只配 1 张源图 |
提示词写法要跟着换:文生图描述整幅画面,图生图描述改动("把背景换成纯色影棚墙"、"把毛色改成姜黄"、"加一条红围巾")。
两条链路都有回归测试:断言传源图时确实打到 /images/edits 且是 multipart,以及纯文本调用仍然走 /images/generations 的 JSON(防止我把所有调用都塞进编辑分支)。
multipart 是手写编码,不是 FormData:boundary、CRLF、每个 part 的头部都由本插件控制。网关对 multipart 的挑剔远超 JSON,用 FormData 只能拿到一句「400 Bad Request」,手写能让失败指到具体位置。断言逐字节检查了开头的 boundary、结尾的 --boundary--、每个 part 的 Content-Disposition、image part 的 filename 与 Content-Type、数字字段以文本传输、以及源图字节原样嵌入。
自定义分辨率的规则
尺寸校验照抄 Dasipaint 的 openai.rs::validate_size 与 OpenAI 图片接口的文档,按同样的顺序校验:
- 宽和高都必须是 16 的整数倍;
- 长宽比不超过 3:1(即 1:3–3:1);
- 最长边不超过 3840;
- 总像素在 655,360 – 8,294,400 之间。
第 4 条是关键:3840×3840 反而超限(14,745,600 像素),能被接受的尺寸里最大的正方形是 2880×2880;3840×2160 是合法且可用的。批量出图时注意这一点。
工具描述里附了一份全都合法的现成尺寸,覆盖竖版(知识卡、海报)、横版(导图、幻灯片)和超宽(时间轴、横幅)三种版式,例如 1024x768 / 768x1024 / 2048x1536 / 3840x2160 / 2160x3840 / 3840x1920 / 3696x1584 / 2048x1024。
画质档位
quality 五档,从低到高:low(低画质)、medium(标准画质)、high(高画质)、xhigh(超高画质)、max(顶级画质)。这五档沿用 Dasipaint genspec.ts::GEN_QUALITIES 的划分,直接映射到请求体的 quality 字段。
文件名模板
设置页里可配 fileNameFormat,四个占位符混搭随意:
| 占位符 | 渲染成 | 用途 |
|---|---|---|
{time} |
2026-10-07T08-14-43-188 |
时间排序 |
{prompt} |
a-cat-in-a-hat(slug,最长 60 字符) |
一眼看出内容 |
{index} |
1、2… |
区分数张 |
{uniq} |
4a9ea17a |
保证不重名 |
默认 {time}-{prompt}。同名不会覆盖:写文件用排他创建(wx 标志),撞名自动加 -2、-3 后缀——所以像 {prompt} 这种不含身份信息的模板也安全,只是可排序性差一点。
怎么写提示词
这个工具把提示词原样交给端点背后的模型,不做任何模板化包装。这个模型的自然语言控制力强、会渲染文字、会排版、审美在线,知识导图 / 知识卡 / 海报 / 信息图 / 幻灯片这类「有版式、有信息密度」的活正是它的主场。所以:
- 不要堆风格形容词、权重语法(
(word:1.2)、::)、质量咒语(masterpiece, 8K, ultra detailed)、参数旗标(--ar 16:9、steps: 30)。它自己的审美够用,这些只会挤掉真正的内容。 - 要说清楚「这是什么、要说什么内容」:格式、分栏/分区/层级数量,每块放什么;图里必须出现的文字用引号原样给出,并说明位置和层级。
- 视觉语言和配色只在调用方明确要求时才指定,否则交给模型决定。
- 画场景、出照片同样可以,用描述场景的普通句子说就行。
- 图生图描述「改什么」,不要重新描述整幅画面。
想按场景切分得很细(构图/镜头/光线/材质分开写)也完全可以,但那是可选项,不是本插件的格式要求。
设置项
设置页里的字段直接对应插件的 Config(存在 profile 的 cordis.patch.yml 里):
| 字段 | 默认值 | 说明 |
|---|---|---|
apiUrl |
https://api.openai.com/v1 |
接口基地址,会自动拼 /images/generations |
secret |
空 | Bearer 令牌,留空表示不改动。字段名与存法见「安全」 |
model |
gpt-image-2.5-flare |
模型 id,任意端点支持的都行,输入框可以直接改成你自己的模型名 |
count |
1 |
默认张数 |
outputFormat |
png |
png/jpeg/webp;透明背景不能用 jpeg |
outputCompression |
100 |
只对 jpeg / webp 生效 |
background |
auto |
默认背景 |
moderation |
auto |
只有你显式选了 low 才会发这个字段(auto 是双方的默认值,不发) |
user |
空 | 可选终端用户标识 |
defaultResolution |
auto |
调用没写分辨率时用的尺寸。auto 让模型自己决定比例 |
maxEdge |
3840 |
自定义分辨率的最长边上限 |
outputDir |
空 | 图片落盘目录;留空用插件包内的 assets/ |
fileNameFormat |
{time}-{prompt} |
文件名模板,四个占位符见上 |
requestTimeoutSec |
600 |
单次请求超时 |
sendsModeration |
true |
关掉后永远不发 moderation,给不认这个字段的模型用 |
sendsOutputCompression |
true |
关掉后永远不发 output_compression |
设置页里填自定义尺寸时会即时校验,违反上面四条规则会直接标红提示;defaultResolution 填了不合法的尺寸时,调用会在发请求前失败并给出原因,不会静默退回默认值;maxEdge 改小后 generate_image 的工具描述和可用档位会跟着变。画质那一栏已经不是输入框了,那是一段说明文字,告诉你它改成了调用参数。
工具卡片
卡片读的是结果的 presentationMeta(文件名和访问 URL),模型侧只看到纯文本的绝对路径列表。
- 标题 + 张数;图片放在固定 260×260 且裁切的框里(图取不到时不会让 alt 文本把卡片撑变形)。
- 生成中:直接显示提示词的前 5 行(最后可见字符换成省略号)。不用点任何按钮。
- 出图后:提示词收起,换成两个展开按钮。
- 展开提示词 —— 这次调用实际用的完整提示词(全文,不是节段),仅限高 260px 可滚动,正文一字不裁。
- 参数 —— 只列这次真正传了的参数(尺寸、画质、张数、背景、位置、写盘文件数)。没传的不显示、不编造;尺寸省略时显示
auto。
- 运行时的参数还在流式写入、是残缺 JSON,所以宿主通过
inject.hooks.toolCallArgumentsPartial注入读取流式前缀的 hook,客户端用容错解析只捞字符串字段(闭合引号可选,这正是「边生成边显示」的关键)。hook 缺失或抛错时退回block.argsRaw。 - 展开状态由 DSH 的
useDisclosure托管(所属 Turn 重置时自动收起);该 hook 缺失、抛错或返回畸形对象时自动退回本地状态,按钮照常可用。空提示词则不显示提示词区块。 - 卡片不会因为畸形输入而消失:卡片读的是本插件不拥有的日志,所以每处读取都是全函数(total)——
props/block不是对象就当归空、meta.images不是数组就当归没有、url不是非空字符串就不渲染(包括访问器抛异常的条目)、argsRaw不是字符串就不解析、两个 hook 缺失/抛错/返回 Proxy 都退回本地状态。最外层还有一个 class 错误边界兜底:真抛错时渲染一张说明失败原因的最小卡片,而不是留下一行空白。渲染的鲁棒性有一整套畸形输入断言网兜着。
自检
cd D:\MyProject\deepseek-image-generation\dsh-image-gen-plugin
# 依赖已随插件安装装好时
npm run check
# 依赖还没装时,指向本机 DSH 安装里的 @deepseek-ai 目录
$env:DSH_PACKAGE_ROOT = "C:\Users\<你>\AppData\Local\nvm\v22.22.1\node_modules\@deepseek-ai\dsh\node_modules\@deepseek-ai"
npm run check
# 想看模型实际读到的工具描述(改完提示词先跑这个)
node show-prompt.mjs
四个套件:
checks/host.mjs:Config 默认值、尺寸四条规则、请求体组装、multipart 逐字节结构、工具注册形状与八个参数、凭证字段命名、文件名消毒、源图校验。checks/client.mjs:设置页渲染与保存(含密钥不被回写)、尺寸即时校验与主机同判定、提示词在三个 phase 全生命周期的可读性、翻译函数的签名契约、错误边界、中英文切换。checks/live.mjs:用假的 fetch 跑完整调用链——落盘、路由取图、错误信息、取消、重试、images路由、输出 schema 校验。checks/pressure.mjs:极端压力套件。上千条合成畸形输入打向尺寸规则、base64 解码、文件名模板、源图规范化、multipart 编码、生成入口,任何引擎级异常(TypeError / ReferenceError / Cannot read / is not iterable)都算失败(「用文字拒绝」是正确结果)。这套件把手写 fetch 桩设为先记录再抛错,所以结构上不可能产生 API 费用,并会把「出网尝试次数」报出来。
文件
dsh-image-gen-plugin/
├── package.json 包清单:dsh.bundle.patch + dsh.client
├── cordis.patch.yml 插入插件行,带一份默认 config
├── index.js Host 侧:Config schema + generate_image 工具 + 图片路由
├── client.js 浏览器侧:设置页 + 工具卡片(原生 JS,无构建)
├── icon.svg 插件管理页的图标
├── show-prompt.mjs 打印模型实际读到的工具定义全文(不是测试,是查看器)
├── LICENSE MIT
├── assets/ 插件自己的资源目录
│ ├── dsh-image-gen-plugin-logo.png 项目 logo
│ ├── served-index.json 已写出文件的索引(自动维护)
│ └── <生成的图>.png outputDir 留空时的落盘位置
└── checks/ 自检脚本,与插件运行无关
├── all.mjs 依次跑下面四套
├── host.mjs Host 契约
├── client.mjs 浏览器契约
├── live.mjs 完整调用链(假 fetch)
└── pressure.mjs 极端压力套件(零出网)
安全
- 密钥的字段名与存法:配置字段名叫
secret(故意不叫apiKey,避免扫盘一眼看出价值),并标记为role('secret'),设置接口只回传「是否已配置」,从不回传值。但要说清楚:它是明文存在 profile 配置里的,DSH 的凭证库同样是明文($DSH_HOME/.credentials.yaml,靠文件权限保护,Windows 上连权限检查都跳过)。别把 profile 目录连带密码一起交出去。 - 文件名会被消毒:Windows 禁止的字符
\ / : * ? " < > |换成-;CON/PRN/AUX/NUL/COM1-9/LPT1-9这些保留设备名(Windows 会把它们解析到设备命名空间,写盘会进控制台而不是文件)自动加-image后缀;名字截到 120 字符,给目录、扩展名和冲突后缀留出 Windows 的 260 字符路径预算。 - 文件名不会注入 multipart 请求头:文件名写在带引号的 header 行里,只转义
"是不够的——CRLF 能提前结束该行并伪造出一整个 part。现在控制字节、"、\、/全部替换为_,并且**先取 basename 再剥前导点**(顺序反了的话,..在被归一化后会留下一个_开头的名字)。字段名也按 RFC token 校验,拒绝任何可能伪造 header 的名字。 - 图生图会先校验源图再发请求:路径不存在、不是文件、零字节、超过 50MB、超过 16 张,都在任何字节离开本机之前报错并指名是哪个文件。源图必须真的解码成图片(PNG/JPEG/WebP/GIF 魔数嗅探)——伪装成
.png的文本文件会被本地拒绝,而不是带着image/png发给网关换回一句看不懂的错。符号链接会被跟随(stat),指向目录的链接仍被拒。
行为说明
请求与响应
- 每张图都会先验证再落盘:base64 必须合法、必须解码成 PNG/JPEG/WebP/GIF 之一(按魔数嗅探)、不能是零字节、不能超过 50MB。解不出图片时拒绝写入并打印开头字节,不会留下一个打开后什么都没有的文件。格式以字节实际是什么为准,所以网关被要求 png 却回 jpeg 时不会错标。
- 瞬时故障会自动重试:408/409/425/429/500/502/503/504/522/524 和连接层故障最多重试 3 次,优先采用
Retry-After;429 这类限流不会白等。4xx(除 429)不重试——那是调用方自己的错,重试只会烧同样的失败。调用方取消的请求立即终止,不会偷偷重发。 - 工具不发送
stream;partial_images因此也不会出现。
文件
- 写文件是排他创建,不是「先检查再写」,所以两次生成同时完成也不会互相覆盖(消毒规则见「安全」)。
- 图片通过 Host 的
/dsh-image-generation/file/<文件名>路由展示,到location指定的任意目录也能取到。路由查表的顺序是「本进程写过的文件表 → 目录扫描 → 落盘索引」:扫描是兜底的那一环(它直接问文件系统,所以无论图是谁写的、有没有被记录过都找得到),前两者只是让重复请求变便宜。同名优先最新的,因为 URL 只带文件名,无法区分不同目录的同名文件;越界的..读取被拒,畸形的百分号转义返回 400 而不是抛异常(decodeURIComponent遇%会抛,未捕获会让请求没有响应)。 - 卸载后已经生成的图不会被删,会作为孤儿留在原目录里。
关于源码里的两处约定
改这个插件的代码之前值得先看这两段,都是踩过坑之后写下来的。
为什么依赖必须写成 peerDependencies
@deepseek-ai/dsh-tools 和 @deepseek-ai/schemastery 只能放在 peerDependencies,不能放到 dependencies。
从源码安装时,dsh plugin add <目录> 是把本包符号链接进 profile 的:
<profile>/node_modules/dsh-image-gen-plugin -> D:\MyProject\...\dsh-image-gen-plugin
所以运行时这个包的真实路径仍在它被克隆的盘上(这里是 D 盘)。Node 解析裸包名时是沿真实路径向上找 node_modules,于是会走到 D:\node_modules 然后失败——profile 里装没装都不影响它找不到。DSH 只对插件自己声明为 peer 的包,才把裸包名路由到宿主已经加载的那一份(dsh-app-boot 的 routeLinked + readPeerNames)。从 npm 装进来的是普通依赖,路径就落在 profile 的 node_modules 里,不存在这个问题——但两种渠道必须共用同一份 package.json,所以 peer 声明照旧。
写回 dependencies 的症状是:包安装成功、profile 里也看得见,但激活时 import '@deepseek-ai/dsh-tools' 抛 ERR_MODULE_NOT_FOUND,工具不会注册。
改完清单要重跑一次 add,让 profile 的依赖图重新计算:
dsh plugin --profile web add "D:\MyProject\deepseek-image-generation\dsh-image-gen-plugin"
客户端插件的服务注入规则
客户端半边声明的 inject 是 Cordis 服务名,宿主按名字把服务挂到 ctx 上:
inject: ['slots', 'locale', 'remote', 'remote.settings']
// 声明之后 ctx.remote.settings 才有值
两个已经踩过的坑,都写进了 checks/client.mjs 的断言,别再踩:
ctx.get('remote')?.settings拿不到东西。ctx.get('remote')返回的是remote服务对象本身,设置控制器是挂在它上面的命名空间。正确做法是把remote.settings写进inject,然后直接读ctx.remote.settings。漏声明时ctx.remote直接是undefined,症状是设置页永远停在「正在读取设置...」。- 远程调用返回
{ ok, value }信封,不直接返回结果、也不抛异常。 失败是{ ok: false, error: { code, message } }。所以describe()要读response.value.namespaces,mutate()要读response.value(新的 namespace view)。照着普通 Promise 写,报错信息会全部丢失。
翻译不要自己嗅探 document.lang(页面设置该属性的时机晚于插件注册,标签会一直是英文),改用 locale 服务:
const localeService = ctx.locale;
const t = (key) => (localeService.getSnapshot().active.startsWith('zh') ? ZH : EN)[key];
ctx.effect(() => localeService.register('dsh-image-generation', { zh: ZH, en: EN }));
许可与来源
本插件由 DeepSeek Harness 制作。
许可证:MIT(全文见 LICENSE) 作者:MELLER_OwO
No comments yet. Be the first to write one.