hiocobueno653-pixel/dsh-image-gen ↗★ 0

dsh-image-gen

DSH 生图插件:generate_image 工具(提示词自动增强 → 出图 / 基于会话图片改图,聊天里只留 GridReveal 动画与图片)+ Turn 尾图片卡片

패키지
dsh-image-gen
호환성
미검증
버전
0.2.0
라이선스
MIT
최근 업데이트
2026. 11. 1.

같은 패키지 이름의 다른 저장소

설치

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:hiocobueno653-pixel/dsh-image-gen

方式 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|doneCSS 靠它判断"这一组是不是只有生图"
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(参数与隔离实例做法写在那个文件的头注释里)。

实现要点(踩过的坑)

  1. ctx.get('webServer'/'tools'/'sessions'…) 在 apply 当下是 undefined —— 服务尚未注册。 必须用 ctx.inject(deps, child => …),依赖齐了才回调,子上下文里才有 child.tools 等; 运行时服务(sessions / attachments / llm / agentDefaultModel)同样在回调里回填给服务实例。
  2. webServer 的 kind:'prefix' 是字符串前缀匹配:注册路径写成 /image-gen/(带尾斜杠)时 /image-gen/raw/… 会落到 SPA 兜底 404。路径不能带尾斜杠。
  3. 工具卡三阶段:tool.call.toolview 的 owner props 里 phase = preparing | start | result; preparing 阶段连参数都还没派发,但因为席位按工具名派发,动画可以在模型刚开始吐参数时就出现。
  4. 图片自己画在 canvas 里:不再声明 tool.call.images 子席位(那是宿主图片画廊的位置), 避免同一张图出现两次——GridReveal 的 canvas 本身就是最终展示面,旁边只给一个「打开原图」链接。
  5. canvas 取色要求同源:readAverages() 会把图片画进 128×128 的离屏 canvas 再 getImageData; 跨域图片会污染 canvas。所以优先用宿主 loadImage() 给的 durable 地址(同源), 退回插件自己的 /image-gen/raw/… 路由;两者都拿不到时组件按设计退回灰格 + 照片淡入。
  6. 宿主半改动不会被热重载拾取:Node 的 ESM 缓存让 disable→enable 仍加载旧代码, 改宿主半(工具描述、注册项)后需要重启一次 DSH;只改 lib/client.js 则刷新页面即可。
  7. 客户端 bundle 只认 clientModules.rebuilt(id)。宿主把「bundle 内容变更」的唯一入口 收敛到这个方法(@deepseek-ai/dsh-client-hmr 的轮询监视器比较 mtime/ctime/size 后调用它), 所以改完 lib/client.js 光刷新页面不一定够——要让监视器观察到 mtime 变化 (Copy-Item 会改 mtime,通常够;必要时 touch 一下),必要时重启 DSH。
  8. /plugins 路由需要浏览器会话 cookie(isAuthenticated 校验 HMAC 签名 + authority), 直接用脚本 fetch 会拿到 401;要验证线上实际加载的字节,得带上 dsh web 打印的 URL 里的凭据。
  9. ⚠️ 不要手动改名 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" 加回 profile package.json 的 dsh.profile.bundles 再重启 DSH。 要禁用请用 cordis.patch.yml 里的 disabled: true,别动目录名。
  10. conversation.input.dock 是 ship 出来的既有席位,order 已被占: todo=0 / goal=10 / queue=20。本插件用 order: 30;抢位会挤掉既有 UI (plugin_manager 的 listSubTree 可以查到 occupants)。
  11. ⚠️ 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 才有上下文。
  12. ⚠️ CSS 不允许 :has() 套 :has()。 :not(:has(X:not(:has(Y)))) 会被