hiocobueno653-pixel/dsh-image-gen ↗★ 0
dsh-image-gen
DSH 生图插件:generate_image 工具(提示词自动增强 → 出图 / 基于会话图片改图,聊天里只留 GridReveal 动画与图片)+ Turn 尾图片卡片
同名包的其他仓库
安装
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:hiocobueno653-pixel/dsh-image-gen说明文档
阅读完整 README ↗方式 A:插件配置
baseUrl: https://your-image-api.example.com # 你的图像 API 地址 model: your-model-name # 该服务上的模型名
方式 B:打开 设置 · 图像模型,点「+ 添加图像模型」填表(支持多家、可切换当前使用)。
密钥用范围:页面里直接填的 `apiKey` → 条目自己的 `apiKeyEnv` → 插件全局 `apiKeyEnv`;
环境变量名默认 `IMAGE_API_KEY`。
协议:默认走 `POST {baseUrl}/v1/images/generations`(`{ model, prompt, size, ratio, extra_body }`,
返回 `data[].url` 或 `data[].b64_json`)。如果你的服务是 OpenAI 兼容形状,把接口类型选成
「OpenAI 兼容」即可(改用像素 `size` 与顶层 `response_format`)。
## 用法
直接说,例如:
画一张:一只趴在窗台的橘猫,午后阳光 只生成图片:赛博朋克雨夜街道,霓虹倒影 给我配一张产品海报图,玻璃立方体在白色影棚里,16:9 把这张图改成日落(基于会话里最新一张图改)
不需要 `/img` 之类的命令(斜杠命令已移除)。尺寸档位与宽高比由模型从你的话里取,默认 `2K` + `1:1`。
### 改图 / 图生图
说「把这张图改成…」「换成…风格」时,模型会给 `generate_image` 带上 `reference_images`:
| 取值 | 含义 |
| --- | --- |
| `["latest"]` | 当前会话里**最新一条带图消息**中的图片(通常就是上一张生成或上传的图) |
| `[""]` | 会话里指定那张图(id 见工具结果/附件引用) |
限制:最多 4 张、单张 ≤ 8MB;只读当前会话里已存在的图片(不读工作区任意路径、不接受 URL)。
找不到图会**直接报错**,不会静默退回文生图 —— 你说的就是"改这张"。
参考图以 `data:` URI 放进请求(`extra_body.image` / OpenAI 兼容的 `image`),由服务端决定支持程度。
## 演示
- 演示视频(MP4,约 36 秒):[assets/demo-generate-image.mp4](https://github.com/hiocobueno653-pixel/dsh-image-gen/blob/2a47223821ba70f1f53a2eecedd66d77507a0346/assets/demo-generate-image.mp4)
- 内容:说一句话生成沙滩图片 → GridReveal 动画 → 显影出图。
## 生图不是消息:这一轮的 Turn 尾卡片
**图片不画在工具行里。** 它渲染在**该轮的 Turn 尾**——`conversation.chat.turnTail` 席位,
位置在收尾文案下方、过程组**之外**,跟着对话流走,但**永远不会被折叠收掉**。
用户的提问 (过程组:思考 / 别的工具 —— 该折就折,随便) 助手的收尾文案 ← 宿主渲染 ★★ 图片卡片(Turn 尾席位,本插件) ← 永不被折叠、刷新后按会话日志重建 已完成,用时 N 秒
### 为什么不能塞在工具行里(这是"图片有时看不见"的根因)
宿主 `ui-chat` 的 `ChatGroupSeat` 对工具调用有两层折叠:
```js
// ui-chat bundle
const grouped = policy.stepGrouping === "collapsed" ||
(policy.stepGrouping === "history" && turnLocation?.status !== "open");
const outerHidden = foldCompleted && presentation?.turnClosed === true && spec !== undefined &&
!alwaysOpen && stored?.answerStep !== (spec.answerStep ?? 0);
而 POLICIES 里默认档位(standard,桌面端默认)是 foldCompletedTurns: true +
stepGrouping: "collapsed"——两条都为真。于是回合一结束,整个过程组连同里面的图片
被 hidden 掉(useSearchableHidden(outerHidden))。图在组里 = 图迟早会消失。
靠 CSS 覆盖 content-visibility、或 JS 摘 hidden 都是在跟宿主的折叠对抗:
React 每次重渲染都可能补回来,而且过程组一多就分不清是谁的。
官方给的位置:Turn 级 Definition + turnTail 席位
这条路子是照 ui-schedule 的 schedule_create 卡片抄的(官方包自己就这么写):
// 1) Definition:只记数据、不产出视图节点;把每一轮的落地结果发布到 Turn 上
ctx.uiConversation.events.register({
kind: "image-gen-images",
match: (event) => /* turn/start | tool/call | tool/result → { id: turn, role } */,
start: (_ctx, match) => ({ turn, calls: new Map(), images: [] }),
update: (context, match) => /* 记住 tool/call 的 name/args;tool/result 里挑出 generate_image */,
buildLocationData: (context, scope) => scope === "turn"
? { kind: "turn", turn, key: "image-gen-images", value: { images } }
: null,
});
// 2) 席位:渲染在收尾文案下方、过程组之外
ctx.slots.register({ name: "conversation.chat.turnTail", id: "image-gen", order: 30 }, Card);
好处:不用对抗折叠(这个位置本来就不折);靠会话日志重放重建—— 刷新页面、换设备、隔天再打开,卡片都还在,不需要任何宿主侧状态。
三个席位怎么分工
| 席位 | 职责 | 产出 |
|---|---|---|
tool.call.toolview(key=generate_image) | 生成中的动画 + 状态上报 | 0 尺寸标记 [data-slot='image-gen-slot'](带 data-image-gen-state=running|done)+ GridReveal 方框 |
| 同一个组件(副作用) | 在宿主渲染的 tool-call 行上打 data-image-gen-row=running|done | CSS 靠它判断"这一组是不是只有生图" |
conversation.chat.turnTail(id=image-gen) | 图片本体 | .ig-turn > .ig-item > a.ig-frameLink > img.ig-image |
工具行与 Turn 尾在 React 树里互不相邻,所以工具行渲染时把 loadImage(durable 附件地址的取法)
按 callId 记进模块级 map,Turn 尾卡片优先用它、拿不到就退回同源路由
/image-gen/raw//。
聊天流侧的 CSS 规则(带守卫,只动生图那几块)
/* 标记本身不占位 */
[data-slot='image-gen-slot']{display:none!important}
/* 出图后:这一行整行收掉 */
[data-chat-flow-kind='tool-call']:has([data-image-gen-state='done']){display:none!important}
/* 只含生图的组:组头 + 整组一起收掉 */
[data-step-process]:has([data-image-gen-row]) > div:first-child{…}
[data-step-process]:has([data-image-gen-row='done']):not(:has([data-chat-flow-kind]:not([data-image-gen-row='done']))){display:none!important}
/* 只生图的那一轮:耗时行也收掉(混合轮保留) */
[data-chat-turn]:has([data-image-gen-row='done']):not(:has([data-chat-flow-kind='tool-call']:not([data-image-gen-row='done']))) [data-turn-process]{display:none!important}
/* 生成中:宿主把过程组折起来时,含 running 标记的那一段强制展开(动画在里面) */
[data-step-process-body][hidden='until-found']:has([data-image-gen-state='running']){content-visibility:visible!important;display:block!important}
三条底线:不藏整轮(用户的提问必须留着)、混合组只收生图那一行(组头和别的步骤照旧)、
别的组/别的轮零影响(test/scope.browser.test.mjs 逐条断言)。
⚠️ CSS 不允许
:has()套:has()(Chrome 会把整个选择器判非法,整条规则静默失效)。 所以"这一组是不是只有生图"不能用:not(:has(…:not(:has(…))))写, 才需要在行上打data-image-gen-row::not(:has(…:not([data-image-gen-row])))是合法的。
尺寸:动画框和成图是同一个盒子
用户要的是「动画的比例和大小跟图片一模一样」。做法只有一条:两边从同一个 boxStyle(aspect)
拿尺寸,而不是各写一遍 min(100%, 72vh × 比例)。
function boxStyle(aspect) { // ← 唯一来源
const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
return { width: `min(100%, calc(72vh * ${ratio}))`, aspectRatio: String(ratio) };
}
// 生成中:
// 出图后:
// .ig-image{width:100%;height:100%;object-fit:cover}
为什么必须这样(实测数据,不是推论):
| 写法 | 16:9 时量到的盒子 |
|---|---|
各写一遍:动画用 aspect-ratio:16/9,图片用自身像素比 | 动画 617.6×347.4(1.7778) / 图片 617.6×346.5(1.7826)→ 落地时跳 1px |
共用 boxStyle + 图片 object-fit:cover | 两边都是 640×360(1.7778),逐像素相同 |
原因:有些服务的「16:9」实际返回 1312×736 = 1.7826,不是数学上的 1.7778。只要让盒子决定比例、
图片只负责填满,就不会被图片的原始像素带偏。查过索引里 34 张图,请求比例与返回像素比的最大偏差
只有 0.27%,所以 cover 顶多裁掉不到 1px,不会真的裁掉画面(比 contain 留黑边好)。
test/box-ratio.browser.test.mjs 在真 Chromium 里把两种盒子并排量一遍,覆盖
1:1 / 16:9 / 4:3 / 9:16 / 2:3 / 21:9 / 真实服务像素比 七种情况,要求宽高都一致((只读、1.2s 一次、到 save即停), 不经ctx.remote.imageGen` —— 那需要写进 inject 名单,缺一项整个桌面端起不来(#11)。
图像模型(设置 · 图像模型)
独立一个导航页(settings.section,id=image-gen-models)。可以存多个图像模型,
点「使用」切换当前生图用哪个;绿点 = 当前使用中(不是"有密钥",两件事别挤一个视觉位)。
布局照官方「设置 · 模型」页的观感:一列卡片行(名称 + 自定义 标记 + 绿点 + 使用/编辑/删除),
底部虚线「+ 添加图像模型」,展开后同一个卡片里是表单,API 密钥直接在表单里填。
⚠️ 试过把它并进官方「模型」页(注册
settings.models.footer席位,宿主确实在 provider 行之后渲染它),用户反馈要恢复独立导航页。别再改回 footer 了。
- 代码:
src/imageModels.js(注册表)+src/imageProviders.js(多协议调度)+lib/client.js的ImageModelsSettings(页面)+src/cordis.js的modelsAdmin(动作分发) - 存储:
$DSH_HOME/cache/dsh-image-gen/models.json({ models, defaultId }) - 接口类型:
KINDS(允许存储)=standard/openai-images/custom;KIND_CHOICES(下拉能选)=openai-images/custom—— 内置那条固定用standard,不再供新选。 两者必须分开:standard是内置那条(由配置的 baseUrl/model 派生)的协议, 从KINDS里删掉会让存量行被normalizeModel静默改协议、立刻打不开图。 编辑存量行时kindOptions()会把它补回选项,否则 select 显示成第一项、 用户点保存就把协议悄悄改了。旧版本存下的旧协议名会被归一成standard(请求体相同)。 协议实现:standard(复用imageApi.js,ratio/extra_body 写法)、openai-images(/v1/images/generations,ratio 映射成像素 size,并随 1K/2K/3K/4K 档位放大)、custom(直接 POST baseUrl,响应形状data|images|output|results×url|b64_json|…都认) - 「从已添加的模型里提取」:读
ctx.llm.listProviders()+listModels(provider)填表, 只读不注册(见踩坑 #15) - 密钥优先级:页面里直接填的
apiKey→ 条目自己的apiKeyEnv→ 插件全局apiKeyEnv。 明文只落本机那个 JSON 文件(与插件早已使用的$DSH_HOME/.env同一信任域), 对外一律走publicModel():剥掉apiKey,换成hasKey+keyHint(掩码)。 设置页标「未配置密钥」看的是宿主算的keyReady(三个来源任一解析得到即可用), 不是hasKey—— 只看它会把"密钥配在环境变量里"的模型误报成没密钥。 编辑时密钥框留空 = 保持原值不变。内置行(default)也可编辑/换 key, 改它会落一条builtin:true的覆盖记录,由listRaw()与配置派生值合并。
错误处理与密钥安全
- 密钥脱敏:上游与中转站经常把
Authorization头原样回显在错误体里。所有会进对话或日志的错误文本, 抛出前统一过redactSecrets():已知密钥逐字替换,并兜底打码sk-…/AIza…/Bearer …/api_key=…这类形状。 - 超时与取消分开:生成由插件自己的看门狗计时。超时抛「生图超时…上游可能已扣费或仍在生成」,
用户主动取消单独归为
ABORTED,不会混成一句“网络失败”。 - 传输诊断:
fetch failed会展开error.cause链并带上阶段与耗时,例如图像 API 请求 failed after 3.2s: TypeError: fetch failed /generated-images/-.png(同目录临时文件 +rename原子落位; 同一秒撞名自动加序号,绝不覆盖已有图) - 附件:
ctx.attachments.saveImage()(内容寻址,随会话持久化;工具结果里以 image 块返回,宿主画廊据此加载) - 结果索引:
$DSH_HOME/cache/dsh-image-gen/index.json(callId → 提示词/优化结果/附件/文件,保留最近 300 条) - 图片字节路由(画廊之外的兜底):
GET /image-gen/raw//(宿主webServer注册,同源、仅本机)
验证
npm test # 8 个离线测试:栅栏 / 注册表 / 图像客户端 / 脱敏与诊断 / 提示词增强 / 宿主管理 / 宿主全链路 / 客户端结构
npm run test:browser # 4 个真 Chromium 测试(需要 `npm i` 拉 playwright,或用 CHROME_PATH 指定本机 Chrome)
离线测试不联网、不需要密钥、不写用户目录(DSH_HOME 与工作目录都是临时目录,
生图请求用假 fetch 桩);真机验证另外跑 test/turn-card.live.mjs(参数与隔离实例做法写在那个文件的头注释里)。
实现要点(踩过的坑)
ctx.get('webServer'/'tools'/'sessions'…)在apply当下是undefined—— 服务尚未注册。 必须用ctx.inject(deps, child => …),依赖齐了才回调,子上下文里才有child.tools等; 运行时服务(sessions / attachments / llm / agentDefaultModel)同样在回调里回填给服务实例。webServer的kind:'prefix'是字符串前缀匹配:注册路径写成/image-gen/(带尾斜杠)时/image-gen/raw/…会落到 SPA 兜底 404。路径不能带尾斜杠。- 工具卡三阶段:
tool.call.toolview的 owner props 里phase=preparing|start|result;preparing阶段连参数都还没派发,但因为席位按工具名派发,动画可以在模型刚开始吐参数时就出现。 - 图片自己画在 canvas 里:不再声明
tool.call.images子席位(那是宿主图片画廊的位置), 避免同一张图出现两次——GridReveal 的 canvas 本身就是最终展示面,旁边只给一个「打开原图」链接。 - canvas 取色要求同源:
readAverages()会把图片画进 128×128 的离屏 canvas 再getImageData; 跨域图片会污染 canvas。所以优先用宿主loadImage()给的 durable 地址(同源), 退回插件自己的/image-gen/raw/…路由;两者都拿不到时组件按设计退回灰格 + 照片淡入。 - 宿主半改动不会被热重载拾取:Node 的 ESM 缓存让 disable→enable 仍加载旧代码,
改宿主半(工具描述、注册项)后需要重启一次 DSH;只改
lib/client.js则刷新页面即可。 - 客户端 bundle 只认
clientModules.rebuilt(id)。宿主把「bundle 内容变更」的唯一入口 收敛到这个方法(@deepseek-ai/dsh-client-hmr的轮询监视器比较 mtime/ctime/size 后调用它), 所以改完lib/client.js光刷新页面不一定够——要让监视器观察到 mtime 变化 (Copy-Item会改 mtime,通常够;必要时touch一下),必要时重启 DSH。 /plugins路由需要浏览器会话 cookie(isAuthenticated校验 HMAC 签名 + authority), 直接用脚本 fetch 会拿到 401;要验证线上实际加载的字节,得带上dsh web打印的 URL 里的凭据。- ⚠️ 不要手动改名
node_modules\dsh-image-gen来「禁用」插件。本插件曾因此让整个宿主 启动失败(web boot: 1 entry did not activate / dsh-image-gen: failed,弹「应用无法启动」)。 机制:条目一旦加载失败,宿主会把它从dsh.profile.bundles移除,之后即使把目录名改回来 也不会自动恢复(与plugin-heatmap记录的 usage-stats 是同一个坑)。 诊断:plugin_manager list_plugins里dsh-image-gen会从列表消失,set_plugin回unknown-plugin; 崩溃日志在桌面端日志目录的crash--web-boot.log。 修复:把"dsh-image-gen"加回 profilepackage.json的dsh.profile.bundles再重启 DSH。 要禁用请用cordis.patch.yml里的disabled: true,别动目录名。 conversation.input.dock是 ship 出来的既有席位,order已被占:todo=0/goal=10/queue=20。本插件用order: 30;抢位会挤掉既有 UI (plugin_manager的listSubTree可以查到 occupants)。- ⚠️
ctx.slots.register必须包在ctx.slots.inject(席位名, …)里调用。 裸 register 在真实的宿主 ctx 上会抛异常(隔离 profile 复现:web boot: 1 entry did not activate / dsh-image-gen: failed), 而桌面端把任何条目激活失败当致命错误 → 整个应用弹「应用无法启动」打不开。 这曾是 2026-09-30 桌面端打不开的根因。宿主自己的示例代码全部用ctx.slots.inject('tool.call.images', () => ctx.slots.register({...}, …))的形态, 照抄这个形态就不会踩。test/client.test.mjs的假 ctx 已模拟这个语义(裸 register 直接 throw)作回归守卫。 诊断手段:用dsh --profile --no-open起隔离 profile(DSH_HOME指临时目录), 再用真 Chrome + HMAC cookie 进页面看 console —— 桌面弹窗只显示failed,浏览器 console 才有上下文。 - ⚠️ CSS 不允许
:has()套:has()。:not(:has(X:not(:has(Y))))会被