以「查天气 + 动效卡片」插件为完整案例,带你走完一个真实 dsh 插件的全部开发流程:需求 → 环境 → 骨架 → 实现(工具/技能/系统提示/浏览器端)→ 双端构建 → 安装测试 → 界面验收 → 发布维护。
DeepSeek Harness(简称 dsh)是一个"万物皆插件"的智能体框架。给 dsh 增加任何新能力——让模型会查天气、会调用你的系统、会画出好看的界面——本质上都是写一个插件。
这门课以一个真实的天气插件(我们已经在 dsh 网页里跑通、显示过天气卡片的项目)为蓝本,手把手带你从零写出、构建、安装并测试一个插件。你最终会得到:
weather 工具;weather-briefing 技能规范播报:"北京今天晴天 ☀️ 29°C 东风 6km/h……";传统做法:模型、工具、对话循环、网页界面……全部"焊死"在一个程序里。想加一个能力,就得改源码、重新编译、重新发布。这对第三方开发者非常不友好。
dsh 换了一种哲学——everything-is-a-plugin(万物皆插件):
一句话:dsh = 一台"所有零件都能换"的智能体机器。写插件 = 造一个零件装上去。不需要 fork 源码。
当你写一个天气插件时,你实际上是在往这台机器上加三样东西:
| 东西 | 类比 | 作用 |
|---|---|---|
| 工具(Tool) | 给模型"手" | 模型可以调用的动作:查天气、查数据、算东西 |
| 技能(Skill) | 给模型"说明书" | 教模型"按什么规范产出":天气怎么播报、文案怎么写 |
| 系统提示 | 给模型"入职培训" | 教模型"什么时候该用哪个工具" |
三样配合,模型才能"会查 → 会播报 → 播得好"。
dsh 网页端的插件往往是双端的:
两端通过 meta 数据衔接:Node 端算好结构化数据放进 meta,浏览器端读取并渲染。
这正是我们天气插件的设计:Node 端调 Open-Meteo 拿温度风力,浏览器端用这些数据画一张会动的天气卡片。
写一个插件不是只写代码,而是一条完整流水线。全程共 7 步,本课程后续章节逐一展开:
① 选型 → ② 环境 → ③ 骨架 → ④ 实现 → ⑤ 构建 → ⑥ 安装 → ⑦ 测试
动手前先回答:"用户要加的新能力属于哪一类?" dsh 插件分四种(详见第 15 章):
| 类型 | 解决什么 | 什么时候用 |
|---|---|---|
| Tool Plugin | 让模型拥有新动作 | 90% 的需求,比如查天气、查数据库、调 API |
| Skill 插件 | 教模型按规范产出 | 想让输出风格统一、结构固定 |
| Service Provider | 换底层驱动 | 换模型网关、换文件沙箱 |
| Event Interceptor | 关键路径加料 | 审批、审计、限流 |
| Agent Loop | 重写核心循环 | 高难定制,一般用不到 |
选型口诀:Provider 是"换驱动",Tool 是"装软件",Interceptor 是"加关卡",AgentLoop 是"换引擎"。 我们的天气插件 = Tool Plugin(工具)+ Skill 插件(技能)+ 系统提示 + 浏览器端渲染,是 dsh 插件最典型的组合。
Promise.withResolvers、node:zlib zstd、node:module stripTypeScriptTypes,Node 20 必崩);dsh 命令;用脚手架一键生成项目骨架,然后填充四块核心代码:工具、技能、系统提示、浏览器端。
用 tsdown 一次构建出两个产物:lib/index.js(Node 端)+ lib/client.js(浏览器端单文件)。
dsh 用 profile(配置档案)管理插件集合。dsh plugin --profile 名字 add file:路径 即可把插件装进去。
重要提醒:命令行跑通 不算完成。用户真正看到的是网页界面。所以最终验收必须在浏览器里做。这也是本课程贯穿始终的标准。
开发天气插件时,第一次启动 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。
| Node 22 新能力 | dsh 用来干什么 |
|---|---|
Promise.withResolvers |
标准库新增的 Promise 构造方式,dsh 内部异步逻辑用到 |
node:zlib 的 createZstdDecompress |
解压 zstd 压缩的数据 |
node:module 的 stripTypeScriptTypes |
让 dsh 能直接加载/剥离 TS 类型 |
这些在 Node 20 里都不存在,所以不是配置问题,是版本问题。
方法一(推荐):升级 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 的最前面,这样 dsh、node、npx 都优先用 Node 22。
验证:
node -v必须输出v22.x及以上。连带坑:构建工具 tsdown 0.22+ 也要求 Node 22。如果只能在 Node 20 环境构建,请用
tsdown@^0.19.0(我们就是这么降级的)。
环境确认后,构建脚本按这个顺序工作(见 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 章)。
不用手动搭文件,用 skill 自带的脚手架脚本:
python scripts/scaffold_plugin.py D:/dev/weather-plugin --name @me/weather-plugin --desc "查天气 + 动效卡片"
它会自动复制模板、替换包名和描述占位符,生成一个结构完整、可直接开始改代码的项目。
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 组件
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 提供) |
- insert:
- id: weather-plugin # 插件实例 id
name: '@me/weather-plugin' # npm 包名
这个文件让 dsh 启动时知道"要加载一个叫 weather-plugin 的插件",并把它挂进当前 profile 的 layer 栈。
每个插件都有一个入口文件 src/index.ts,导出 name、inject、Config 和 apply 函数。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')
}
| 部分 | 说明 |
|---|---|
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 插件规范写法。
工具 = 给模型看的"可执行函数签名" + 你写的 execute 实现。
description 和 parameters(决定它会不会调、怎么调);execute 去干活(这里就是查天气 API);我们用的天气 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=..¤t_weather=true&daily=..&timezone=auto&forecast_days=1
真实 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 定浏览器渲染的数据 |
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}¤t_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 }
}
这是 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、渲染富、两端职责分离。
两种失败要区别对待:
// 参数错误 → 直接 throw(让模型知道这次调用无效,自行修正)
if (city === '') throw new Error('weather: city 是必填参数')
// 业务失败(找不到城市、外部服务报错)→ 抛出带信息的错误,让模型读得懂并调整策略
if (!hit) throw new Error(`weather: 找不到城市「${city}」,请换英文名试试`)
原则:参数/调用错误抛异常,业务失败返回结构化结果。别把"外部服务挂了"当成"工具坏了"抛给模型。
Node 端把天气数据写进 meta,浏览器端要读它渲染。如果两端各写各的解析逻辑,很容易不一致:Node 端说 WMO 天气码 0 是"晴",浏览器端以为是"阴",卡片就画错了。
解法:把"数据怎么算/怎么解析"抽成纯函数契约模块 src/fragment.ts,Node 端、浏览器端、测试三方 import 同一个函数。
// 双端共用的纯函数:不 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 { /* ... */ }
经验:把"所有会算数据的逻辑"都往 fragment 里放,是 dsh 插件的最佳实践。
技能 = 给 AI 的"写作规范手册"。 工具给 AI"手"(动作),技能给 AI"说明书"(怎么做、按什么规范产出)。
我们的天气技能 weather-briefing 就干一件事:教模型拿到天气数据后,按什么结构、什么语气播报。
当前版本的技能是 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, // 完整规范:重,触发才加载
}
},
}
| 层 | 内容 | 何时进上下文 |
|---|---|---|
list() 候选 |
name + description(轻) | 常驻,模型据此判断是否触发 |
get() 正文 |
完整 content(重) | 仅当模型决定使用该技能时 |
为什么要这样? 模型上下文是稀缺资源。如果把所有技能的完整规范都常驻,上下文立刻爆炸。所以:候选精炼常驻、正文按需加载。
写技能正文 = 写清楚产出物的结构、必含要素、格式、语气、边界。要点:
在 apply 里注册:
ctx.effect(() => ctx.skills.registerProvider(() => weatherSkillProvider), 'weather-plugin.skill')
工具 description 通常写得较长,模型在长对话里不一定记得用它。系统提示是常驻的,加一小段就能持续引导:"遇到天气问题 → 调用 weather 工具 → 按技能播报"。
注意签名是传一个对象 { 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')
系统提示由多个插件各自追加,顺序有讲究:通用规则在前、具体约定在后。order 小的靠前,约定类段落给较大 order 放后面(我们用 117),避免干扰模型对通用指令的理解。
模型只通过文字理解工具。 写 description 的黄金模板:
查询指定城市的实时天气。当用户询问天气、温度、雨雪、风力时使用。仅当用户要求华氏度时才传 unit 参数。实战验证:weather 的系统提示 + description 按"三件套"写,浏览器实测模型一次就准确调用了
weather工具。
| 维度 | 系统提示 | 技能 |
|---|---|---|
| 加载 | 常驻(始终在上下文) | 按需触发加载 |
| 体量 | 宜短 | 可长 |
| 用途 | 引导"何时用工具/遵守约定" | 提供"怎么写/怎么组织"的完整规范 |
原则:全局的、简短的 → 系统提示;局部的、详细的 → 技能。别把长规范塞进系统提示。
Node 端负责"干活"(调 API、算数据),浏览器端负责"画画"(渲染界面)。两端通过持久化 meta 衔接:
工具 presentationMeta ──► block.meta(持久化)──► 浏览器端 Toolview ──► 用户看到的动效卡片
浏览器端代码最终被打包成 lib/client.js,由 dsh 以 /plugins/weather-plugin/client.js 提供,在沙箱 iframe 里运行。
真实 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,
))
}
组件拿到的 block 有三种形态:
| 形态 | 判断方式 | 渲染什么 |
|---|---|---|
| 运行中(pending) | !('kind' in block) |
loading 动画 |
| 失败 | block.isError |
错误文本 |
| 成功 | 有 kind,从 block.meta 还原 |
结果卡片 |
别漏掉:运行中的 block 没有 kind 字段,直接读 block.meta 会拿不到数据。
天气卡片用纯 CSS keyframes 实现 6 种天气动画,不引任何 CDN(沙箱 CSP 也禁止外部脚本):
| 天气 | 动效 |
|---|---|
| ☀️ 晴 | 太阳核心 + 旋转的虚线光芒 |
| ⛅ 多云 | 漂浮移动的云朵 |
| 🌫 雾 | 平移的雾带 |
| 🌧 降雨 | 周期性下落、淡出的雨线 |
| ❄️ 降雪 | 飘落 + 旋转的雪花 |
| ⛈ 雷暴 | 闪烁的闪电 |
卡片还显示:城市、温度、体感、风向风速、今日温度范围、"白天/夜间"、更新时间。
硬约束:浏览器产物必须单文件、无动态 import、无多 chunk,不能 import Node 内置模块。这些由构建自检把关(见第 11 章)。
一次构建产出两个文件:
lib/index.js —— Node 端(工具/技能/系统提示),ESM + 类型声明;lib/client.js —— 浏览器端(Toolview),CJS 单文件,带 ModuleLoader 包装。插件要 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 已按此编排)。
// 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;',
},
},
]
关键点:
Config schema,必须看到它自己的实例;window.__ModuleLoader__.load({ id, factory }),这是 dsh 网页端加载插件浏览器端的方式。从旧 API 迁移最易漏的一环:忘写 ModuleLoader 包装,前端会报
__ModuleLoader__ is not a function。
构建后自检 lib/client.js:
react、@deepseek-ai/dsh-client-* 等平台模块;import(;任一不满足即报错,保证浏览器产物能在 CSP 沙箱安全加载。
# ① 装构建工具
npm install --legacy-peer-deps
# ② 链宿主依赖(junction,必须在 install 之后)
python scripts/link_deps.py
# ③ 双端打包
npx tsdown
# ④ 自检
# 检查 lib/index.js 与 lib/client.js 是否生成
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 顺序加载。
不需要浏览器,命令行直接问:
dsh --profile headless "北京今天天气怎么样"
我们真实得到的输出:
北京今天晴天 ☀️
气温:当前 29°C,体感较热,全天在 20~29°C 之间
风力:东风,风速约 6 km/h,风很小
白天太阳很足、午后偏热,出门记得防晒补水;早晚 20°C 左右稍凉,可以带件薄外套。
再测一个城市:
dsh --profile headless "上海今天天气怎么样"
# → 上海当前阴天 ☁️ 29°C 东风 11km/h
这证明了什么:
weather-briefing 技能,按规范播报(结构、语气、实用建议都对)。为了确认不是模型编的,我们写了一个独立脚本直接调 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 配置里,绝不写进代码。
用户真正看到的是网页界面。命令行跑通只能证明"逻辑对",界面验收才能证明"用户看得见、用得上"。
dsh --profile web
# 浏览器打开 http://127.0.0.1:3080
我们真实实测的过程:
http://127.0.0.1:3080,标题显示 DeepSeek Harness,模型已配置为 DeepSeek-V4-Flash;Think:模型判断要用 weather 工具;Skill:weather-briefing 被加载;☀️
📍 北京
29°C
体感 29°C · 东风 6km/h
20~29°C · 白天
更新于 16:30
Think:工具返回"北京当前晴天,29°C,20~29°C,6km/h 东风。";weather-briefing 技能被加载并指导播报;排查顺序:
http://127.0.0.1:3080/plugins/weather-plugin/client.js 是否返回 200(浏览器端产物是否被 serve);block.meta 是否有数据(Node 端 presentationMeta 是否正确投影);ctx.slots.register 的 key 是否与工具名 weather 一致;用 file: 方式安装插件,dsh 是把它复制进 profile 的 node_modules,而不是软链。所以:
正确姿势(改代码后三步):
# ① 重新构建
powershell -ExecutionPolicy Bypass -File scripts/build.ps1
# ② 同步产物到已安装的 profile
python scripts/sync_profile.py
# ③ 重启 dsh 生效
sync_profile.py 会把 lib/、cordis.patch.yml、package.json 拷到 profile 里的插件目录。
| # | 坑 | 现象 | 解法 |
|---|---|---|---|
| 1 | Node 版本太旧 | dsh 启动报 Promise.withResolvers 等 |
升级 Node 22+,或用 node22 的 node.exe 跑 |
| 2 | junction 被 npm install 清掉 | 构建找不到 @deepseek-ai/* |
link_deps 放 npm install 之后 |
| 3 | 改了代码没生效 | 行为还是旧的 | file: 是复制,跑 sync_profile.py |
| 4 | 浏览器端没渲染 | 前端报 __ModuleLoader__ 错误 |
client.js 缺 ModuleLoader 包装 |
| 5 | 中文城市查不到 | "纽约" geocode 无结果 | API 语言问题,换英文名或北京/上海 |
| 6 | tsdown 版本 | Node 20 下 tsdown 0.22 报 Promise.withResolvers |
用 tsdown@^0.19.0 |
| 7 | PowerShell 中文乱码 | build.ps1 输出乱码 | 脚本输出用纯英文 |
| 8 | npm peer 冲突 | npm install 报 peerDependencies | 加 --legacy-peer-deps |
对外发布前逐项确认:
lib/index.js 和 lib/client.js 已生成且通过自检;cordis.patch.yml 的 id 与 package.json 的包名一致;dsh --profile headless "<问题>");npm publish 后,用户用 dsh plugin add 包名 安装。| 类型 | 一句话 | 实现手段 | 典型场景 |
|---|---|---|---|
| Tool Plugin | 给模型加可执行动作 | defineTool + ctx.tools.register |
查天气、查库、调 API |
| Skill 插件 | 教模型按规范产出 | SkillProvider {list,get} |
统一写作风格、播报规范 |
| Service Provider | 换底层驱动 | 实现服务接口 + ctx.super('key') |
换模型网关、换文件沙箱 |
| Event Interceptor | 关键路径加料 | waterfall 事件 + next() 委托/短路 |
审批、审计、限流 |
| Agent Loop | 重写核心循环 | 实现 Agent 接口 + AgentFactory | Plan-and-Execute、多智能体 |
用户想"加新能力" →
├─ 想让模型多一个"能干的动作"? → Tool Plugin(首选)
├─ 想教模型"怎么写/怎么组织"? → Skill 插件
├─ 想换掉底层驱动(模型/文件/沙箱)? → Service Provider
├─ 想在关键路径插审批/审计/限流? → Event Interceptor
└─ 想重写整个思考-行动循环? → Agent Loop
选型口诀:Provider 是"换驱动",Tool 是"装软件",Interceptor 是"加关卡",AgentLoop 是"换引擎"。
建议:多数需求用 Tool + Skill + 系统提示 + 浏览器端 组合即可解决。先做最小可用的 Tool,再补技能/系统提示/前端,不要一上来就动 Agent Loop。
你刚走完一个完整插件的全部流程。下次开发一个类似插件(查股票、查物流、查数据库),80% 的步骤是重复的:环境、骨架、构建、安装、测试、避坑。
把这些沉淀成一个 Skill(给 AI 的"作业规范手册"),就能让 AI 照着做、少踩坑、速度快一倍。
| 资源 | 内容 |
|---|---|
SKILL.md |
7 步工作流(选型→环境→骨架→实现→构建→安装→测试) |
references/ |
深度文档:架构、四种类型、工具、技能、系统提示、浏览器端、构建测试、排错 |
assets/plugin-skeleton/ |
可直接复制的插件骨架模板 |
assets/examples/weather-plugin/ |
完整可运行的天气插件案例(就是本课做的这个) |
scripts/scaffold_plugin.py |
一键生成插件项目脚手架 |
defineTool、SkillProvider {list,get}、systemPrompt.section、slots.inject——都是当前版本的真实用法,网上很多旧资料是错的;天气插件的完整源码位于 D:\dsh-openmaic-main\weather-plugin\。核心文件速览:
| 文件 | 职责 | 关键代码 |
|---|---|---|
src/index.ts |
入口 + 配置 | export const name / inject / Config(schemastery z.object)/ apply 里三个 ctx.effect 注册 |
src/tool.ts |
weather 工具 | defineTool:name / description / parameters / output(render+presentationMeta)/ execute / presentCall / presentResult |
src/skill.ts |
weather-briefing 技能 | SkillProvider { name, list(), get() },正文 BODY 是播报规范 |
src/fragment.ts |
共享契约 | weatherTypeFromCode(WMO 码→动效/中文)、parseWeather、summarize、weatherMetaFrom |
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.py |
junction 链依赖 | mklink /J 链 @deepseek-ai/* |
scripts/sync_profile.py |
同步产物 | 拷 lib/ + patch + package.json 到 profile |
这份源码也完整保存在 dsh-plugin-developer Skill 的
assets/examples/weather-plugin/,可随时对照。
| 目的 | 命令 |
|---|---|
| 检查 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 |
| 安装进 profile | dsh plugin --profile web add file:<绝对路径> |
| headless 冒烟 | dsh --profile headless "北京天气怎么样" |
| 启动 web | dsh --profile web(浏览器开 http://127.0.0.1:3080) |
| 同步产物到 profile | python scripts/sync_profile.py |
| 检查浏览器端产物是否被 serve | 浏览器访问 http://127.0.0.1:3080/plugins/weather-plugin/client.js |
| 报错/现象 | 原因 | 解法 |
|---|---|---|
Promise.withResolvers is not a function |
Node 太旧(需 22+) | 升级 Node 22+,或用 node22 的 node.exe 跑 dsh |
zlib.createZstdDecompress is not a function |
Node < 22 | 同上 |
module.stripTypeScriptTypes is not a function |
Node < 22 | 同上 |
构建找不到 @deepseek-ai/* |
junction 被 npm install 清掉 | link_deps 放 npm install 之后重跑 |
mklink /J 需要管理员权限 |
用了符号链接 | 用 junction(mklink /J),不需要管理员 |
tsdown 报 Promise.withResolvers |
tsdown 0.22+ 也要 Node 22 | Node 20 用 tsdown@^0.19.0 |
| npm peerDependencies 冲突 | dsh 各包 peer 相互引用 | npm install --legacy-peer-deps |
前端报 __ModuleLoader__ is not a function |
client.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 编码 | 脚本输出用纯英文 |
| 术语 | 含义 |
|---|---|
| dsh | DeepSeek Harness,万物皆插件的智能体框架 |
| Cordis | 插件容器框架,所有插件挂载于此 |
| 插件(Plugin) | 一个 npm 包,导出 apply(ctx, config),给 dsh 加能力 |
| Profile | dsh 的配置档案,管理一组插件(如 web、headless) |
| 工具(Tool) | 给模型的可执行动作,defineTool + execute |
| 技能(Skill) | 给模型的写作规范,SkillProvider {list,get} 两级加载 |
| 系统提示(System Prompt) | 常驻指导文字,ctx.systemPrompt.section |
| Toolview | 浏览器端把工具结果渲染成界面的组件 |
| meta | Node 端写、浏览器端读的持久化结构化数据 |
| presentationMeta | 工具把结构化数据投影进持久化 meta 的钩子 |
| junction | Windows 目录联接,用于链宿主依赖 |
| tsdown | TS 打包器,本课用于双端打包 |
| ModuleLoader | dsh 网页端加载浏览器端产物的机制(window.__ModuleLoader__.load) |
| Bundle | profile 的插件清单(dsh.profile.bundles) |
| Open-Meteo | 免费天气 API(地理编码 + 实时预报,无需 key) |
恭喜你走完了从 0 到 1 开发 dsh 插件的完整旅程。你现在掌握了:
下一步建议:用脚手架生成一个新骨架,把天气插件换成你的真实需求(查股票、查物流、查数据库……),照本课流程再走一遍。第二次会比第一次快一倍。
祝你做出好插件!