Back to home@liangdabiao

dsh-weather-plugin

dsh = 一台"所有零件都能换"的智能体机器。写插件 = 造一个零件装上去。本项目展示怎样开发一个天气插件:天气插件的设计:Node 端调 Open-Meteo 拿温度风力,浏览器端用这些数据画一张会动的天气卡片。

Stars
2
Language
TypeScript
Created
Sep 1, 2026
Updated
Sep 1, 2026

Introduction

从 0 到 1 开发 dsh 插件实战课

以「查天气 + 动效卡片」插件为完整案例,带你走完一个真实 dsh 插件的全部开发流程:需求 → 环境 → 骨架 → 实现(工具/技能/系统提示/浏览器端)→ 双端构建 → 安装测试 → 界面验收 → 发布维护。


写在前面

这门课教什么

DeepSeek Harness(简称 dsh)是一个"万物皆插件"的智能体框架。给 dsh 增加任何新能力——让模型会查天气、会调用你的系统、会画出好看的界面——本质上都是写一个插件

这门课以一个真实的天气插件(我们已经在 dsh 网页里跑通、显示过天气卡片的项目)为蓝本,手把手带你从零写出、构建、安装并测试一个插件。你最终会得到:

  • 输入"北京今天天气怎么样",模型自动调用 weather 工具;
  • 工具调用 Open-Meteo 天气 API 拿到真实数据(不是编的);
  • 模型按 weather-briefing 技能规范播报:"北京今天晴天 ☀️ 29°C 东风 6km/h……";
  • 在 dsh 网页界面里,渲染出一张会动的天气卡片(☀️ 旋转光芒、🌧 下落雨线、❄️ 飘落雪花……)。

这门课适合谁

  • 完全没接触过 dsh,但会一点前端/Node 的开发者;
  • 想给自己的智能体(Agent)增加自定义工具和界面的产品/技术人员;
  • 想理解"插件化智能体框架"到底怎么回事的人。

怎么学效果最好

  1. 跟着做:每一章的代码和命令都自己敲一遍,别只看。
  2. 看真实报错:这门课记录了我们开发时真实踩过的坑和报错原文,遇到类似问题直接查附录 C。
  3. 遇到卡点先自查:先看是不是 Node 版本、junction 顺序、产物没同步这类"高频三坑"。

第 1 部分 · 认识 dsh

第 1 章 dsh 是什么:一个「万物皆插件」的智能体框架

1.1 传统智能体 vs dsh

传统做法:模型、工具、对话循环、网页界面……全部"焊死"在一个程序里。想加一个能力,就得改源码、重新编译、重新发布。这对第三方开发者非常不友好。

dsh 换了一种哲学——everything-is-a-plugin(万物皆插件)

  • dsh 的核心几乎是一个"空壳"(无特权内核);
  • 几乎所有能力——模型适配器、工具注册表、Agent 循环、网页界面——本身都是插件
  • 所有插件挂在一个叫 Cordis 的容器框架上,按需组合、替换、扩展。

一句话:dsh = 一台"所有零件都能换"的智能体机器。写插件 = 造一个零件装上去。不需要 fork 源码。

1.2 你的插件挂在哪里

当你写一个天气插件时,你实际上是在往这台机器上加三样东西:

东西类比作用
工具(Tool)给模型"手"模型可以调用的动作:查天气、查数据、算东西
技能(Skill)给模型"说明书"教模型"按什么规范产出":天气怎么播报、文案怎么写
系统提示给模型"入职培训"教模型"什么时候该用哪个工具"

三样配合,模型才能"会查 → 会播报 → 播得好"。

1.3 一个插件通常写"两端"

dsh 网页端的插件往往是双端的:

  • Node 端(后端进程,可信环境):负责"干活"——注册工具、调 API、算数据;
  • 浏览器端(前端沙箱 iframe,受限环境):负责"画画"——把工具结果渲染成卡片、图表、动效。

两端通过 meta 数据衔接:Node 端算好结构化数据放进 meta,浏览器端读取并渲染。

这正是我们天气插件的设计:Node 端调 Open-Meteo 拿温度风力,浏览器端用这些数据画一张会动的天气卡片。


第 2 章 一个 dsh 插件的生命周期(全景图)

写一个插件不是只写代码,而是一条完整流水线。全程共 7 步,本课程后续章节逐一展开:

① 选型 → ② 环境 → ③ 骨架 → ④ 实现 → ⑤ 构建 → ⑥ 安装 → ⑦ 测试

第 ① 步 选型:这是哪一类插件?

动手前先回答:"用户要加的新能力属于哪一类?" dsh 插件分四种(详见第 15 章):

类型解决什么什么时候用
Tool Plugin让模型拥有新动作90% 的需求,比如查天气、查数据库、调 API
Skill 插件教模型按规范产出想让输出风格统一、结构固定
Service Provider换底层驱动换模型网关、换文件沙箱
Event Interceptor关键路径加料审批、审计、限流
Agent Loop重写核心循环高难定制,一般用不到

选型口诀:Provider 是"换驱动",Tool 是"装软件",Interceptor 是"加关卡",AgentLoop 是"换引擎"。 我们的天气插件 = Tool Plugin(工具)+ Skill 插件(技能)+ 系统提示 + 浏览器端渲染,是 dsh 插件最典型的组合。

第 ② 步 环境准备

  • Node.js 必须 22+(dsh 0.1.x 用到 Promise.withResolversnode:zlib zstd、node:module stripTypeScriptTypes,Node 20 必崩);
  • 能运行 dsh 命令;
  • 定位到 dsh 发布包缓存(构建时要用它里面的宿主依赖,见第 11 章)。

第 ③~④ 步 骨架与实现

用脚手架一键生成项目骨架,然后填充四块核心代码:工具、技能、系统提示、浏览器端。

第 ⑤ 步 双端构建

用 tsdown 一次构建出两个产物:lib/index.js(Node 端)+ lib/client.js(浏览器端单文件)。

第 ⑥ 步 安装进 profile

dsh 用 profile(配置档案)管理插件集合。dsh plugin --profile 名字 add file:路径 即可把插件装进去。

第 ⑦ 步 测试与验收

  • headless 冒烟:命令行问一句,验证"识别意图 → 调工具 → 播报"全链路;
  • web 界面验收(最终标准):浏览器里看到工具调用 + 动效卡片渲染。

重要提醒:命令行跑通 不算完成。用户真正看到的是网页界面。所以最终验收必须在浏览器里做。这也是本课程贯穿始终的标准。


第 2 部分 · 环境与骨架

第 3 章 环境准备:Node 22 是硬门槛

3.1 我们真实踩到的坑

开发天气插件时,第一次启动 dsh 直接崩了,报错类似:

TypeError: Promise.withResolvers is not a function
TypeError: zlib.createZstdDecompress is not a function
TypeError: module.stripTypeScriptTypes is not a function

原因:dsh 0.1.x 必须在 Node 22+ 上运行,而我们系统里的 node 是 v20.20.2。Node 20 缺少这些 API。

3.2 dsh 到底用了哪些 Node 22 新能力

Node 22 新能力dsh 用来干什么
Promise.withResolvers标准库新增的 Promise 构造方式,dsh 内部异步逻辑用到
node:zlibcreateZstdDecompress解压 zstd 压缩的数据
node:modulestripTypeScriptTypes让 dsh 能直接加载/剥离 TS 类型

这些在 Node 20 里都不存在,所以不是配置问题,是版本问题

3.3 怎么解决

方法一(推荐):升级 Node 到 22+。官网下载 LTS 版(22.x)安装即可。

方法二(本机多版本):如果你系统里同时有多个 node,直接用 Node 22 的 node.exe 跑 dsh,不污染全局:

# 找到 node22 的 node.exe,直接调 dsh 的入口文件
C:\Users\49707\node22\node-v22.23.2-win-x64\node.exe C:\Users\49707\AppData\Local\npm-cache\_npx\<hash>\node_modules\@deepseek-ai\dsh\lib\bin.js --profile web

方法三:把 node22 目录放到 PATH 的最前面,这样 dshnodenpx 都优先用 Node 22。

验证node -v 必须输出 v22.x 及以上。

连带坑:构建工具 tsdown 0.22+ 也要求 Node 22。如果只能在 Node 20 环境构建,请用 tsdown@^0.19.0(我们就是这么降级的)。

3.4 一键脚本的完整流程

环境确认后,构建脚本按这个顺序工作(见 scripts/build.ps1):

# ① 安装构建工具(tsdown/typescript)
npm install --legacy-peer-deps
# ② 链宿主依赖(junction,必须放在 npm install 之后!)
python scripts/link_deps.py
# ③ 双端打包
npx tsdown
# ④ 自检产物
# 检查 lib/index.js 与 lib/client.js 是否生成

为什么 link_deps 必须放在 npm install 之后? 因为 npm install 会把 node_modules 里的链接清掉重新装,如果在它之前建链接,会被冲掉。这是开发时反复踩的坑(详见第 11 章)。


第 4 章 认识插件骨架:每个文件是干什么的

4.1 用脚手架一键生成

不用手动搭文件,用 skill 自带的脚手架脚本:

python scripts/scaffold_plugin.py D:/dev/weather-plugin --name @me/weather-plugin --desc "查天气 + 动效卡片"

它会自动复制模板、替换包名和描述占位符,生成一个结构完整、可直接开始改代码的项目。

4.2 项目结构逐文件解读(真实 API 版)

weather-plugin/
├─ package.json          # 插件"身份证":入口/依赖/清单字段
├─ cordis.patch.yml      # 挂载声明:把插件插进 profile 的 layer 栈
├─ tsdown.config.ts      # 双端打包配置
├─ tsconfig.json         # Node 端 TS 配置
├─ tsconfig.client.json  # 浏览器端 TS 配置(lib: DOM)
├─ scripts/
│  ├─ build.ps1          # 一键构建(Windows)
│  ├─ link_deps.py       # junction 链宿主依赖
│  └─ sync_profile.py    # 构建后同步产物到已安装的 profile
└─ src/
   ├─ index.ts           # 插件入口 apply + 配置 Config
   ├─ tool.ts            # 工具定义(defineTool)
   ├─ skill.ts           # 技能定义(SkillProvider)
   ├─ fragment.ts        # 共享契约纯函数(双端共用)
   └─ client/
      └─ index.tsx       # 浏览器端 Toolview 组件

4.3 package.json 里的关键字段(容易看懵)

dsh 插件不用 dsh.plugin.json(那是旧版资料的说法),而是在 package.json 里声明:

{
  "name": "@me/weather-plugin",
  "type": "module",
  "main": "lib/index.js",
  "exports": {
    ".": "./lib/index.js",
    "./client": "./lib/client.js",
    "./cordis.patch.yml": "./cordis.patch.yml"
  },
  "dsh": {
    "bundle": { "patch": "./cordis.patch.yml" },
    "client": { "inject": ["@deepseek-ai/dsh-client-runtime"], "platform": "web" }
  },
  "dshx": {
    "contributes": { "tools": ["weather"], "skills": ["weather-briefing"] }
  },
  "peerDependencies": {
    "@deepseek-ai/cordis": "^4.0.1-rc.1",
    "@deepseek-ai/dsh-tools": "*",
    "@deepseek-ai/dsh-skill": "*",
    "@deepseek-ai/dsh-system-prompt": "*",
    "@deepseek-ai/schemastery": "^3.18.1-rc.1",
    "react": "^18.2.0"
  }
}
字段干什么
dsh.bundle.patch指向 cordis.patch.yml,告诉 dsh 怎么挂载插件
dsh.client.inject / dsh.client.platform浏览器端注入声明(web 平台)
dshx.contributes.tools/skills插件提供的工具/技能清单(发现用)
peerDependencies声明依赖的宿主包(不打包,运行时由 dsh 提供)

4.4 挂载声明 cordis.patch.yml

- insert:
    - id: weather-plugin      # 插件实例 id
      name: '@me/weather-plugin'   # npm 包名

这个文件让 dsh 启动时知道"要加载一个叫 weather-plugin 的插件",并把它挂进当前 profile 的 layer 栈。


第 3 部分 · 开发核心

第 5 章 插件入口 apply 与配置 Config

5.1 一切的起点:index.ts

每个插件都有一个入口文件 src/index.ts,导出 nameinjectConfigapply 函数。dsh 启动时加载插件,调用 apply(ctx, config),把容器上下文 ctx 传给你。

import type { Context as CordisContext } from '@deepseek-ai/cordis'
import type SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type SkillService from '@deepseek-ai/dsh-skill'
import type ToolRegistry from '@deepseek-ai/dsh-tools'
import z from '@deepseek-ai/schemastery'
import { weatherTool } from './tool.js'
import { weatherSkillProvider } from './skill.js'
import type { WeatherClientConfig } from './client.js'

// 声明"这个插件要用哪些服务",并给出合并后的 ctx 类型
type Context = CordisContext & {
  tools: ToolRegistry
  systemPrompt: SystemPrompt
  skills: SkillService
}

export const name = 'weather-plugin'          // 插件唯一 id
export const inject = ['tools', 'systemPrompt', 'skills']  // 依赖的服务

export interface Config extends WeatherClientConfig {}

export const Config: z<Config> = z.object({
  baseUrl: z.string().default('https://api.open-meteo.com/v1/forecast')
    .description('Open-Meteo 天气 API 基地址;可指向自建镜像做离线开发。'),
  geocodingUrl: z.string().default('https://geocoding-api.open-meteo.com/v1/search')
    .description('Open-Meteo 地理编码 API 基地址。'),
  timeoutMs: z.number().step(1).min(1_000).default(10_000)
    .description('单次天气请求超时(毫秒)。'),
})

export function apply(ctx: Context, config: Config): void {
  // 把配置收敛成一个运行时对象(带默认值兜底)
  const resolved: WeatherClientConfig = {
    baseUrl: config.baseUrl ?? 'https://api.open-meteo.com/v1/forecast',
    geocodingUrl: config.geocodingUrl ?? 'https://geocoding-api.open-meteo.com/v1/search',
    timeoutMs: config.timeoutMs ?? 10_000,
  }

  // 三件套都包在 ctx.effect 里注册:插件被移除时自动清理
  ctx.effect(() => ctx.tools.register(weatherTool(resolved)), 'weather-plugin.tool')
  ctx.effect(() => ctx.skills.registerProvider(() => weatherSkillProvider), 'weather-plugin.skill')
  ctx.effect(() => ctx.systemPrompt.section({
    name: 'tool:weather',
    order: 117,
    text: PROMPT_TEXT,
  }), 'weather-plugin.prompt')
}

5.2 逐块拆解

部分说明
export const name插件唯一 id,必须和 cordis.patch.yml 的 id 一致
export const inject声明依赖的服务。ctx 就能访问 .tools / .skills / .systemPrompt
export const Config用 schemastery 定义配置 schema,带默认值 + 中文说明。用户可在 profile/补丁层覆盖,不用改代码
export function apply真正干活的地方:注册工具、注册技能、注入系统提示
ctx.effect(fn, key)把注册动作挂到插件生命周期,卸载自动清理("插头的保险丝")

为什么用 ctx.effect 如果插件被移除/停用,effect 会自动注销它注册的工具、技能、系统提示,不会留下"幽灵注册"污染容器。这是 dsh 插件规范写法。


第 6 章 写工具:让模型「会查天气」(defineTool)

6.1 工具的本质

工具 = 给模型看的"可执行函数签名" + 你写的 execute 实现。

  • 模型看到的是 descriptionparameters(决定它会不会调、怎么调);
  • 模型调用后,你的 execute 去干活(这里就是查天气 API);
  • 你返回的数据,一部分给模型看(短文本),一部分给浏览器渲染(结构化数据)。

6.2 天气数据源:Open-Meteo(免 key)

我们用的天气 API 是 Open-Meteo,完全免费、无需注册 API key:

  • 地理编码:把城市名转成经纬度
    GET https://geocoding-api.open-meteo.com/v1/search?name=北京&count=1&language=zh
    
  • 实时天气:按经纬度拿天气
    GET https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..&current_weather=true&daily=..&timezone=auto&forecast_days=1
    

6.3 defineTool 四要素

真实 API 用 defineTool(...) 产出工具定义,再 ctx.tools.register(...) 注册:

import { defineTool, type ToolDefinition } from '@deepseek-ai/dsh-tools'
import { normalizeItems, summarize, myMetaFrom } from './fragment.js'

export function weatherTool(config: WeatherClientConfig, fetchImpl?: typeof fetch): ToolDefinition {
  return defineTool({
    name: 'weather',
    description: '查询指定城市的实时天气。当用户询问天气、温度、雨雪、风力时使用。',
    parameters: {
      city: { type: 'string', required: true, description: '城市名,例如:北京、上海、London' },
      unit: { type: 'string', enum: ['celsius', 'fahrenheit'], description: '温度单位,默认摄氏度' },
    },
    output: {
      schema: { /* ... */ },
      render: (_args, value) => [{ type: 'text', text: value.text }],
      presentationMeta: (_args, value) => ({ kind: 'weather', ...value.weather }),
    },
    isConcurrencySafe: () => true,
    async execute(args) { /* 查天气,返回 { text, weather } */ },
    presentCall: () => ({ card: 'generic', title: '查天气', kind: 'other' }),
    presentResult() { /* 结果卡片标题 */ },
  })
}

四要素详解:

要素作用写作要点
name工具名蛇形命名,唯一
description说明模型决定调不调它全靠这句。写清"做什么 + 何时用 + 注意"
parameters参数 Schema手写 JSON Schema;每个参数带 description,否则模型不知道传什么
output返回说明render 定模型看到的文本;presentationMeta 定浏览器渲染的数据

6.4 execute 里做了什么(核心逻辑)

async execute(args) {
  const city = typeof args.city === 'string' ? args.city.trim() : ''
  if (city === '') throw new Error('weather: city 是必填参数')

  // ① 地理编码:城市名 → 经纬度
  const geo = await fetchImpl_(`${config.geocodingUrl}/search?name=${encodeURIComponent(city)}&count=1&language=zh`)
  const geoData = await geo.json()
  const hit = geoData.results?.[0]
  if (!hit) throw new Error(`weather: 找不到城市「${city}」,请换英文名试试`)

  // ② 实时天气:经纬度 → 天气数据
  const unit = args.unit === 'fahrenheit' ? 'fahrenheit' : 'celsius'
  const forecast = await fetchImpl_(`${config.baseUrl}?latitude=${hit.latitude}&longitude=${hit.longitude}&current_weather=true&daily=temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=1`)
  const data = await forecast.json()

  // ③ 用共享契约把原始数据规整成 WeatherInfo
  const info = parseWeather(hit.name, data, unit)

  // ④ 返回:text 给模型看(短),weather 结构化数据进 meta 给浏览器
  return { text: summarize(info), weather: info }
}

6.5 output 与 presentationMeta 分离(核心套路)

这是 dsh 插件最重要的设计套路

output: {
  schema: {
    type: 'object', additionalProperties: false,
    properties: {
      text: { type: 'string', required: true },      // 给模型的一句话
      weather: { type: 'object', additionalProperties: true, required: true }, // 结构化数据
    },
  },
  render: (_args, value) => [{ type: 'text', text: value.text }],
  presentationMeta: (_args, value) => ({ kind: 'weather', ...value.weather }),
}
  • text(模型看到的):要。模型还要把它写进自己的回复,太长挤占上下文。例:北京当前晴天,29°C,20~29°C,6km/h 东风。
  • presentationMeta(浏览器用的):可序列化的结构化数据(城市、天气类型、温度、风力、温度范围……),dsh 把它写进持久化 meta不占模型上下文;浏览器端从 block.meta 读回渲染卡片,回放也一致。

结果:用户看到的是"模型一句话播报 + 浏览器一张精美动效卡片",而模型上下文里只有一句话,省 token、渲染富、两端职责分离。

6.6 错误处理

两种失败要区别对待:

// 参数错误 → 直接 throw(让模型知道这次调用无效,自行修正)
if (city === '') throw new Error('weather: city 是必填参数')

// 业务失败(找不到城市、外部服务报错)→ 抛出带信息的错误,让模型读得懂并调整策略
if (!hit) throw new Error(`weather: 找不到城市「${city}」,请换英文名试试`)

原则:参数/调用错误抛异常,业务失败返回结构化结果。别把"外部服务挂了"当成"工具坏了"抛给模型。


第 7 章 共享契约 fragment:双端一致的秘密

7.1 问题:同一份数据,两端各算一套?

Node 端把天气数据写进 meta,浏览器端要读它渲染。如果两端各写各的解析逻辑,很容易不一致:Node 端说 WMO 天气码 0 是"晴",浏览器端以为是"阴",卡片就画错了。

解法:把"数据怎么算/怎么解析"抽成纯函数契约模块 src/fragment.ts,Node 端、浏览器端、测试三方 import 同一个函数

7.2 fragment.ts 里有什么

// 双端共用的纯函数:不 import 任何 @deepseek-ai 包,Node/浏览器都能打包

/** WMO 天气码 → 动效类型 + 中文描述 */
export function weatherTypeFromCode(code: number): { type: WeatherType; zh: string } {
  if (code === 0) return { type: 'sunny', zh: '晴' }
  if (code >= 1 && code <= 3) return { type: 'partly', zh: '多云' }
  if (code >= 45 && code <= 48) return { type: 'fog', zh: '雾' }
  if (code >= 51 && code <= 67) return { type: 'rain', zh: '降雨' }
  if (code >= 71 && code <= 77) return { type: 'snow', zh: '降雪' }
  if (code >= 95) return { type: 'storm', zh: '雷暴' }
  return { type: 'cloudy', zh: '阴' }
}

/** 把 Open-Meteo 原始数据规整成渲染/展示用的 WeatherInfo */
export function parseWeather(city: string, data: unknown, unit: string): WeatherInfo { /* ... */ }

/** 给模型看的一句话摘要 */
export function summarize(info: WeatherInfo): string {
  return `${info.city}当前${info.zh},${info.temp}°${unitChar},${info.low}~${info.high}°${unitChar},${info.windSpeed}km/h ${info.windDir}。`
}

/** 从持久化 tool/result meta 还原结构化数据(Node 写入、浏览器读回) */
export function weatherMetaFrom(meta: unknown): WeatherInfo | undefined { /* ... */ }

7.3 为什么这设计很值

  1. 一致性:同一份映射、同一份解析,双端必然一致;
  2. 可测试:纯函数不碰网络、不碰 DOM,是最便宜、最该多写的测试层;
  3. 省 token:规则说明(如天气码映射)还能被模型直接引用,省得重复写。

经验:把"所有会算数据的逻辑"都往 fragment 里放,是 dsh 插件的最佳实践。


第 8 章 写技能:教模型「怎么播报天气」(SkillProvider)

8.1 技能是什么

技能 = 给 AI 的"写作规范手册"。 工具给 AI"手"(动作),技能给 AI"说明书"(怎么做、按什么规范产出)。

我们的天气技能 weather-briefing 就干一件事:教模型拿到天气数据后,按什么结构、什么语气播报

8.2 真实 API:SkillProvider(list + get)

当前版本的技能是 SkillProvider { name, list(), get() } 两级加载(旧资料里的 fetchCandidates/load 已经变了):

import {
  BUNDLED_SKILL_RANK,
  type SkillCandidate, type SkillDefinition, type SkillProvider,
} from '@deepseek-ai/dsh-skill'

const PROVIDER_NAME = 'weather-plugin'
const INVOCATION = { modelInvocable: true, userInvocable: true } as const

const CANDIDATES: SkillCandidate[] = [{
  name: 'weather-briefing',
  description: '向用户播报天气时使用:固定结构、语气自然、附一条实用建议。'
    + '在调用 weather 工具拿到实时数据后,按此规范组织播报。',
  invocation: INVOCATION,
  provider: PROVIDER_NAME,
  source: 'bundled',
  rank: BUNDLED_SKILL_RANK,
  locator: 'weather-briefing',
}]

const BODY = `# 天气播报规范
1. 先报城市与天气类型(如:北京当前晴天)。
2. 再报温度与体感,单位与用户要求一致(摄氏度/华氏度)。
3. 补充风力(风速+风向)。
4. 有高低温时附上今日范围。
5. 结合天气给一句实用建议(雨天带伞、降温加衣、雪天慢行),不超过 10 字。
6. 只依据 weather 工具返回的实时数据,不编造预报。`

export const weatherSkillProvider: SkillProvider = {
  name: PROVIDER_NAME,
  list: () => Promise.resolve(CANDIDATES),   // 候选清单:轻量,常驻
  async get(candidate): Promise<SkillDefinition> {
    return {
      name: candidate.name,
      description: candidate.description,
      invocation: candidate.invocation,
      provider: PROVIDER_NAME,
      source: 'bundled',
      rank: candidate.rank,
      content: BODY,   // 完整规范:重,触发才加载
    }
  },
}

8.3 两级加载的设计用意

内容何时进上下文
list() 候选name + description(轻)常驻,模型据此判断是否触发
get() 正文完整 content(重)仅当模型决定使用该技能时

为什么要这样? 模型上下文是稀缺资源。如果把所有技能的完整规范都常驻,上下文立刻爆炸。所以:候选精炼常驻、正文按需加载。

8.4 技能正文就是"创作契约"

写技能正文 = 写清楚产出物的结构、必含要素、格式、语气、边界。要点:

  1. 结构契约:固定顺序(先城市 → 温度 → 风力 → 范围 → 建议);
  2. 必含要素:列出缺一不可的(单位一致、高低温范围);
  3. 格式契约:字数、语言;
  4. 边界:明确"不做什么"——我们写了"只依据工具实时数据,不编造预报",这是防止模型幻觉的关键。

在 apply 里注册:ctx.effect(() => ctx.skills.registerProvider(() => weatherSkillProvider), 'weather-plugin.skill')


第 9 章 系统提示:引导模型「何时用工具」

9.1 为什么需要系统提示

工具 description 通常写得较长,模型在长对话里不一定记得用它。系统提示是常驻的,加一小段就能持续引导:"遇到天气问题 → 调用 weather 工具 → 按技能播报"。

9.2 真实 API:ctx.systemPrompt.section

注意签名是传一个对象 { name, order, text }(不是三个参数):

const PROMPT_TEXT = `## Query weather (weather)
Use the \`weather\` tool when the user asks about the weather, temperature,
rain/snow, or wind for any city (for example "北京天气怎么样" or
"will it rain in London tomorrow morning?"). Pass the city name in \`city\`.
The tool returns real-time conditions as an animated weather card; report it
following the weather-briefing skill. Only pass \`unit\` when the user asks
for Fahrenheit.`

ctx.effect(() => ctx.systemPrompt.section({
  name: 'tool:weather',   // 段落唯一 id
  order: 117,             // 排序:小的靠前
  text: PROMPT_TEXT,      // 段落正文
}), 'weather-plugin.prompt')

9.3 order 的作用

系统提示由多个插件各自追加,顺序有讲究:通用规则在前、具体约定在后order 小的靠前,约定类段落给较大 order 放后面(我们用 117),避免干扰模型对通用指令的理解。

9.4 写好工具/系统提示的"三件套"

模型只通过文字理解工具。 写 description 的黄金模板:

  1. 做什么查询指定城市的实时天气。
  2. 何时用当用户询问天气、温度、雨雪、风力时使用。
  3. 何时不用/注意仅当用户要求华氏度时才传 unit 参数。

实战验证:weather 的系统提示 + description 按"三件套"写,浏览器实测模型一次就准确调用了 weather 工具。

9.5 系统提示 vs 技能:怎么分

维度系统提示技能
加载常驻(始终在上下文)按需触发加载
体量宜短可长
用途引导"何时用工具/遵守约定"提供"怎么写/怎么组织"的完整规范

原则:全局的、简短的 → 系统提示;局部的、详细的 → 技能。别把长规范塞进系统提示。


第 10 章 浏览器端:把数据画成动效卡片

10.1 浏览器端在架构里的位置

Node 端负责"干活"(调 API、算数据),浏览器端负责"画画"(渲染界面)。两端通过持久化 meta 衔接:

工具 presentationMeta ──► block.meta(持久化)──► 浏览器端 Toolview ──► 用户看到的动效卡片

浏览器端代码最终被打包成 lib/client.js,由 dsh 以 /plugins/weather-plugin/client.js 提供,在沙箱 iframe 里运行。

10.2 注册方式:slots 注入 keyed Toolview

真实 API 是 ctx.slots.inject('tool.call.toolview', ...) + ctx.slots.register({name, key}, Component)(旧资料的 registerView 已经变了):

import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import { weatherMetaFrom } from '../fragment'

export const name = 'weather-plugin'
export const inject = ['slots']

function WeatherCardView({ callId, block }: ToolCallViewProps) {
  // ① 运行中:block 还没有 kind,显示 loading
  if (!('kind' in block)) {
    return <div className="wea-load">加载天气中…</div>
  }
  // ② 失败
  if (block.isError) {
    return <div>天气查询失败</div>
  }
  // ③ 成功:从持久化 meta 还原数据,渲染动效卡片
  const info = weatherMetaFrom(block.meta)
  if (info === undefined) return <div>无天气数据</div>
  return <WeatherCard info={info} />
}

export function apply(ctx: ClientContext): void {
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
    { name: 'tool.call.toolview', key: 'weather' },  // key = 工具名
    WeatherCardView,
  ))
}

10.3 三种状态判断(重要)

组件拿到的 block 有三种形态:

形态判断方式渲染什么
运行中(pending)!('kind' in block)loading 动画
失败block.isError错误文本
成功有 kind,从 block.meta 还原结果卡片

别漏掉:运行中的 block 没有 kind 字段,直接读 block.meta 会拿不到数据。

10.4 动效卡片:纯 CSS,零外部依赖

天气卡片用纯 CSS keyframes 实现 6 种天气动画,不引任何 CDN(沙箱 CSP 也禁止外部脚本):

天气动效
☀️ 晴太阳核心 + 旋转的虚线光芒
⛅ 多云漂浮移动的云朵
🌫 雾平移的雾带
🌧 降雨周期性下落、淡出的雨线
❄️ 降雪飘落 + 旋转的雪花
⛈ 雷暴闪烁的闪电

卡片还显示:城市、温度、体感、风向风速、今日温度范围、"白天/夜间"、更新时间。

硬约束:浏览器产物必须单文件、无动态 import、无多 chunk,不能 import Node 内置模块。这些由构建自检把关(见第 11 章)。


第 4 部分 · 构建与测试

第 11 章 双端构建:tsdown + junction + ModuleLoader

11.1 构建的目标

一次构建产出两个文件:

  • lib/index.js —— Node 端(工具/技能/系统提示),ESM + 类型声明;
  • lib/client.js —— 浏览器端(Toolview),CJS 单文件,带 ModuleLoader 包装。

11.2 junction:宿主依赖从哪来

插件要 import @deepseek-ai/* 宿主包,但不打包它们(运行时由 dsh 提供)。这些包在 dsh 发布包的 node_modules 里就有。

junction(Windows 目录联接) 把它们链进插件的 node_modules,保证和运行的 dsh 完全同版本

# scripts/link_deps.py(节选)
NODE_DEPS = ["cordis", "cosmokit", "schemastery",
             "dsh-tools", "dsh-skill", "dsh-system-prompt", ...]
CLIENT_DEPS = ["dsh-client-runtime", "dsh-client-ui-tool", "dsh-client-ui-slots", ...]

# 用 cmd 的 mklink /J 建目录联接(不需要管理员权限)
subprocess.run(["cmd", "/c", "mklink", "/J", dst, src], check=True)

为什么不用 npm install 装一份? 会装到 registry 上的另一个版本,和运行的 dsh 不一致,类型和运行时都可能错位。junction 直连 dsh 自带的包最稳。

高频坑npm install 会把 node_modules 里的链接清掉重装,所以 link_deps.py 必须放在 npm install 之后跑(build.ps1 已按此编排)。

11.3 tsdown 双端配置

// tsdown.config.ts(要点)
export default [
  {
    entry: { index: 'src/index.ts' },
    outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
    dts: true, clean: true,
    deps: { neverBundle: ['@deepseek-ai/schemastery', '@deepseek-ai/cordis'] },
  },
  {
    entry: { client: 'src/client/index.tsx' },
    outDir: 'lib', format: 'cjs', platform: 'browser',
    outputOptions: {
      entryFileNames: 'client.js',
      inlineDynamicImports: true,   // 强制单文件
      banner: `window.__ModuleLoader__.load({ id: "weather-plugin", factory: (require) => {`,
      footer: `return module.exports; } });`,
      intro: 'var module = { exports: {} }; var exports = module.exports;',
    },
  },
]

关键点:

  • neverBundle:schemastery 和 cordis 不打包,因为 dsh 的 Loader 要校验你的 Config schema,必须看到它自己的实例
  • 浏览器端 format: 'cjs' + inlineDynamicImports:产出一个 CJS 文件;
  • banner/footer:把产物包进 window.__ModuleLoader__.load({ id, factory }),这是 dsh 网页端加载插件浏览器端的方式。

从旧 API 迁移最易漏的一环:忘写 ModuleLoader 包装,前端会报 __ModuleLoader__ is not a function

11.4 产物自检(三大硬约束)

构建后自检 lib/client.js

  1. require 白名单:只允许 import react@deepseek-ai/dsh-client-* 等平台模块;
  2. 无动态 import:不允许 import(
  3. 无多 chunk:必须是单文件。

任一不满足即报错,保证浏览器产物能在 CSP 沙箱安全加载。

11.5 build.ps1 全流程

# ① 装构建工具
npm install --legacy-peer-deps
# ② 链宿主依赖(junction,必须在 install 之后)
python scripts/link_deps.py
# ③ 双端打包
npx tsdown
# ④ 自检
# 检查 lib/index.js 与 lib/client.js 是否生成

第 12 章 安装进 profile 与 headless 冒烟测试

12.1 安装:三行命令

dsh 用 profile(配置档案)管理插件。我们装进两个 profile:web(网页端)和 headless(命令行端)。

dsh plugin --profile web add file:D:/dsh-openmaic-main/weather-plugin
dsh plugin --profile headless add file:D:/dsh-openmaic-main/weather-plugin

这条命令会自动把插件(含 cordis.patch.yml 的 insert)写进 profile 的 package.json → dsh.profile.bundles,dsh 启动该 profile 时按 layer 顺序加载。

12.2 headless 冒烟(最快验证全链路)

不需要浏览器,命令行直接问:

dsh --profile headless "北京今天天气怎么样"

我们真实得到的输出

北京今天晴天 ☀️
气温:当前 29°C,体感较热,全天在 20~29°C 之间
风力:东风,风速约 6 km/h,风很小

白天太阳很足、午后偏热,出门记得防晒补水;早晚 20°C 左右稍凉,可以带件薄外套。

再测一个城市:

dsh --profile headless "上海今天天气怎么样"
# → 上海当前阴天 ☁️ 29°C 东风 11km/h

这证明了什么

  1. 模型识别了"查天气"的意图 → 调用了 weather 工具
  2. 工具真的调了 Open-Meteo 拿到实时数据
  3. 模型加载了 weather-briefing 技能,按规范播报(结构、语气、实用建议都对)。

12.3 数据真实性校验

为了确认不是模型编的,我们写了一个独立脚本直接调 Open-Meteo,与插件返回逐项比对一致

Open-Meteo 北京实时:晴天 29°C 6km/h 东风 20~29°C
weather 工具返回:   北京当前晴天 29°C 20~29°C 6km/h 东风

数据一致 → 全链路真实可靠。

说明:weather 工具调用的是 Open-Meteo 免费 API,无需任何 API key。如果你开发的插件需要调用付费服务,key 应放在 ~/.dsh/.credentials.yaml 或 profile 配置里,绝不写进代码。


第 13 章 web 界面验收:在浏览器里看到天气卡片

13.1 为什么这步是"最终验收"

用户真正看到的是网页界面。命令行跑通只能证明"逻辑对",界面验收才能证明"用户看得见、用得上"

13.2 启动 web 并实测

dsh --profile web
# 浏览器打开 http://127.0.0.1:3080

我们真实实测的过程

  1. 浏览器打开 http://127.0.0.1:3080,标题显示 DeepSeek Harness,模型已配置为 DeepSeek-V4-Flash;
  2. 输入框输入"北京今天天气怎么样",点击发送;
  3. 会话标题自动变成 "北京今日天气查询"
  4. 界面消息流里依次出现:
    • 上下文注入(system-prompt、skill-catalog);
    • Think:模型判断要用 weather 工具;
    • Skillweather-briefing 被加载;
    • 动效天气卡片渲染:
      ☀️
      📍 北京
      29°C
      体感 29°C · 东风 6km/h
      20~29°C · 白天
      更新于 16:30
      
    • Think:工具返回"北京当前晴天,29°C,20~29°C,6km/h 东风。";
  5. 模型按技能规范完整播报 + 实用建议;
  6. 统计信息:1 轮 · 2 步 | LLM 4.5s · 工具调用 3.6s | 首 token 平均 1.1s · 156 tok/s | 缓存命中 50% | 输入 31.4K tok · 输出 358 tok

13.3 这次验收证明了什么

  • 天气工具被真实调用并返回数据;
  • weather-briefing 技能被加载并指导播报;
  • 浏览器端动效卡片成功渲染(前端读取持久化 meta → 画出生动的天气卡);
  • 前端卡片数据与 headless 完全一致 → 回放一致性的体现。

13.4 如果卡片没显示怎么办

排查顺序:

  1. http://127.0.0.1:3080/plugins/weather-plugin/client.js 是否返回 200(浏览器端产物是否被 serve);
  2. block.meta 是否有数据(Node 端 presentationMeta 是否正确投影);
  3. ctx.slots.registerkey 是否与工具名 weather 一致;
  4. client.js 是否单文件、无动态 import(构建自检)。

第 5 部分 · 交付与进阶

第 14 章 发布与维护:改代码后的正确姿势

14.1 一个容易忽略的坑:file: 依赖是复制

file: 方式安装插件,dsh 是把它复制进 profile 的 node_modules,而不是软链。所以:

  • 第一次安装后,你再改插件代码、重新构建,profile 里还是旧产物
  • 必须手动把新产物同步过去。

正确姿势(改代码后三步):

# ① 重新构建
powershell -ExecutionPolicy Bypass -File scripts/build.ps1
# ② 同步产物到已安装的 profile
python scripts/sync_profile.py
# ③ 重启 dsh 生效

sync_profile.py 会把 lib/cordis.patch.ymlpackage.json 拷到 profile 里的插件目录。

14.2 本次开发真实踩过的坑(高频)

#现象解法
1Node 版本太旧dsh 启动报 Promise.withResolvers升级 Node 22+,或用 node22 的 node.exe 跑
2junction 被 npm install 清掉构建找不到 @deepseek-ai/*link_deps 放 npm install 之后
3改了代码没生效行为还是旧的file: 是复制,跑 sync_profile.py
4浏览器端没渲染前端报 __ModuleLoader__ 错误client.js 缺 ModuleLoader 包装
5中文城市查不到"纽约" geocode 无结果API 语言问题,换英文名或北京/上海
6tsdown 版本Node 20 下 tsdown 0.22 报 Promise.withResolverstsdown@^0.19.0
7PowerShell 中文乱码build.ps1 输出乱码脚本输出用纯英文
8npm peer 冲突npm install 报 peerDependencies--legacy-peer-deps

14.3 发布清单

对外发布前逐项确认:

  • lib/index.jslib/client.js 已生成且通过自检;
  • cordis.patch.ymlidpackage.json 的包名一致;
  • headless 冒烟通过(dsh --profile headless "<问题>");
  • web 界面看到渲染效果(最终验收);
  • 密钥/口令不在代码或产物里(放配置文件);
  • 可选:npm publish 后,用户用 dsh plugin add 包名 安装。

第 15 章 进阶:四种插件类型,选型与实现

15.1 四类插件总览

类型一句话实现手段典型场景
Tool Plugin给模型加可执行动作defineTool + ctx.tools.register查天气、查库、调 API
Skill 插件教模型按规范产出SkillProvider {list,get}统一写作风格、播报规范
Service Provider换底层驱动实现服务接口 + ctx.super('key')换模型网关、换文件沙箱
Event Interceptor关键路径加料waterfall 事件 + next() 委托/短路审批、审计、限流
Agent Loop重写核心循环实现 Agent 接口 + AgentFactoryPlan-and-Execute、多智能体

15.2 选型决策

用户想"加新能力" →
├─ 想让模型多一个"能干的动作"?            → Tool Plugin(首选)
├─ 想教模型"怎么写/怎么组织"?            → Skill 插件
├─ 想换掉底层驱动(模型/文件/沙箱)?     → Service Provider
├─ 想在关键路径插审批/审计/限流?         → Event Interceptor
└─ 想重写整个思考-行动循环?              → Agent Loop

选型口诀:Provider 是"换驱动",Tool 是"装软件",Interceptor 是"加关卡",AgentLoop 是"换引擎"。

建议:多数需求用 Tool + Skill + 系统提示 + 浏览器端 组合即可解决。先做最小可用的 Tool,再补技能/系统提示/前端,不要一上来就动 Agent Loop


第 16 章 沉淀:把经验变成可复用的 Skill

16.1 为什么值得沉淀

你刚走完一个完整插件的全部流程。下次开发一个类似插件(查股票、查物流、查数据库),80% 的步骤是重复的:环境、骨架、构建、安装、测试、避坑。

把这些沉淀成一个 Skill(给 AI 的"作业规范手册"),就能让 AI 照着做、少踩坑、速度快一倍。

16.2 一个 dsh 插件开发 Skill 应该装什么

资源内容
SKILL.md7 步工作流(选型→环境→骨架→实现→构建→安装→测试)
references/深度文档:架构、四种类型、工具、技能、系统提示、浏览器端、构建测试、排错
assets/plugin-skeleton/可直接复制的插件骨架模板
assets/examples/weather-plugin/完整可运行的天气插件案例(就是本课做的这个)
scripts/scaffold_plugin.py一键生成插件项目脚手架

16.3 你从这门课带走的核心资产

  1. 一套方法论:需求 → 选型 → 环境 → 骨架 → 实现 → 构建 → 安装 → 测试 → 验收;
  2. 一个真实 API 认知defineToolSkillProvider {list,get}systemPrompt.sectionslots.inject——都是当前版本的真实用法,网上很多旧资料是错的;
  3. 一个可运行的案例:天气插件(查天气 + 动效卡片),可作为任何新插件的起点;
  4. 一堆避坑经验:Node 22、junction 顺序、file: 同步、ModuleLoader、中文城市 geocode……都在附录 C 有对照表。

附录

附录 A · 完整代码清单(weather-plugin)

天气插件的完整源码位于 D:\dsh-openmaic-main\weather-plugin\。核心文件速览:

文件职责关键代码
src/index.ts入口 + 配置export const name / inject / Config(schemastery z.object)/ apply 里三个 ctx.effect 注册
src/tool.tsweather 工具defineTool:name / description / parameters / output(render+presentationMeta)/ execute / presentCall / presentResult
src/skill.tsweather-briefing 技能SkillProvider { name, list(), get() },正文 BODY 是播报规范
src/fragment.ts共享契约weatherTypeFromCode(WMO 码→动效/中文)、parseWeathersummarizeweatherMetaFrom
src/client/index.tsx浏览器端ctx.slots.inject('tool.call.toolview') + keyed WeatherCardView + 6 种纯 CSS 天气动画
tsdown.config.ts双端打包Node ESM + 浏览器 CJS 单文件 + ModuleLoader banner/footer
cordis.patch.yml挂载声明- insert: { id: weather-plugin, name: '@demo/weather-plugin' }
scripts/build.ps1一键构建install → link_deps → tsdown → 自检
scripts/link_deps.pyjunction 链依赖mklink /J 链 @deepseek-ai/*
scripts/sync_profile.py同步产物拷 lib/ + patch + package.json 到 profile

这份源码也完整保存在 dsh-plugin-developer Skill 的 assets/examples/weather-plugin/,可随时对照。

附录 B · 常用命令速查

目的命令
检查 Node 版本(必须 ≥22)node -v
生成插件骨架python scripts/scaffold_plugin.py <目录> --name @scope/name
一键构建powershell -ExecutionPolicy Bypass -File scripts/build.ps1
链宿主依赖python scripts/link_deps.py
双端打包npx tsdown
安装进 profiledsh plugin --profile web add file:<绝对路径>
headless 冒烟dsh --profile headless "北京天气怎么样"
启动 webdsh --profile web(浏览器开 http://127.0.0.1:3080
同步产物到 profilepython scripts/sync_profile.py
检查浏览器端产物是否被 serve浏览器访问 http://127.0.0.1:3080/plugins/weather-plugin/client.js

附录 C · 常见报错对照表

报错/现象原因解法
Promise.withResolvers is not a functionNode 太旧(需 22+)升级 Node 22+,或用 node22 的 node.exe 跑 dsh
zlib.createZstdDecompress is not a functionNode < 22同上
module.stripTypeScriptTypes is not a functionNode < 22同上
构建找不到 @deepseek-ai/*junction 被 npm install 清掉link_deps 放 npm install 之后重跑
mklink /J 需要管理员权限用了符号链接用 junction(mklink /J),不需要管理员
tsdown 报 Promise.withResolverstsdown 0.22+ 也要 Node 22Node 20 用 tsdown@^0.19.0
npm peerDependencies 冲突dsh 各包 peer 相互引用npm install --legacy-peer-deps
前端报 __ModuleLoader__ is not a functionclient.js 缺 banner/footer配 tsdown outputOptions banner/footer
改了代码行为没变file: 依赖是复制重建后 python scripts/sync_profile.py
工具返回了但前端没渲染meta 缺失 / key 不一致查 presentationMeta、slots.register 的 key
"纽约"查不到天气geocode API 中文问题换英文名或北京/上海
PowerShell 中文乱码PS 5.1 编码脚本输出用纯英文

附录 D · 术语表

术语含义
dshDeepSeek Harness,万物皆插件的智能体框架
Cordis插件容器框架,所有插件挂载于此
插件(Plugin)一个 npm 包,导出 apply(ctx, config),给 dsh 加能力
Profiledsh 的配置档案,管理一组插件(如 web、headless)
工具(Tool)给模型的可执行动作,defineTool + execute
技能(Skill)给模型的写作规范,SkillProvider {list,get} 两级加载
系统提示(System Prompt)常驻指导文字,ctx.systemPrompt.section
Toolview浏览器端把工具结果渲染成界面的组件
metaNode 端写、浏览器端读的持久化结构化数据
presentationMeta工具把结构化数据投影进持久化 meta 的钩子
junctionWindows 目录联接,用于链宿主依赖
tsdownTS 打包器,本课用于双端打包
ModuleLoaderdsh 网页端加载浏览器端产物的机制(window.__ModuleLoader__.load
Bundleprofile 的插件清单(dsh.profile.bundles
Open-Meteo免费天气 API(地理编码 + 实时预报,无需 key)

结语

恭喜你走完了从 0 到 1 开发 dsh 插件的完整旅程。你现在掌握了:

  • 认知:dsh 的"万物皆插件"哲学,插件双端架构;
  • 实战:工具、技能、系统提示、浏览器端动效四块核心能力的真实 API 写法;
  • 工程:Node 22 环境、junction 链依赖、tsdown 双端构建、ModuleLoader 包装;
  • 交付:profile 安装、headless 冒烟、web 界面验收、发布维护;
  • 沉淀:把经验变成可复用 Skill。

下一步建议:用脚手架生成一个新骨架,把天气插件换成你的真实需求(查股票、查物流、查数据库……),照本课流程再走一遍。第二次会比第一次快一倍。

祝你做出好插件!

特别致谢: https://linux.do 社区支持