SilasSolivagus / dsh-plugin-multimodal

Listed

DeepSeek Harness (dsh/Cordis) plugin: makes the text-only V4 model multimodal via a pre-step hook. image/video/audio -> local VLM/ASR -> injected text. Zero paid API.

mainModel View source

Installation

npx -y @deepseek-ai/dsh plugin --profile web add github:SilasSolivagus/dsh-plugin-multimodal

This installation command is an unverified starting point generated from the GitHub repository address.

README

Maintainer-authored documentation snapshot.

View on GitHub ↗
Commit 8a47f37Synced Aug 17, 2026

dsh-plugin-multimodal

给 DeepSeek Harness(dsh / Cordis)直接加上多模态能力(图 / 声 / 视频)的插件。

真实 Web 页面演示(dsh web)

以下是插件在 DeepSeek Harness Web UI 中的真实运行截图(HTML 渲染,中文正常显示,不存在终端录屏的乱码问题;截图时左侧栏已收起以保持画面干净):

图像多模态:moondream 描述旗帜(红上蓝下)

图像:用户发送 这张图片里是什么?/tmp/dsh-demo/flag.png → V4 先尝试直接读图失败(model does not declare image input)→ 自动降级调用插件 read_media → moondream 描述 → V4 回答"上半红色、下半蓝色,两条等宽水平条纹,红上蓝下"。

语音多模态:whisper 转写中文语音

语音:用户发送 这段语音里说了什么?/tmp/dsh-demo/speech.wav → 插件调 whisper 转写 → 注入"你好,我是校园网智能助手,请问能帮你做什么?" → V4 润色标点并逐句解释。

视频多模态:ffmpeg 抽帧 + moondream 逐帧描述

视频:用户发送 这个视频里有什么?/tmp/dsh-demo/bars.mp4 → 插件 ffmpeg 抽 4 帧 → moondream 逐帧描述 → V4 给出元数据(320×240 / 25fps / 100 帧)与逐帧表(前一半纯蓝、后一半红块,硬切换),并诚实说明"采样无法精确到每一帧"。

这些截图来自真实 dsh web 会话(模型 DeepSeek-V4-Pro,推理等级 High)。可见 V4 自身不收图 / 声 / 视频,所有多模态能力都由本插件在 agent/pre-step(自动注入)与 read_media 工具侧完成"翻译 → 文字注入",V4 作为纯文本主脑作答。

仓库内另保留 demo.gif(asciinema 终端录屏),展示 headless 模式下的三模态闭环,可作为补充参考。

它做什么

挂上这个插件后,基于 DeepSeek 的 Agent 能"看见 / 听见"图片、音频、视频。

关键实测结论(2026-08-17,真发请求验证):DeepSeek 的对话 API (deepseek-v4-pro / deepseek-v4-flash / deepseek-chat是纯文本的, 收 image_url 会返回 400 unknown variant image_url, expected text。 因此"直接多模态"V4 自己做不到,必须靠插件侧做视觉翻译。本插件正是那个机制: 媒体 → 可插拔视觉 / 语音后端 → 文字描述 → 作为 text block 注入 V4, 让 V4 当"主脑(文本)"、插件当它的"眼睛"。

三条模态路径(按文件类型自动分派)

  • 图片(image):路径 / URL → 视觉后端(moondream 等本地 VLM)翻成文字。
  • 视频(video)ffmpegvideoFps 抽最多 videoMaxFrames 张帧 → 逐帧送视觉后端 描述 → 拼成带 Frame i/N 标号的文本块。
  • 音频(audio)whisper CLI(本地 ASR)或外部 ASR HTTP 端点翻成文字转录。

三种接入方式:

  • pre-request hook(默认开启,最"直接"):在 agent/pre-step(Harness 官方 waterfall 扩展点,决定"模型能看到什么")上注册监听器,自动扫描每条消息里的 媒体路径 / URL,按类型经可插拔后端翻译为文字后注入——模型无需主动调工具即"看见 / 听见"。
  • read_media 工具:模型也可主动调用,显式处理媒体文件(同样按类型分派)。
  • set_multimodal_backend 工具:模型 / 用户可在会话内实时切换后端或模型 (如把 vision_model 换成更强模型,或中途关掉 auto_inject),无需重启 dsh。
  • 单条消息内联提示:在媒体路径后紧跟 @vision=<backend>/<model>@asr=<backend>/<model>,仅对这一条消息临时覆盖后端 / 模型,不动全局配置。 例:看 /tmp/x.png @vision=moondream/a.mp4 @vision=ollama/llava/t.wav @asr=whisper/small

架构(契合 Cordis 范式)

  • 解耦前端(核心):图 / 声 / 视频各有独立翻译器(可插拔 visionProvider), 输出统一的 text content block。V4 原生不收图,这一步是必需的。
  • 空间可组合(coeffect)inject: ['tools'] 声明依赖,Cordis 等服务就绪才加载。
  • 时间可组合(effect):插件卸载 / 热替换时,已注册的 hook 与工具随 ctx 自动清理。
用户媒体(图/声/视频)
   │ 解耦前端: 各模态独立翻译器 (可插拔后端)
   ▼
视觉后端 (本地 VLM / 外部 vision API)  ──┐
音频 ASR (whisper 等)                  ──┤→ text block
视频 ffmpeg 抽帧 + 视觉后端           ──┘
   │
   ▼
DeepSeek Harness (Cordis) ── dsh-plugin-multimodal ──► V4 (纯文本主脑)

配置可配置性(四级优先级)

同一个旋钮有四层来源,优先级从高到低

单条消息内联提示  >  会话内 set_multimodal_backend 工具  >  环境变量  >  patch.yml / schema 默认
  1. profile 级静态配置cordis.patch.ymlconfig: 或 schema 默认值)—— 重启 dsh 生效。

  2. 环境变量(启动前 export,最高优先于静态配置,适合开发 / CI 临时覆盖):

    环境变量对应旋钮
    MULTIMODAL_AUTO_INJECTautoInject(true/false)
    MULTIMODAL_VISION_BACKENDvisionBackend
    MULTIMODAL_VISION_MODELvisionModel
    MULTIMODAL_OLLAMA_URLollamaBaseUrl
    MULTIMODAL_VISION_HTTPhttpEndpoint
    MULTIMODAL_FFMPEG_BINffmpegBin
    MULTIMODAL_VIDEO_MAX_FRAMESvideoMaxFrames
    MULTIMODAL_VIDEO_FPSvideoFps
    MULTIMODAL_ASR_BACKENDasrBackend
    MULTIMODAL_ASR_MODELasrModel
    MULTIMODAL_WHISPER_BINwhisperBin
    MULTIMODAL_ASR_HTTPasrEndpoint
    MULTIMODAL_VISION_MODEL=llava MULTIMODAL_ASR_MODEL=small dsh --profile headless "看 /tmp/x.png"
    
  3. 会话内工具切换set_multimodal_backend(参数 vision_backend / vision_model / asr_backend / asr_model / auto_inject,均可选),调用后影响该会话后续所有请求。

  4. 单条消息内联提示@vision= / @asr= 仅作用于一条消息(见上)。

插件实现(与官方 Cordis API 对齐)

lib/index.js 是标准 Cordis 插件,导出 Config / apply / inject / name

import "@deepseek-ai/cordis";
import z from "@deepseek-ai/schemastery";
import { defineTool } from "@deepseek-ai/dsh-tools";
import { createUserMessage } from "@deepseek-ai/dsh-llm";
import { findMediaTokens, messageText, makeVisionProvider, makeASRProvider,
         detectMediaType, extractVideoFrames, describeFrames,
         mergeEnvConfig, hintToOverrides } from "./core.js";

const name = "dsh-plugin-multimodal";
const inject = ["tools"];

function apply(ctx, rawConfig = {}) {
  const config = mergeEnvConfig(rawConfig);     // ① 环境变量覆盖最高优先
  const runtime = { ...config };                // 可变运行时态(工具切换改它)
  let vision = makeVisionProvider(runtime);
  let asr = makeASRProvider(runtime);
  // 按媒体类型分派到对应翻译器;单条消息的内联提示仅覆盖本条
  async function translate(tok) {
    const override = hintToOverrides(tok.hint);
    const c = Object.keys(override).length ? { ...runtime, ...override } : runtime;
    const v = makeVisionProvider(c); const a = makeASRProvider(c);
    if (tok.type === "video") {
      const frames = extractVideoFrames(tok.path, { ffmpegBin: c.ffmpegBin, maxFrames: c.videoMaxFrames, fps: c.videoFps });
      return describeFrames(frames, v);          // 抽帧 → 逐帧视觉 → 拼文本
    }
    if (tok.type === "audio") return a(tok.path); // 本地 whisper / 外部 ASR
    return v(tok.path);                          // 图片 / URL 走视觉后端
  }
  // 主路径:pre-step hook 自动注入(autoInject 每次读 runtime,可被工具切换)
  ctx.on("agent/pre-step", async ({ agent, turn, step, signal }, next) => {
    if (!runtime.autoInject) return next();
    const decision = await next();              // 先拿到下游决策(含最终 messages)
    if (decision.kind === "reject" || signal.aborted) return decision;
    // ... 扫描 media token → translate() 按类型翻译 → createUserMessage 追加一条插件消息
    return { kind: "enter", messages: [...decision.messages, injected] };
  });
  // 显式入口 1:read_media 工具(同样按类型分派,支持内联提示)
  // 显式入口 2:set_multimodal_backend 工具(会话内实时切换后端 / 模型)
  ctx.tools.register(defineTool({ name: "read_media", /* ... */ async execute(args){ /* translate() */ } }));
  ctx.tools.register(defineTool({ name: "set_multimodal_backend", /* ... */ async execute(args){ /* 改 runtime + rebuildProviders() */ } }));
}
  • 纯逻辑层 lib/core.js 不依赖 cordis,可独立单测 (媒体令牌提取、消息摊平、视觉后端降级、extractVideoFrames / describeFrames / makeASRProvidermergeEnvConfig 环境变量覆盖、hintToOverrides 内联提示解析)。
  • 符合 Harness 铁律 "model-visible means logged":注入内容进入模型可见上下文,由会话日志留痕。

本地加载(已实测可用)

需要 @deepseek-ai/dsh0.1.0-rc.6)与 Node ≥ 20,外加三个可选系统依赖:

  • 视觉后端:本地 Ollama(ollama pull moondream && ollama serve)。moondream ~1.7GB、CPU 友好; llava(7B) 在本机实测会 OOM,不建议。
  • 视频抽帧:系统 ffmpeg(本机 8.0.1,PATH 内即可;可用 ffmpegBin 覆盖)。
  • 音频 ASR:本地 whisper CLI(openai-whisper,pip install whisperwhisper --model base 首次会下载 ~145MB 模型,之后离线可用)。也可用 asrBackend: http 指向自建 / 第三方 ASR 端点。

① 让包可被 profile 解析(二选一):

  • 发布后:dsh plugin --profile web add dsh-plugin-multimodal
  • 本地开发(本仓库验证用过的方式):把插件与其 deepseek 依赖按真实路径 symlink 进 profile 的 node_modules(见 scripts/link-local.sh):
bash scripts/link-local.sh web
bash scripts/link-local.sh headless

② 在 profile 的 cordis.patch.yml 加一行 insert(用户可编辑层,附带于 bundle 层之后):

- insert:
    - id: multimodal
      name: 'dsh-plugin-multimodal'
      config:
        autoInject: true
        visionBackend: ollama
        visionModel: moondream
        ollamaBaseUrl: http://localhost:11434
        ffmpegBin: ffmpeg          # 视频抽帧
        videoMaxFrames: 4
        videoFps: 1
        asrBackend: whisper        # whisper | http | none
        asrModel: base
        whisperBin: whisper
        asrEndpoint: ""            # asrBackend=http 时填端点

③ 启动dsh web(或 dsh --profile headless "看 /tmp/x.png 是什么"

开发 / 单测

node --test test/core.test.js   # 纯逻辑层 17/17 通过(含视频抽帧、ASR 降级、环境变量/内联提示/工具切换)

端到端验证(真发请求,已实测通过)

真正的多模态闭环用 dsh --profile headless 实跑(插件 hook 会在 pre-step 自动注入):

ollama pull moondream && ollama serve          # 本地视觉后端(图 / 视频帧)
whisper --model base                           # 首次下载 ASR 模型(之后离线)
dsh --profile headless "look at /tmp/dsh-mm-test.png and tell me what you see"
dsh --profile headless "describe the video /tmp/dsh-mm-test.mp4"
dsh --profile headless "transcribe the audio /tmp/dsh-mm-test.wav"

图片实测(2026-08-17,真实请求,EXIT=0):V4 正确回答"红上蓝下两横条"(自述不收图、靠注入描述)。

视频(ffmpeg 抽帧):插件抽 N 帧 → 逐帧 moondream 描述 → 注入 Frame i/N: ...,V4 据此回答画面内容。

音频(whisper ASR):插件调 whisper --model base 转写 → 注入文本,V4 据此回答语音内容。

配置三级动态切换(均已实测)

# ② 环境变量覆盖(最高优先于 patch.yml;改模型无需编辑配置、无需重启)
MULTIMODAL_VISION_MODEL=llava dsh --profile headless "看 /tmp/x.png"

# ③ 会话内工具切换:让模型/你中途把视觉模型换成更强的
dsh --profile headless "先调用 set_multimodal_backend 把 vision_model 设为 llava,再看 /tmp/x.png"

# ④ 单条消息内联提示:仅本条用 ollama/llava,其余消息仍走默认 moondream
dsh --profile headless "看 /tmp/x.png @vision=ollama/llava"

要点:四级来源(patch.yml → 环境变量 → 工具切换 → 内联提示)优先级递增,越靠后越临时、 越精确;内联提示只对单条消息生效,工具切换对该会话后续所有请求生效,环境变量对本次 dsh 进程生效。

四级切换均已在真实 harness 实测(2026-08-17)

  • MULTIMODAL_VISION_BACKEND=none 启动 → 自动注入上下文显示 "vision backend disabled", V4 随之自行调用 set_multimodal_backend 切回 ollama/moondream 再描述图片 (环境变量覆盖 + 会话内工具切换在同一次真跑中同时被验证)。
  • /tmp/x.png @vision=none → V4 在自动上下文里直接引用 "vision backend disabled", 证明单条消息内联提示只作用于本条、覆盖全局默认。
  • 普通 autoInject: true 图文请求仍正确返回 "红上蓝下双色旗"(回归通过,EXIT=0)。

要点:DeepSeek V4 本身不收图(实测 read_imagemodel does not declare image input), 但本插件的 agent/pre-step hook 自动把图经 moondream 翻译成文字注入上下文,V4 作为纯文本主脑 据此正确回答了"红上蓝下"。这说明"直接多模态"在 DeepSeek 文本 API 上只能靠插件侧视觉翻译实现, 而本插件正是该机制,且已挂进本地两个 profile(web / headless)跑通。

(可选)verify-e2e.mjs 也能单独验证"图→文字→V4"链路(生成测试图、经视觉后端翻译、发 V4)。

边界说明

  • Harness 仍在开发者预览期(0.1.0-rc.6),API 可能变。
  • DeepSeek 对话 API 不收 image_url(已实测 400);图必须经视觉后端翻译为文字注入。
  • 视觉后端默认 ollama(本地 VLM);也可配 visionBackend: http 指向自建 / 第三方 vision 端点,或 none 优雅降级为占位说明。
  • 视频:靠系统 ffmpeg 抽帧(最多 videoMaxFrames 张,频率 videoFps),逐帧送视觉后端; 无 ffmpeg 时优雅降级为 [video: no frames could be extracted]
  • 音频asrBackend: whisper 调本地 whisper CLI(默认 base 模型);也可配 asrBackend: http 指向自建 / 第三方 ASR 端点,或 none 降级为占位转录。

Project files and signals

Shown items are public repository signals detected in the directory snapshot.

TestsDetected

Repository information

Language
JavaScript
License
Not reported
Last updated
Aug 17, 2026, 9:11 AM

Install deliberately

Review source code, permissions, lifecycle hooks, dependencies and network access. Test untrusted plugins in an isolated environment.