在开始任何技术学习之前,最令人兴奋的时刻莫过于亲眼看到自己的第一行代码跑起来。本章将带你完成这个里程碑——创建一个最小的 Harness 插件,并把它加载到 Web UI 中。你不需要理解太多复杂的概念,只需要跟着步骤一步步操作,就能在终端里看到插件成功加载的日志输出。

在这个过程中,你会接触到 Harness 插件最核心的三个概念:插件是什么、如何注册、以及框架如何管理插件的生命周期。读完本章,你不仅拥有一个可以运行的插件,更重要的是,你将对 Harness 的插件机制建立起直观的认知。

准备工作

在开始之前,请确保你已经完成了DeepSeek Harness 从源码运行 的准备工作。简单来说,就是你已经把 DeepSeek Harness 的仓库克隆到了本地,并且能够成功运行 pnpm dsh 命令。

如果你还没有完成这一步,建议先按照官方 README 的指引操作。本章的所有操作都基于这个源码仓库的环境。

创建本地项目

首先,我们需要在仓库根目录创建一个临时项目,用来存放本章的插件代码:

mkdir -p scratch-plugin/src

这个命令会在仓库根目录下创建 scratch-plugin/src 文件夹。scratch-plugin 是我们整个插件的项目目录,src 用于存放 TypeScript 源文件。

为什么叫 scratch? “Scratch” 在编程中常指“草稿”或“临时”的意思。我们用它来命名,表示这是一个用于学习和实验的临时项目——你可以随时删除它,而不会影响 Harness 本身的代码。

插件是什么?

在动手写代码之前,先理解一个核心问题:Harness 插件本质上是什么?

在 Harness(以及它底层的 Cordis 框架)中,一个插件就是一个导出了 apply 函数的 TypeScript 模块。当框架启动时,它会加载这个模块并调用 apply 函数,同时传入一个 ctx(上下文对象)作为参数。你通过这个 ctx 来向框架注册各种能力——比如工具、事件监听器、定时任务等。

最简单的插件长这样:

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

export const name = 'my-plugin'

export function apply(ctx: Context) {
    // 在这里注册各种能力
}

这就是一个完整插件的最小结构。是的,就这么简单——一个 name 和一个 apply 函数,足矣。

创建插件文件

现在,让我们动手创建第一个真正的插件文件。在 scratch-plugin/src/ 目录下创建 my-plugin.ts:

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

export const name = 'hello-plugin'

export function apply(ctx: Context) {
    // 当插件加载时,框架会调用这个函数
    console.log('[hello-plugin] plugin loaded!')
}

这段代码做了三件事:

  1. 导入 Context 类型:从 Cordis 框架中引入上下文对象的类型定义。注意这里用了 import type,这表示我们只在 TypeScript 类型层面使用它,不会打包进最终的 JavaScript 代码——这是一种更轻量的导入方式。

  2. 导出插件名称:export const name = 'hello-plugin' 给插件起了一个唯一标识名。这个名字会在框架的日志、依赖管理和热更新中用到。

  3. 导出 apply 函数:这是插件的入口点。当框架加载你的插件时,会调用这个函数。目前我们只做了一件事——在控制台打印一条日志,证明插件确实被加载了。

给初中级开发者的提示:console.log 是最直接的调试手段。在插件开发初期,通过日志来确认加载顺序、参数值、执行流程,是非常实用且高效的调试方式。

实操细节

在 scrath-plugin 目录下创建 tsconfig.json,添加内容:

{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "composite": false,
    "noEmit": true
  },
  "include": ["src/**/*.ts", "*.ts"]
}

可消除代码编辑器中"Cannot find module…"的提示报错。

注册到 cordis.yml

插件文件写好了,但框架还不知道它的存在。我们需要通过一个配置文件告诉框架:“嘿,这里有个插件,启动的时候记得加载它!”

在 scratch-plugin/ 目录下创建 cordis.yml 文件:

- insert:
    - id: hello
      name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/my-plugin.ts'

这里有三个关键点需要注意:

第一,insert 表示我们要往框架的插件列表中“插入”一个新的插件。id: hello 是这个插件在配置层面的标识,可以和插件内部导出的 name 不同——id 是配置层的称呼,name 是代码层的标识。

第二,name 字段的值必须是绝对路径,且必须加上 file:// 协议(笔者测试环境为window)。

第三,这个 cordis.yml 文件是一个“补丁文件”(patch file)。它不会替换框架原有的配置,而是在原有配置的基础上“打补丁”——追加我们新写的插件。这种设计让你可以在不修改框架核心配置的情况下,灵活地加载本地开发中的插件。

常见错误:新手最容易犯的错误就是忘记使用绝对路径,或者路径写错了。如果启动后看不到插件的日志,请先检查路径是否正确。

启动 Web UI

一切准备就绪,现在来见证奇迹的时刻。

在仓库根目录下运行以下命令:

pnpm dsh web --patch ./scratch-plugin/cordis.yml

这个命令的含义是:启动 Harness 的 Web UI 服务,同时通过 --patch 参数加载我们刚才写的补丁配置文件。

启动成功后,你会看到终端输出一系列日志信息。在其中,你应该能找到这样一行:

[hello-plugin] plugin loaded!

恭喜你!到此为止,你的第一个 Harness 插件已经成功加载并运行了。

此时,打开浏览器访问 http://127.0.0.1:3080,你会看到 Harness 的 Web 界面。虽然我们的插件目前没有在界面上添加任何可见的元素,但它已经在后台默默地完成了加载——这就是一个最小插件所能做到的事情。

自动清理:一个值得信赖的“管家”

现在插件能跑了,但还有一个重要的问题:当插件被卸载时,我们注册的那些能力怎么办?

在很多框架中,你需要手动清理——移除事件监听、清除定时器、关闭网络连接……稍有不慎就会造成内存泄漏或残留的副作用。

但 Harness 的设计让你可以省去这些烦恼。通过 ctx 注册的任何东西——事件监听、工具、定时器——在插件卸载时都会被自动清理。你不需要手动调用 removeListener 或 clearInterval。

这背后的原理是:Cordis 框架会追踪所有通过 ctx 注册的资源,并在插件卸载时统一清理。这就像有一个尽职的管家,你离开房间时他会帮你关灯、锁门,你完全不用操心。

但如果你有需要手动清理的资源(比如一个数据库连接池、一个 WebSocket 连接),框架也提供了明确的清理入口——ctx.effect():

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

export function apply(ctx: Context) {
    ctx.effect(() => {
        const timer = setInterval(() => {
            console.log('heartbeat')
        }, 5000)

        // 返回一个清理函数,在插件卸载时执行
        return () => clearInterval(timer)
    })
}

ctx.effect() 接收一个函数,这个函数可以返回一个清理函数。当插件卸载时,框架会自动调用这个清理函数,让你有机会释放手动管理的资源。这个机制确保了你的插件在任何情况下(正常退出、热更新、甚至加载失败后的回滚)都能正确地清理自己。

声明依赖:让插件按正确的顺序加载

随着插件功能的丰富,你可能会需要用到其他服务——比如 tool(工具注册表)、llm(大语言模型服务)等。

如果你的插件依赖这些服务,就需要声明 inject 数组:

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

export const name = 'my-tool-plugin'
export const inject = ['tools']

export function apply(ctx: Context) {
    // 此时 ctx.tools 已经就绪,可以安全使用
    ctx.tools.register(/* ... */)
}

声明了 inject=['tools'] 之后,框架会确保 tools 服务已经就绪才会加载你的插件。如果 tools 服务加载失败,你的插件也不会被加载——这避免了“依赖缺失”导致的运行时错误。

给初中级开发者的提示:依赖声明是一种“契约”。你告诉框架“我需要什么”,框架保证“在你开始工作之前,这些东西都准备好了”。这种设计让插件之间的协作变得清晰而可靠。

插件的三种形态

到目前为止,我们使用的都是函数形式的插件——直接导出一个 apply 函数。这是最常用、最简洁的方式。

但 Harness 还支持另外两种形态:

对象形式
import type { Context } from '@deepseek-ai/cordis'

export default {
    name: 'my-plugin',
    inject: ['tools'],
    apply(ctx: Context) {
        // ...
    },
}

对象形式其实就是把函数形式的各个导出项(name、inject、apply)整合到一个对象里,通过 export default 导出。这种写法在需要默认导出而非命名导出时比较方便。

类形式
import { Service, type Context } from '@deepseek-ai/cordis'

export default class MyService extends Service {
    static inject = ['tools']

    constructor(ctx: Context) {
        super(ctx, 'myService')
        // 在构造函数中执行同步初始化
    }
}

类形式最为强大——当你的插件需要向其他插件提供服务时,使用类形式。通过继承 Service 类,你的插件本身就成了一个可被其他插件依赖的服务。

给初中级开发者的建议:在刚开始学习时,优先使用函数形式即可。它足够简单,也足够强大。等你需要构建更复杂的插件体系时,再逐步探索对象形式和类形式。

总结

回顾一下本章我们完成的内容:

步骤

做了什么

核心知识点

创建项目

mkdir -p scratch-plugin/src

插件代码放在独立的目录中

编写插件

导出 name 和 apply 函数

 插件是导出 apply 的模块

注册插件

创建 cordis.yml 补丁文件

通过 --patch 加载本地插件

启动验证

pnpm dsh web --patch 补丁文件

日志中看到 plugin loaded!

你还接触到了三个重要的框架机制:

  • 自动清理:通过 ctx 注册的资源会自动清理,无需手动管理。

  • ctx.effect():为需要手动清理的资源提供明确的清理入口。

  • 依赖声明:通过 inject 确保依赖服务就绪后再加载插件。

你已经成功写出了第一个 Harness 插件,并且理解了插件加载的完整流程。这是一个很好的开始——接下来的章节中,我们会在这个基础上让插件变得越来越“有本事”:给它添加工具、让它接受配置、最终把它打包成一个可分发的组合包。

下一章,我们将学习如何为你的插件添加能够被大语言模型调用的工具能力。