Skip to content

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

让纯文本模型也能看图,用户无感知,不用手动切换模型。

English

在 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 写声明

manageDeclarationsllm-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.buildRequestdeepFreeze 包裹,所以 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 瀑布拦截,不注册路由或适配器)。