ysr666/dsh-vision-router
ysr666/dsh-vision-router为纯文本 Agent 提供视觉能力:内置免 Key 视觉链 + 像素级视觉工具(看图问答、定位、裁剪、像素对比、取色、OCR、矢量化、抠图、截图);粘贴图片即可用。
带提示词自动增强、可切换多个 API 配置,并在 Turn 尾展示不被折叠的图片卡片的生图工具。需自行配置图像 API 地址与密钥,插件不内置。
把「生图」接进 DSH:一个可换服务的图像 API 客户端 + 一条不说话的生图流水线。 直接说话(不需要任何命令)→ 工具卡先出 GridReveal 动画 → 图好了动画消失换成图片。 工具内部自动优化提示词,所以模型不需要替用户润色,也就不会在对话里写一堆文字。
⚠️ 本仓库不含任何服务商信息、端点与密钥。cordis.patch.yml里的baseUrl/model
故意留空,装好后必须自己填(设置 · 图像模型,或直接改插件配置)。密钥只从环境变量 /
$DSH_HOME/.env读,或填在设置页里(存本机models.json,不随仓库分发)。
# ~/.dsh/profiles/<profile>/package.json 的 dsh.profile.bundles 里加一行
"dsh-image-gen"# 本地开发:把插件链进 profile
dsh plugin add "file:/path/to/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。
图片不画在工具行里。 它渲染在该轮的 Turn 尾——conversation.chat.turnTail 席位, 位置在收尾文案下方、过程组之外,跟着对话流走,但永远不会被折叠收掉。
用户的提问
(过程组:思考 / 别的工具 —— 该折就折,随便)
助手的收尾文案 ← 宿主渲染
★★ 图片卡片(Turn 尾席位,本插件) ← 永不被折叠、刷新后按会话日志重建
已完成,用时 N 秒宿主 ui-chat 的 ChatGroupSeat 对工具调用有两层折叠:
// 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 每次重渲染都可能补回来,而且过程组一多就分不清是谁的。
这条路子是照 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//。
/* 标记本身不占位 */
[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) };
}
// 生成中:<div class="ig-frame" style="…boxStyle…"><canvas/></div>
// 出图后:<a class="ig-frameLink" style="…boxStyle…"><img class="ig-image"/></a>
// .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 / 真实服务像素比 七种情况,要求宽高都一致(<0.5px)。
动画是 rareui.com/components/gridreveal 的 1:1 移植(源码逐函数搬过来,见下节)。
阶段 · 界面 preparing(模型刚开始吐工具参数) · 图区立刻出现一个圆角方框,框里就是 GridReveal:canvas 上 1 个根格二分分裂成 180 格,按自走进度依次裂开、缝隙先开(带圆角)后合、格子保持灰度微闪。运行期一个文字都没有 start(已派发、正在跑) · 与上面完全一致。图片没到之前进度封顶在 WAIT_CAP=0.72,永远不会"跑完" result · 图片一落地就以"图片到达"收尾:从图里采样各格平均色开始着色 → 缝隙合拢 → PHOTO_FROM=0.93 起照片淡入,动画在同一框里结束。整个框就是「打开原图」链接,框下没有任何文字 失败 · 红色 role=alert 文案(HTTP 状态 + 原因)
无障碍说明:「零文字」只是视觉上的——运行期方框仍带 aria-busy="true" 与 aria-label="正在生成图片", 读屏用户不会被静默。
模型拿到工具结果后习惯写"已生成…画面/规格/文件",所以 generate_image 在 execute 一开始就调用 exec.concludeTurn?.()——工具原生能力,这一轮在结果落地后直接结束,不会再有跟进模型调用。 工具描述里也补了一句同样的要求作为兜底。
lib/client.js 上半部分)原组件用 motion/react + Tailwind + @/lib/utils;插件客户端半边没有构建链、模块种子表里也只有 react, 所以做了三处等价替换,动画数学一行未改:
原实现 · 这里 useReducedMotion()(motion) · matchMedia('(prefers-reduced-motion: reduce)') 自挂钩子;reduce 时直接落到定格帧 document.documentElement.classList.contains('dark') · 读 DSH 的 --dsw-alias-bg-base 亮度(500ms 缓存),退回系统偏好 caption 的 motion.span + SHIMMER + backgroundPosition 关键帧 · 同一个渐变 + @keyframes ig-shimmer(1.6s 扫过 + 0.5s 停顿 = 2.1s 循环);插件里不传 caption(运行期零文字),组件本身仍保留这项能力
保留的原常量:CELLS=180、OPENING_CELLS=4、HOLD=0.9、WAIT_CAP=0.72、LAST_SPLIT=0.92、 MORPH=0.055、SAMPLE=128、GUTTER_FROM=0.35、GUTTER_TO=0.75。
rareui 只解决"格子怎么裂开",出图那一下是硬淡入。bencho 的 generate 块 的做法是先定大的、后定细的:图一直在玻璃下,同一个时钟开三个窗口 (shapes=smooth(0.1,0.82,t) 驱动 blur 20→1.6px / contrast 2.4→1,grain=smooth(0.7,1,t) 最后才定, 液体扰动 ∝ (1-t)^1.5,scale 1.04→1)。移植到 canvas:
层 · 内容 · 驱动 底 · 照片在离屏画布里显影:filter: blur() contrast() + 轻微收拢,再按 26 条切片做正弦扰动(逐渐静置) · DEV_MS=1700 内的 dev 进度 上 · 格子马赛克继续细分(此时已染上照片自己的分块平均色),最后整体溶解 · split + grain = 1 - smoothstep(0.25, 1, dev)
所以照片一落地就出现(大块模糊形状),而不是等格子跑满才淡入;格子在它上面收拢消失, "大形状先定、细节后定"。test/develop.browser.test.mjs 在真 Chromium 里逐帧渲染并断言 早期颜色数显著少于末期(14 → 1055 → 518),把这条节奏钉死。
只有"刚刚出图"(结果事件时间 12s 内)才播显影;刷新页面重放的历史图直接显示,不重播。
驱动方式:原组件支持 progress(外部给)与自走(selfPaced,时间常数 estimatedDuration)两条路。 插件里不传 progress,用自走 + "图片落地即进入显影"——这正是原组件为「生成中 → 出图」设计的路径 (HOLD 让它永远差一点到 1,WAIT_CAP 让等待期不至于把格子裂满)。
cordis.patch.yml 的 config)key · 默认 · 说明 apiKeyEnv · IMAGE_API_KEY · 密钥来源:先看进程环境变量,再看 $DSH_HOME/.env baseUrl · 空 · 必填:服务端点(空值直接报错,绝不回落到写死地址) model · 空 · 必填:模型名 size / ratio · 2K / 1:1 · 默认档位与宽高比 count · 1 · 默认张数 optimize · true · 是否在生图前做提示词增强(false = 直接用原文) optimizer.provider / .model · 空 · 留空 = 跟随当前会话的模型;填了就固定用它。设置页选过的会覆盖这两项 optimizeTimeoutMs · 8000 · 增强超时。超时即跳过、回退原文,绝不把等待翻倍 optimizeTemperature / optimizeMaxTokens · 0.7 / 700 · 创意外扩与结构稳定的折中 saveFiles / saveDir · true / generated-images · 是否把图存到会话工作区,存到哪个子目录 timeoutMs · 300000 · 单次请求超时(多数服务建议 60–360s)
[用户原始输入] → [promptOptimizer.js:轻量 LLM 改写] → [生图 Client] → [图像]
src/promptTemplates.js(Art Director 英文模板:主体与动作 / 构图与留白 /风格与媒介 / 光影与配色;海报文字用双引号定位置;禁止 fluff 词), 调用与回退在 src/optimize.js,接线在 src/promptOptimizer.js。
withPromptEnhancement() 装饰 generateWithModel,imageApi.js / imageProviders.js 一行不改,只在调用前换掉 prompt。
退回规则式增强或原文,via 字段标明走了哪条路(llm / cache / rule / none / off)。 超时是自己掐表(withDeadline 竞速),不依赖上游是否尊重 signal。
stripFluff() 兜底剥掉 photorealistic / 8K / masterpiece(实测 deepseek-flash 会带出来,违反模板规则 4)。
(即你在「模型」页添加的那些)。存 models.json,不写宿主的 cordis.yml。 ⚠️ llm.listModels() 收的是路由 id(LlmProviderInfo.id),不是显示名 —— 拿显示名去查会全空。
info,含 via / 模型 / 耗时 / raw 与 optimized 对照,出图不理想时能判断是谁的锅。Final canvas: 9:16 (vertical), 2K,要求按这个比例排构图与留白,并明确禁止把比例/分辨率写进正文 (渲染器负责,写反了只会自相矛盾)。出口再用 stripRatioMentions() 兜底清掉模型硬写的 3:4 aspect ratio / (9:16) / 裸 21:9,保留 vertical / horizontal 这类构图词。 实测过的真实故障:1:1 画布被模型写成 A vertical 3:4 aspect ratio。
via=cache,不重复烧 LLM;只缓存 LLM 成功结果(回退不缓存),上限 40 条、LRU 淘汰。