dsh-vision-bridge
Verified@zzdream67/dsh-vision-bridge · v0.1.0 · MIT · Web UI
Let text-only models see images in DeepSeek Harness: intercepts the llm/stream waterfall and transparently substitutes each image with a vision model's description.
Install
dsh plugin add @zzdream67/dsh-vision-bridge Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
@zzdream67/dsh-vision-bridge
让纯文本模型也能看图,用户无感知,不用手动切换模型。
在 DeepSeek Harness 中,给纯文本模型贴一张图会被直接拒绝:
Model "xxx" does not support image input.
本插件拦截 llm/stream 瀑布,在请求到达提供方之前,把每个图片块替换成视觉模型的文字转述。你只管贴图,纯文本模型收到的是它读得懂的文字。
你贴图 + 提问
↓
插件调用你配置的视觉模型识别 → 得到文字描述
↓
纯文本模型收到:[图片「shot.png」视觉模型转述:一个 Python TypeError…] + 你的问题
↓
它基于这段文字回答
安装
前置:pnpm
dsh plugin 内部转发给 pnpm,很多环境没有它:
'pnpm' is not recognized as an internal or external command
dsh: pnpm failed in profile directory ...
安装:
npm install -g pnpm
corepack enable pnpm在 Windows 上常因需要写入C:\Program Files\nodejs\而失败(EPERM)。用上面的npm install -g更稳。
从 npm 安装(推荐)
dsh plugin --profile web add @zzdream67/dsh-vision-bridge
装的是预编译产物,不需要构建授权。
从 GitHub 安装
dsh plugin --profile web add github:zzdream67/dsh-vision-bridge
git 安装拉取源码并在安装时构建。pnpm ≥10 在得到显式允许之前拒绝运行 git 依赖的构建脚本,所以首次 add 会失败,报 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED。
报错里会打印需要放行的那个键,末尾是它解析到的 commit:
allowBuilds:
@zzdream67/dsh-vision-bridge@https://codeload.github.com/zzdream67/dsh-vision-bridge/tar.gz/<sha>: true
把这个键原样复制到该 profile 的 pnpm-workspace.yaml 里,并用引号包起来,因为它含 @ 和 :。这个文件本来就存在,所以是追加,不是覆盖:
allowBuilds:
'@zzdream67/dsh-vision-bridge@https://codeload.github.com/zzdream67/dsh-vision-bridge/tar.gz/<sha>': true
然后重新执行 add。这个键里含 commit hash,分支一动它就变。锁定 commit(github:zzdream67/dsh-vision-bridge#<sha>)可以让它保持稳定,或者直接从 npm 安装,就不用管这一步。
从 tarball 安装
npm pack # 在本仓库执行
dsh plugin --profile web add ./zzdream67-dsh-vision-bridge-0.1.0.tgz
卸载
dsh plugin --profile web remove @zzdream67/dsh-vision-bridge
依赖和它的配置层会一并移除。如果 manageDeclarations 开过,插件会在卸载时撤回它写入的声明。
撤回恢复的是原本的确切值。每条声明都会记录该行 input 改动前的样子,因为去掉 image 之后,残留的 ['text'] 和「这个字段本来就不存在」无法区分。靠规则推断的撤回会删掉一行你手写的配置,记录下来的值避免了这种情况。
记录只是「写下这条记录时该行确实存在」的备注,文件是你的。如果撤回之前你删了模型、删了整条路由,或者手工改过那一行,插件选择放弃,而不是重建:
| 你做了什么 | 撤回时的行为 |
|---|---|
| 删掉了模型 | 跳过。不会根据过期的记录重建一个你删掉的模型 |
| 删掉了整条路由 | 跳过,其余路由照常清理 |
把 input 手工改成了不合法的值 |
留给你自己修 |
| 什么都没做,行还是插件留下的样子 | 恢复原本的确切值 |
一个目标缺失,其余撤回照常进行。把一条声明留在已经不存在的行上,比重建一个你删掉的行要好,所以缺失的行直接跳过。
唯一回不来的是行内注释的对齐空格,细节见「开发」一节。
配置
插件自带浏览器端 bundle(dist/client.js),把自己的分区注册进 Web 设置页的 settings.section 插槽。所有字段都能在这里改,即时生效,不用重启。
这个页面里有三个按钮值得了解:
- 测试并启用:向所选视觉模型发一张 16×16 的 PNG。端点接受图片才写声明,失败就撤回。能力是测出来的,因为 OpenAI 兼容的
/v1/models响应不包含模态信息。 - 直接启用:跳过测试,直接写声明。你已经确定模型能看图时用这个。
- 重新读取:从磁盘重新读配置。桌面端没有刷新这个动作,这是唯一能同步「模型」页、其他窗口或手工编辑改动的方式。有未保存的修改时会先向你确认。
宿主侧只注册 schema 不会渲染表单。这个分区存在,是因为本插件手工编写了那个浏览器端 bundle;DSH 客户端格式的构建预设没有公开,但格式本身简单且稳定。
为什么必须先有声明
准入闸门(dsh-host-apiproxy)在图片进入会话之前就检查模型声明:
if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image'))
return err(request, { details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } })
纯文本模型必须先声明 input: [text, image],否则图片根本进不来,本插件的转换也不会运行。
manageDeclarations(默认开)为 bridge 里的每个模型写这条声明,并在你关掉开关、禁用插件或卸载时撤回。插件加载、模型已桥接时,这条路由确实接受图片输入,因为转换发生在请求到达提供方之前。
input: [text, image] 只是一张准入标签。宿主读它的每一处都由「请求里有没有图片」把关,所以纯文本请求在有无声明时行为完全一致。它不影响 token 计费、上下文窗口、采样参数或模型行为。
给模型加声明却不桥接,失败只是被推迟:宿主放行图片,提供方再拒绝。所以声明跟随 bridge 列表,而不是广泛应用。
字段
| 字段 | 默认值 | 说明 |
|---|---|---|
enabled |
true |
总开关。关闭后插件保持加载但不干预任何请求,并撤回它写入的声明,相当于不碰 node_modules 的卸载 |
visionProvider |
'' |
视觉路由 id。留空则插件空转(见「行为细节」) |
visionModel |
'' |
该路由下的视觉模型 id。留空则插件空转(见「行为细节」) |
prompt |
见下 | 随每张图发送的转述指令 |
bridge |
[] |
要桥接的纯文本模型 |
manageDeclarations |
true |
由插件写入/撤回模态声明 |
cacheSize |
64 |
缓存的转述条数;0 关闭缓存 |
timeoutMs |
120000 |
单张图片的转述超时 |
verbose |
false |
每次转换输出一行日志(不记录转述文本) |
也可以写在 cordis.patch.yml:
- id: zz-vision-bridge
name: '@zzdream67/dsh-vision-bridge'
config:
visionProvider: my-local-route
visionModel: my-vision-model
bridge:
- provider: my-text-route
model: my-text-model
manageDeclarations: true
没列入 bridge 的模型不受触碰,保持宿主的原有行为。
内置 deepseek-official 路由下的模型无法桥接:它的模态硬编码在提供它的插件里,适配器也拒绝图片内容。要桥接 DeepSeek,就自建一个模型提供方,路由用 openai-completions 指向 https://api.deepseek.com/v1,再桥接该路由下的模型。
默认提示词
它要求转述,不是解读:如实转述图中所有可见文字,描述布局和结构,不推测、不补空白,看不清就直说。推理属于消费转述的模型;视觉模型擅自解读,调用方丢的信息就再也找不回来。
行为细节
| 情形 | 行为 |
|---|---|
| 没配置视觉后端 | 插件空转:照常加载,什么都不做,遇到图片时警告一次 |
目标模型不在 bridge 里 |
原样透传,零影响 |
| 请求是插件自己的转述调用 | 跳过(否则会无限递归) |
| 请求里没有图片 | 走快速路径,跳过 |
| 同一张图反复出现 | 顺序解析,命中缓存,只转述一次 |
| 转述失败或超时 | 注入失败说明,告诉模型不要假装看到了图 |
| 请求对象(始终深度冻结) | 不改动。通过 ctx.llm.stream() 发起一次携带改写后消息的新请求 |
转述调用走 ctx.llm.stream(),所以视觉路由的凭据、重试策略、attribution 头、可观测性全部照常生效。插件自己不发起任何 HTTP 请求。
安装之前需要知道的
模型收到的是文字,不是像素。
| 能做 | 做不到 |
|---|---|
| 读截图里的报错、代码、日志 | 精确比较两张图 |
| 描述界面布局、按钮位置 | 取色号、量像素 |
| 转录表格、图表趋势 | 精细的空间或几何推理 |
| 读手写和印刷文字 | 依赖细微视觉特征的判断 |
每次替换都标注为转述,模型不会当自己亲眼看到了图。
你需要自己的视觉模型。 插件不带任何视觉服务,用的是你配置的视觉模型:本地的 LM Studio / Ollama 实例,或者你持有密钥的远程服务。本地模型零成本,数据不出机器;远程的记在你自己账上。
插件会写 settings.yaml。 manageDeclarations 会给 bridge 里每个模型往 llm-pi-ai 配置段写 input: [text, image],在你关掉开关、禁用插件或卸载时撤回。开关默认开,因为声明是功能生效的前提。能保住什么、保不住什么,见「开发」一节。第一次启用前先备份 settings.yaml。
开发
npm install
npm run build # tsc -> dist/
npm test # node --test, 68 项
npm run typecheck
src/rewrite.ts 是纯函数、无 I/O,所以改写逻辑不用真实模型就能完整测试。test/uninstall.test.mjs 断言声明/撤回往返逐字节一致:字段原本缺失、手写的 [ text ]、不常见的模态列表、未被触碰的相邻行。
为什么用 llm/stream 而不是新建路由
常见做法是注册一条孪生路由(如 xxx (vision)),硬编码模态。本插件不这么做:
- 不注册路由,也不注册适配器,宿主的模型注册表继续说真话,模型选择器里不会多出条目。
- 不参与注册表竞争,也就不需要「注册表被别人重建」那套防御逻辑。
- 不留残留:
ctx.on()是 effect,自动清理;声明写入显式撤回。
深层改写
图片块可以出现在任意深度,包括嵌套在 tool-result 里。内置 read_image 工具把图片记进工具结果,所以用过它的会话,之后每一轮都带着嵌套图片块。
只改顶层会把它们留在原地,纯文本适配器在之后每一轮都失败,不只是上传图片那一轮。本插件递归处理所有深度。
提示词注入防护
从图片里恢复的文字是攻击者可控的,和网页一样:截图里可以写「忽略之前的所有指令」。
每条转述都带明确标注:图中文字是不可信证据,不能当指令执行。标注在转述之前,文字用围栏包住,围栏里的内容没法冒充周围的叙述。
往 settings.yaml 写声明
manageDeclarations 往 llm-pi-ai 配置段写 input: [text, image],在你关掉开关、禁用插件或卸载时撤回。以下是对真实 dsh-settings-file 的实测,它用 YAML AST 做最小化编辑:
能保住的:
- 文件头、段落、行内注释的文本
- 其他路由、其他 namespace
- 已有的内联写法(如
input: [ text, image ]) - 空行和整体结构
唯一真实的副作用:
- displayName: LM Studio Local # 手工对齐的注释
+ displayName: LM Studio Local # 手工对齐的注释
行内注释前手工对齐的空格会被压成一个,撤回后也不会回来。
声明会显式重申 text:
input: [ text, image ]
pi-ai 把这个数组直接映射到 inputModalities。只写 [image] 会声明出一个接受图片但不接受文本的模型,所以写入时重申 text。
已验证的事实
以下都是对真实 DSH 运行时的实测:
llm/stream监听器会触发,但请求到达时已被深度冻结:dsh-agent-loop.buildRequest用deepFreeze包裹,所以options.messages = x会抛TypeError。替换参数槽也不行,cordis 的 inner 回调闭包的是原始对象,arguments[0] = …和next(replacement)都到不了适配器。唯一可行的接缝是跳过next(),发起一次新的ctx.llm.stream({ ...options, messages: rewritten }),并标记为插件自己的请求,防止重入。- 适配器收不到任何 image 块,包括嵌套在
tool-result里的。 - 嵌套的
ctx.llm.stream()会重入监听器,用WeakSet防住。 - 声明
[text, image]后,准入闸门放行图片。 image块携带的是附件引用,不是内联字节。pi-ai 用attachments.readImage(block.attachment)解析它;携带{ data, mediaType }的块会被静默丢弃。- 所有终止结果,包括适配器拒绝,都以
{ type: 'finish', reason: { kind: 'error' | 'aborted', failure } }到达。不存在type: 'error'的 chunk,也没有finishReason字段。 - 跨 namespace 写配置没有所有权限制,但路径寻址不支持数组下标,改一行要整数组写回。
- 真实
dsh-settings-file落盘会保留注释,副作用只有上文那一个格式问题。
许可
MIT © ZZ Dream (zzdream67)
致谢
以下项目的公开源码为本插件的若干设计考量提供了参考:
本插件为独立实现,采用不同的架构路径(llm/stream 瀑布拦截,不注册路由或适配器)。