SilasSolivagus / dsh-plugin-multimodal

已收录

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.

main模型 查看源代码

安装

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

此安装命令根据 GitHub 仓库地址生成,是未经验证的安装起点。

README

维护者编写的文档快照。

在 GitHub 查看 ↗
提交版本 8a47f37同步于 2026年8月17日

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 降级为占位转录。

项目文件与信号

以下项目是目录快照中检测到的公开仓库信号。

测试已检测

仓库信息

开发语言
JavaScript
许可证
未提供
最后更新
2026年8月17日 09:11

谨慎安装

请检查源代码、权限、生命周期脚本、依赖与网络访问;不受信任的插件应先在隔离环境中测试。