# @zzdream67/dsh-vision-bridge

**让纯文本模型也能看图,用户无感知,不用手动切换模型。**

[English](./README.md)

在 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 ...
```

安装:

```powershell
npm install -g pnpm
```

> `corepack enable pnpm` 在 Windows 上常因需要写入 `C:\Program Files\nodejs\` 而失败(`EPERM`)。用上面的 `npm install -g` 更稳。

### 从 npm 安装(推荐)

```powershell
dsh plugin --profile web add @zzdream67/dsh-vision-bridge
```

装的是预编译产物,不需要构建授权。

### 从 GitHub 安装

```powershell
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` 里,并用引号包起来,因为它含 `@` 和 `:`。这个文件本来就存在,所以是追加,不是覆盖:

```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 安装

```powershell
npm pack                                    # 在本仓库执行
dsh plugin --profile web add ./zzdream67-dsh-vision-bridge-0.1.0.tgz
```

### 卸载

```powershell
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`)在图片进入会话之前就检查模型声明:

```js
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`:

```yaml
- 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`。

---

## 开发

```powershell
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 ]`)
- 空行和整体结构

**唯一真实的副作用:**

```diff
- displayName: LM Studio Local     # 手工对齐的注释
+ displayName: LM Studio Local # 手工对齐的注释
```

行内注释前手工对齐的空格会被压成一个,撤回后也不会回来。

声明会显式重申 `text`:

```yaml
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)

## 致谢

以下项目的公开源码为本插件的若干设计考量提供了参考:

- [dsh-vision-router](https://www.npmjs.com/package/dsh-vision-router) (MIT)
- [@anionex/dsh-vision-toolkit](https://www.npmjs.com/package/@anionex/dsh-vision-toolkit)
- [Anionex/agent-vision-toolkit](https://github.com/Anionex/agent-vision-toolkit)

本插件为独立实现,采用不同的架构路径(`llm/stream` 瀑布拦截,不注册路由或适配器)。
