DeepSeek Harness 插件开发进阶:从 Host 工具到 Client UI

一、先理解 DSH 的插件模型

DSH 的插件运行时基于 Cordis。一个插件本质上是一个模块,它导出几样东西:

export const name = 'my-plugin'        // 插件名
export const inject = ['tools']        // 依赖哪些服务
export const Config = Schema.object({})// 配置 schema(可选)
export function apply(ctx, config) {}  // 插件主体

inject 声明依赖的服务;Cordis 会在这些服务可用时调用 apply,并在插件卸载或依赖消失时释放它注册的资源。Config 用于校验 profile 配置行里的 config。

1.1 Profile、Bundle 与 Patch

DSH 的运行时组合是分层的:

DSH 进程
└─ profile(desktop / web / headless / ...)
   ├─ package.json
   │  └─ dsh.profile.bundles: [bundle A, bundle B, ...]
   ├─ cordis.patch.yml        # 用户自己的覆盖层
   └─ node_modules/
      ├─ @deepseek-ai/dsh-base       # 基础 bundle
      ├─ @deepseek-ai/dsh-web-app    # Web 形态 bundle
      └─ my-plugin                   # 你的插件
         ├─ cordis.patch.yml         # 插件要插入的配置行
         ├─ lib/index.js             # Host 侧入口
         └─ lib/client.js            # Client 侧入口(可选)
  • Bundle:一组配置行的集合,通常是一个 npm 包,通过 dsh.bundle.patch 指向自己的 cordis.patch.yml。
  • Patch:按 id 定位配置行,可以覆盖、禁用或插入。注意,patch 会替换目标行的整个 config,不是深合并;想保留的字段要重写。
  • Profile:把若干 bundle 按顺序叠加,再叠加用户自己的 cordis.patch.yml 和 --patch 覆盖层。

加载顺序大致是:bundle 的 patch → profile 的 cordis.patch.yml → home 级 patch → --patch。后写的层覆盖先写的层。

1.2 Host 侧与 Client 侧

一个插件可以只有一个 Host 侧,也可以同时有 Host 和 Client 两侧:

Host 侧Client 侧
运行环境Node.js浏览器页面
入口main / exports["."]exports["./client"]
能做什么工具、命令、服务、会话投影、模型提供方、文件/网络访问页面、面板、设置卡片、聊天区域扩展
不能做什么不能直接画 UI不能直接读文件、跑进程、访问 Node API
依赖声明peerDependenciesdsh.client.inject

两侧通过远程调用(Typert)通信。不要为了省事把 Host 的文件读取逻辑塞进 Client;浏览器侧拿不到 Node 能力。

1.3 常见扩展点

扩展点服务典型用途
工具ctx.tools让模型调用新能力
斜杠命令ctx.commands用户直接触发、不经过模型
UI 插槽ctx.slots新增页面、面板、标签页、设置卡片
会话投影ctx.sessionProjections把事件流投影成 UI 可读状态
设置表单ctx.configForms插件详情页的配置卡片
远程调用ctx.typertHost 与 Client 之间的类型化 RPC
模型提供方ctx.llm注册新的模型路由(进阶)

二、准备开发环境

2.1 前置条件

  • Node.js ^22.19.0 或 >=24.0.0。
  • pnpm。
  • 一个可运行的 DSH。开发阶段建议用 web profile,而不是桌面端持有的 desktop profile。
  • TypeScript 和 React 基础。Host 侧可以不用 React,Client 侧需要。
node --version
pnpm --version
dsh --version

2.2 版本对齐

插件会 import DSH 的包,例如 @deepseek-ai/cordis、@deepseek-ai/dsh-tools、@deepseek-ai/schemastery。这些包在运行时由 DSH profile 提供,在开发时作为 peerDependencies / devDependencies 安装,用来做类型检查。

原则:

  • 插件的 peer 范围和当前 DSH 版本对齐。
  • 不要在一个插件里捆绑另一份 @deepseek-ai/cordis,否则可能出现两个服务容器。
  • 升级 DSH 后,重新跑类型检查和一次真实安装测试。

2.3 建一个独立目录

不要把插件源码直接放进 ~/.dsh/profiles/web。在项目目录里开发,再通过本地路径安装:

dsh-plugin-hello/
├─ package.json
├─ cordis.patch.yml
├─ README.md
├─ tsconfig.json
├─ tsdown.config.ts
├─ tsdown.client.config.ts
└─ src/
   ├─ index.ts
   └─ client.tsx

三、最小可用:一个 Host 工具插件

这个例子注册一个 hello_greet 工具、一个 /hello 命令,并在会话里增加一个标签页。先完成 Host 侧,再补 Client 侧。

3.1 package.json

{
  "name": "dsh-plugin-hello",
  "version": "0.1.0",
  "description": "A minimal DeepSeek Harness plugin with one tool, one command and one conversation tab.",
  "type": "module",
  "main": "lib/index.js",
  "exports": {
    ".": "./lib/index.js",
    "./client": "./lib/client.js",
    "./package.json": "./package.json"
  },
  "files": [
    "lib",
    "cordis.patch.yml",
    "README.md"
  ],
  "keywords": [
    "dsh-plugin",
    "deepseek-harness",
    "dsh"
  ],
  "license": "MIT",
  "engines": {
    "node": "^22.19.0 || >=24.0.0",
    "dsh": ">=0.1.5-rc.3"
  },
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    },
    "client": {
      "inject": [
        "@deepseek-ai/dsh-client-ui-conversation"
      ],
      "platform": "web"
    }
  },
  "peerDependencies": {
    "@deepseek-ai/cordis": "^4.0.2",
    "@deepseek-ai/dsh-tools": ">=0.1.5-rc.3",
    "@deepseek-ai/schemastery": "^3.18.2",
    "react": "^18.3.1"
  },
  "devDependencies": {
    "@deepseek-ai/cordis": "^4.0.2",
    "@deepseek-ai/dsh-tools": "0.1.5-rc.3",
    "@deepseek-ai/schemastery": "^3.18.2",
    "@types/node": "^22.9.0",
    "@types/react": "^18.3.31",
    "react": "^18.3.1",
    "tsdown": "^0.22.2",
    "typescript": "^5.6.3"
  },
  "scripts": {
    "build": "tsdown && tsdown -c tsdown.client.config.ts",
    "typecheck": "tsc --noEmit"
  }
}

几个字段的作用:

  • dsh.bundle.patch:告诉 DSH 这个包的组合补丁在哪里。
  • dsh.client:声明存在 Client 侧入口;inject 是客户端依赖的模块,platform 目前是 web。
  • exports["./client"]:Client 侧的加载入口,文件名和构建产物要一致。
  • keywords:dsh-plugin 和 deepseek-harness 是插件市场发现插件的关键词。
  • engines.dsh:声明测试过的 DSH 范围,便于用户判断兼容性。

3.2 cordis.patch.yml

- insert:
    - id: hello
      name: 'dsh-plugin-hello'
      config:
        greeting: 你好

insert 会向当前 profile 插入一行插件配置。id 在同一 profile 中应唯一;name 是包名;config 会传给 apply 的第二个参数,并先经过 Config 校验。

3.3 Host 入口

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import Schema from '@deepseek-ai/schemastery'

export const name = 'dsh-plugin-hello'
export const inject = ['tools', 'commands']

export const Config = Schema.object({
  greeting: Schema.string().default('你好'),
})

export function apply(ctx: Context, config: { greeting: string }) {
  ctx.tools.register(defineTool({
    name: 'hello_greet',
    description: 'Return a short greeting for a person. Use when the user asks for a greeting demo.',
    parameters: {
      name: {
        type: 'string',
        required: true,
        description: 'Name to greet',
      },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args, exec) {
      exec.signal.throwIfAborted()
      return `${config.greeting},${args.name}!`
    },
  }))

  ctx.commands.register({
    name: 'hello',
    description: 'Print a greeting without calling the model',
    input: { hint: '<name>' },
    handler: ({ rawInput }) => ({
      kind: 'success',
      text: `${config.greeting},${rawInput.trim() || '世界'}!`,
    }),
  })
}

逐段说明:

  • name:插件标识,和包名保持一致最省心。
  • inject:声明依赖 tools 和 commands 两个服务。缺少这些服务的 profile 会一直等待依赖,apply 不会执行;如果只是想在无头环境使用工具,可以去掉 commands 依赖。
  • Config:Cordis 用它校验配置行。greeting 有默认值,所以 patch 里不写也能启动。
  • apply:插件主体。ctx.tools.register 和 ctx.commands.register 注册的资源会随插件卸载自动释放。
  • defineTool:声明工具名、描述、参数 schema、输出 schema 和 execute。参数在执行前会被校验,非法输入变成普通的工具错误,而不是让整轮对话崩溃。
  • output.render:把返回值渲染成模型可读的内容块。这里返回纯文本,所以用 text 块。
  • exec.signal:调用方的取消信号。工具应尽量在耗时操作前检查它,并把信号传给 fetch、readFile 等支持取消的 API。
  • ctx.commands.register:注册 /hello。命令直接对 Agent 执行,不会产生模型消息;handler 返回 success 或 error。

不要小看工具的 description。模型是根据名称、描述和参数 schema 选择工具的;描述写清楚「什么时候用、返回什么」,比堆很多同义工具更有效。

3.4 类型检查与构建

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": [
    "src",
    "tsdown.config.ts",
    "tsdown.client.config.ts"
  ]
}

Host 构建配置 tsdown.config.ts:

import { defineConfig } from 'tsdown'

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm'],
  outDir: 'lib',
  dts: true,
  external: [/^@deepseek-ai\//],
})

Client 构建配置 tsdown.client.config.ts:

import { defineConfig } from 'tsdown'

export default defineConfig({
  entry: ['src/client.tsx'],
  format: ['esm'],
  outDir: 'lib',
  clean: false,
  external: [
    'react',
    'react-dom',
    'react/jsx-runtime',
    /^@deepseek-ai\//,
  ],
})

客户端构建使用 clean: false,因为两个构建共用 lib/;否则第二次构建可能清掉 Host 产物。

安装依赖并构建:

pnpm install
pnpm typecheck
pnpm build

构建后应得到 lib/index.js 和 lib/client.js。如果只做 Host 侧,可以暂时去掉 dsh.client、exports["./client"] 和 src/client.tsx。

3.5 本地安装并验证

用 web profile 安装本地目录:

dsh plugin --profile web add E:\code\dsh-plugin-hello
dsh plugin --profile web list --depth 0

检查 profile 的组合清单:

Get-Content "$env:USERPROFILE\.dsh\profiles\web\package.json"

确认 dsh.profile.bundles 里出现了 dsh-plugin-hello。然后启动 Web:

dsh --profile web --no-open --port 8080

打开终端打印的 URL,在对话里说:

请调用 hello_greet 工具,向张三问好。

也可以直接输入:

/hello 张三

两者的区别:

  • 工具调用会经过模型:模型决定是否调用、传什么参数。
  • /hello 是命令:不产生模型消息,直接由 Host 执行并返回结果。

如果工具没有出现,先看配置树是否真的包含插件行:

dsh --profile web --dump-config

再检查 ~/.dsh/logs 和 profile 下的 .plugin-manager/logs。新增 bundle 不是热加载,安装后必须重启 profile。

四、Client 侧:给会话加一个标签页

4.1 客户端入口

import type { Context } from '@deepseek-ai/cordis'

export const name = 'dsh-plugin-hello'
export const inject = ['slots']

export function apply(ctx: Context) {
  ctx.slots.inject('conversation.view', () =>
    ctx.slots.register(
      {
        name: 'conversation.view',
        id: 'hello',
        order: 50,
        label: () => 'Hello',
      },
      () => (
        <div style={{ padding: 24 }}>
          <h3 style={{ margin: '0 0 8px' }}>Hello from dsh-plugin-hello</h3>
          <p style={{ margin: 0, opacity: 0.75 }}>
            这个面板由插件的客户端入口注册。
          </p>
        </div>
      ),
    ),
  )
}

这段代码做了三件事:

  1. 声明依赖 slots 服务。
  2. ctx.slots.inject('conversation.view', ...) 等待宿主注册这个插槽。
  3. 在插槽出现后,用 ctx.slots.register 注册一个标签页。id 必须唯一,order 控制顺序,label 是标签文本。

插槽名称、可用参数和生命周期由宿主 UI 决定。不同 DSH 版本可能新增或调整插槽;写插件时应以对应 UI 包的 README/类型为准,不要凭猜测使用内部插槽。

4.2 构建与加载

构建后确认 lib/client.js 存在,然后重启 profile 并刷新页面。Client 侧变更通常不会被已经打开的页面自动拾取;开发时可以让构建进程 watch,然后重启/刷新。

如果你在 DSH 源码仓库内开发,并且同时运行了 pnpm run dev:web,客户端插件可以利用 HMR 重载;第三方插件安装在 profile 里时,不要假设一定有同样的热更新体验,按「重新构建 → 重启 → 刷新」的稳妥流程验证。

4.3 插槽与状态

一个常见的误区是「Client 侧直接读文件、发网络请求」。这会绕过 Host 的权限和审计,也不利于跨端兼容。正确做法是:

  • Client 只负责展示和交互。
  • 需要数据时,通过 Host 暴露的 Remote 方法获取。
  • 需要写入时,同样走 Host 服务,让文件策略和审批生效。

如果只是做一个静态说明页,可以完全不请求 Host;一旦涉及会话、文件、设置或凭据,就应该走服务层。

4.4 插件详情页的配置卡片

如果插件需要图形化配置,可以注入 configForms 服务,并在 plugins.bundle.config 插槽注册卡片。dsh-context 的客户端入口就是一个完整范例:它通过 configForms.whileServed([...]) 等待插件配置可用,然后注册卡片。

这部分依赖较新的设置服务,建议先读 @deepseek-ai/dsh-client-ui-settings 和 @deepseek-ai/dsh-client-ui-settings-plugin-inventory 的 README,再决定是否使用。简单的插件可以先只提供 cordis.patch.yml 配置,等稳定后再做 UI。

五、Host 与 Client 之间:Typert RPC

5.1 为什么需要它

Client 侧运行在浏览器里,不能直接调用 Node 服务。DSH 用 Typert 做类型化远程调用:

  • Host 侧声明一个 Remote 服务和方法。
  • Typert 生成方法描述和 Zod schema。
  • Client 侧通过生成的远程客户端调用,参数和返回值在两端校验。

dsh-plugin-shop 就是一个完整例子:Host 侧有商店服务,Client 侧只能调用它暴露的九个 shop/* 方法,不能直接访问文件系统或网络。这种拆分既是架构边界,也是安全边界。

5.2 Host 侧骨架

import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'

export class HelloRemote extends TypertRemoteService {
  @Remote('ping')
  async ping(input: { name: string }) {
    return { message: `pong, ${input.name}` }
  }
}

要点:

  • 继承 TypertRemoteService,用 @Remote('方法名') 标记可被 Client 调用的方法。
  • 方法参数和返回值需要能被 schema 描述;生成器会据此产出两端的类型和校验。
  • 生成产物通常叫 typert.host.js 和 typert.remote-client.js,由 @deepseek-ai/dsh-typert-generator 生成,不要手改。
  • 生成产物通过 loader 注册,或在代码里调用 ctx.typert.register(contribution)。
  • 只暴露 Client 真正需要的方法。每多一个方法,就多一个攻击面。

装饰器需要 TypeScript 装饰器配置。不同插件的构建配置不完全一样,建议直接参考 dsh-plugin-shop 的 tsconfig、tsdown 配置和生成脚本,而不是从零猜。

5.3 Client 侧调用

Client 侧 import 生成的远程客户端,然后像调用本地异步方法一样调用:

const result = await hello.ping({ name: 'DSH' })

实际导入路径、服务名和调用方式由生成器决定。关键是:Client 只拿到声明过的方法,拿不到 Host 的任意对象。

5.4 什么时候不需要 RPC

  • 纯静态页面、说明卡片:不需要。
  • 只读取宿主已经提供的 props 或插槽上下文:不需要。
  • 需要访问会话、文件、设置、凭据、后台任务:需要,而且应该走 Host 服务。

不要为了「看起来架构完整」而给每个小组件都套一层 RPC;也不要为了省事把敏感逻辑放到 Client。

六、打包、发布与上架

6.1 发布前检查

  • package.json 的 name、version、license、repository 完整。
  • files 包含 lib、cordis.patch.yml、README 和必要的静态资源。
  • exports 的 . 和 ./client 指向真实存在的构建产物。
  • dsh.bundle.patch 指向的 YAML 能被解析。
  • peerDependencies 与当前 DSH 版本匹配。
  • 在一个干净的测试 profile 中安装、启动、卸载各一次。
pnpm build
npm publish --access public

如果包名已被占用,改用 scope,例如 @your-name/dsh-plugin-hello,并同步修改 cordis.patch.yml 里的 name。

6.2 插件市场如何发现你

dsh-plugin-shop 会从 npm 和 GitHub 收集带有 dsh-plugin、deepseek-harness 关键词/主题的包。一个包要成为可安装插件,至少要有:

  • dsh.bundle(否则它只是一个普通库)。
  • 可核验的许可证和仓库。
  • 没有会在安装时失败的生命周期脚本或不合理的 workspace: 依赖。

市场做的是机械筛选,不是人工安全审计。即使上架,也不代表代码被逐行审查过。

6.3 版本与兼容性

  • 用 semver 管理版本;破坏性改动升 major。
  • engines.dsh 写你实际测试过的范围,不要写「全版本兼容」。
  • 在 README 中写清楚支持的 DSH 版本、profile、Node 版本和外部依赖(例如 ffmpeg)。
  • 如果插件依赖某个 DSH 内部插槽或内部服务,明确标注版本范围;这些接口比公开 API 更容易变。

七、调试与排障

现象可能原因处理
--profile 缺失命令没写 profile加上 --profile web 或你的 profile 名
pnpm not found本机没有 pnpmnpm install -g pnpm
插件不在列表安装未完成,或 bundle 未写入dsh plugin --profile web list --depth 0,检查 package.json 的 dsh.profile.bundles
client bundles not found ... lib/client.js没构建 Client 侧,或 exports 路径不对补 src/client.tsx 和客户端构建配置
工具不出现Host 插件未加载,或 inject 写错dsh --profile web --dump-config,看日志,重启 profile
标签页不出现Client 侧未加载、插槽名不对、id 冲突重启并刷新,查看浏览器控制台
配置被拒绝Config 与 patch 的 config 不匹配检查字段名、类型、默认值
保存设置冲突另一个标签页或外部编辑改了 settings重新加载后再保存
peer 版本被拒插件声明的 DSH 版本与当前不符对齐版本,或按插件面板提示处理

常用命令:

# 查看已安装插件
dsh plugin --profile web list --depth 0

# 查看组合后的配置树
dsh --profile web --dump-config

# 重新启动
dsh --profile web --no-open --port 8080

日志位置:

  • ~/.dsh/logs:DSH 主日志。
  • ~/.dsh/profiles/<profile>/.plugin-manager/logs:插件管理相关日志。
  • 浏览器开发者工具:Client 侧报错、插槽注册失败、组件异常。

八、安全与质量清单

开发插件时,至少过一遍下面几项:

  1. 最小依赖:只 inject 真正需要的服务,不要为了省事把文件、网络、进程服务全拿一遍。
  2. 输入校验:工具参数依赖 schema 校验,但业务约束(长度、范围、路径)仍要在 execute 里检查。
  3. 取消与超时:把 exec.signal 传给耗时操作;外部请求设置超时,避免插件挂死整个回合。
  4. 输出上限:大结果要截断或落盘,不要把几 MB 文本直接塞回模型上下文。
  5. 不记录密钥:日志、错误信息、Client 代码里都不要出现 API Key、Token 或用户隐私。
  6. Client 侧不信任输入:浏览器侧的参数同样可能被篡改,Host 侧必须重新校验。
  7. 权限边界:能走 Host 文件策略的操作,不要绕到 Client 或裸 fetch。
  8. 测试卸载:启用、禁用、更新、卸载各走一遍,确认没有残留注册或重复 id。
  9. 版本声明:engines.dsh 和 peer 范围写实际测试过的值。
  10. 许可证与仓库:要让用户能审计你的代码,尤其是会上架市场的插件。

九、参考来源

以上结构和 API 以 2026-10-24 的 0.1.5-rc.3 核验结果为准。插件接口仍在演进,升级 DSH 后请重新阅读对应包的类型声明和 README。