从一个问候工具到 AI 回复卡片:前端开发者的 DSH 插件入门实战
我们做了一个叫 mydsh-plugin 的教学插件:让 DSH 可以调用 learning_greet 工具,根据姓名生成问候语;然后,在调用这个工具的那一轮对话结束后,给 AI 的最终回复加上一张卡片。
问候本身很简单,却能串起插件开发里最容易混淆的几件事:插件如何加载,工具如何执行,结果怎么交给模型,以及浏览器如何把消息画出来。
本文面向熟悉 JavaScript、TypeScript 和 React 的前端开发者。代码按当前教学插件 1.0.7 核对,DSH 依赖版本为 0.2.0-rc.2。以下片段用于解释关键机制,不是完整的可复制项目;涉及消息渲染注册的代码需要与宿主版本一起核对。
一、先看清:我们到底改了哪一层
一次带工具调用的对话,至少涉及三种不同的内容。
| 内容 | 例子 | 谁负责 |
|---|---|---|
| 工具业务结果 | { message: "你好,小明!" } |
服务端 execute |
| 工具调用记录的外观 | 正在调用、成功结果、错误详情 | 浏览器的工具卡片组件 |
| AI 最终回复的外观 | AI 总结文字外面的边框、背景和图标 | 浏览器的消息组件扩展 |
当前 1.0.7 的实际状态是:工具正常执行,工具调用使用 DSH 默认展示;只有满足条件的 AI 最终回复会加卡片。 自定义工具卡片 GreetCard 的代码仍在,但它的注册语句已被注释。
因此,看到默认工具调用区域和下面的问候回复卡片同时存在,是符合当前实现的。
二、一张图理解整条调用流程

图 1:问候工具的执行与展示流程。上半部分是 Node.js 服务端,下半部分是浏览器中的消息展示;图中展示成功调用路径,失败时需按错误结果处理。
读图时,沿箭头看四件事:
- 先注册,再调用。
apply(ctx)把工具注册到宿主;真正发起learning_greet调用后,才进入execute(args, exec)。 - 先完成业务,再生成模型内容。
execute返回{ message: ... },结果通过校验后由output.render转成内容块,供模型继续生成回复。 - 工具展示与 AI 回复分开。 工具调用区域展示调用记录;AI 最终回复是模型在获得结果后生成的另一段内容,两者不需要逐字相同。
- 卡片由浏览器按条件添加。 本轮结束、当前节点确为最终回复,且前面存在
learning_greet结果记录时,浏览器才给原生 AI 消息组件添加卡片外观;具体条件见第七节。
**示例:**调用参数为 { name: " 小明 " } 时,业务代码先去除首尾空格,得到 { message: "你好,小明!" };模型收到这个结果后继续组织回复。浏览器根据会话记录决定是否加卡片,而不是因为页面文字里出现了“小明”就加卡片。
这里有两个执行环境:Node.js 服务端负责工具业务,浏览器负责 React 界面。服务端的 execute 不会直接调用浏览器里的卡片组件。它们通过宿主记录并传递的会话数据衔接。
图中“模型产生工具调用”表示一次确实发生工具调用的路径。仅仅安装了插件,或者在提示词里提到问候,并不保证每次都调用工具;验收时要检查真实调用记录。
三、apply、ctx、args、exec 分别是什么
1. apply:插件激活时的入口
服务端入口文件是 src/index.ts,它导出插件名、依赖声明和激活函数。
// 插件名用于识别插件,不等于工具名。
export const name = 'mydsh-plugin'
// 声明运行时依赖 tools 服务;这不会自动安装 npm 包。
export const inject = ['tools']
export function apply(ctx: Context): void {
// 插件激活后,在这里向宿主注册工具。
// 某一次问候的业务逻辑,放在工具的 execute 中。
}
apply 是“把能力接入宿主”的地方。execute 才是“使用这项能力处理一次请求”的地方。重新加载插件可以再次触发激活,不能把 apply 理解成整个应用永久只运行一次。
2. ctx:当前插件访问宿主能力的上下文
可以把它理解为宿主传给插件的一组服务入口,但它还带着插件作用域和生命周期管理能力。
| 入口 | 用途 |
|---|---|
ctx.tools.register |
注册工具定义 |
ctx.tools.get |
按工具名查找工具 |
ctx.tools.execute |
通过工具服务发起调用 |
ctx.on |
监听宿主事件,并关联插件生命周期 |
ctx.effect |
登记副作用及其清理操作 |
ctx.plugin |
加载子插件 |
当前工具服务的 register 内部已经管理注册清理,插件卸载时会撤销工具,所以示例没有再给它套一层重复的 ctx.effect。
浏览器插件也有自己的 apply(ctx),但它依赖的是 slots、locale 等浏览器服务。两边的 ctx 不是同一个跨进程对象。 不能因为服务端可以用 ctx.tools,就认为浏览器也能用相同方式执行任意服务端能力。
3. args:这一次调用的业务参数
args.name // 例如“小明”,来自本次工具调用的参数
工具的 parameters 声明参数规则。外部调用参数先接受校验,再进入业务回调。对前端开发者来说,可以类比表单数据或接口请求体;区别是这里的参数通常由模型发起的工具调用提供。
4. exec:这一次调用的执行上下文
exec.name // 工具名:learning_greet
exec.callId // 当前调用的标识
exec.signal // AbortSignal,用于响应取消
exec.agent // 关联 Agent;独立测试等环境中可能没有
注意两个 name:args.name 是人的姓名,exec.name 是工具名称。
以后把问候工具改成网络查询,可以将 exec.signal 传给 fetch,让请求响应取消。当前示例在业务开始时调用 exec.signal.throwIfAborted(),已经取消就不再继续处理。
四、最小工具:输入姓名,返回问候
下面是当前服务端实现的精简版,省略了诊断日志,保留主要定义。
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'mydsh-plugin'
export const inject = ['tools']
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
// 模型通过这个名字指定工具。
name: 'learning_greet',
description: '根据姓名生成问候语',
// 声明每次调用需要提供的参数。
parameters: {
name: {
type: 'string',
required: true,
description: '需要问候的姓名',
},
},
output: {
// 声明 execute 成功返回的结构。
schema: {
type: 'object',
additionalProperties: false,
properties: {
message: { type: 'string', required: true },
},
},
// 将业务结果转换为模型可读的内容块。
// value 是业务返回值;这里不生成 HTML 或 React 元素。
render: (_args, value) => [
{ type: 'text', text: value.message },
],
},
execute: async (args, exec) => {
// 响应已发生的取消。
exec.signal.throwIfAborted()
// 类型检查保证 string,但空白字符串仍需要业务校验。
const personName = args.name.trim()
if (!personName) {
throw new Error('姓名不能为空')
}
// 返回业务数据,后续由宿主处理结果校验与转换。
return { message: `你好,${personName}!` }
},
}))
}
这几个字段各有一份工作:parameters 描述输入,execute 完成业务,output.schema 描述成功结果,output.render 生成给模型使用的内容。
例如 execute 返回 { message: "你好,小明!" },render 将其转换成 [{ type: "text", text: "你好,小明!" }]。模型获得工具结果后,还可以组织自己的最终回复,因此 AI 最后那段文字不一定逐字等于 message。
这里的 Schema 写法来自当前 DSH/Schemastery 使用方式,不要直接当成任意 JSON Schema 库都能照搬的配置。
五、三个依赖包各做什么
| 依赖 | 在这个例子中的职责 |
|---|---|
@deepseek-ai/cordis |
插件上下文、依赖注入和生命周期机制;示例从中导入 Context 类型 |
@deepseek-ai/schemastery |
Schema 描述和校验相关基础设施;当前 index.ts 没有直接导入它 |
@deepseek-ai/dsh-tools |
提供 defineTool 等 DSH 工具定义能力 |
这个教学包的基础依赖版本分别是 4.0.4、3.18.4、0.2.0-rc.2。增加 Web UI 后,还需要 React 及相应的 DSH 客户端接口依赖;上面三个包只是服务端入门部分。
inject = ['tools'] 属于运行时服务依赖,package.json 的 dependencies 属于包依赖。前者解决“激活时需要什么服务”,后者解决“代码依赖哪些安装包”,两者不能互相替代。
六、output.render 和工具卡片不是同一个入口
初看 render 很容易联想到 JSX,但这里它运行在 Node.js 服务端,返回的是内容块数组。
真正负责工具调用 UI 的入口在 src/client.tsx。我们曾经注册过这样的工具卡片:
// 以下是可恢复的工具卡片注册片段;当前 1.0.7 将它注释掉了。
ctx.slots.inject('tool.call.toolview', () =>
ctx.slots.register(
{
name: 'tool.call.toolview',
key: 'learning_greet', // 按工具名匹配,不是按插件包名匹配。
locale: 'mydsh-plugin',
},
GreetCard,
),
)
tool.call.toolview 是展示位置,learning_greet 是匹配键,GreetCard 是 React 组件。组件根据调用状态展示准备中、执行中、成功或失败,读取宿主提供的结果内容。
当前保留的 GreetCard 还处理了空内容、未知内容类型、原始详情和可选的查看详情按钮。工具文本通过 JSX 输出;它不依赖把工具返回值当作 HTML 注入页面。
注释这段注册,只会撤销自定义工具外观,DSH 仍会显示默认工具调用区域。 如果目标是完全隐藏工具调用记录,这是另一个界面需求;当前插件没有实现,不能用“去掉自定义卡片”代替“隐藏全部调用记录”。
七、registerGreetingReply 如何控制 AI 最终回复
这个函数在 src/assistant-reply.tsx 中,由浏览器端 apply 调用。它注册的是消息展示扩展:
插槽:conversation.chat.node
匹配键:assistant-step
组件:对 DSH 原生 AI 消息组件进行包装
它不是每调用一次问候工具,就手动创建一张消息卡片。它在插件激活时接入消息展示入口,之后每次相关会话状态变化,都根据当前数据判断是否需要卡片。
1. 先等原生消息组件注册完成
“插槽已经存在”和“插槽里的原生组件已经注册”是两个时间点。只在初始化时查一次,可能刚好查不到,结果就是插件加载了,但回复卡片没有出现。
当前代码先订阅 conversation.chat.node 的变化,再立即尝试安装。找到 assistant-step 的原生注册后,才创建包装组件;同时用已经保存的清理函数判断是否安装过,避免反复包装。
它保留原生注册,以更小的 priority 数字注册包装组件。这里“小数字优先”是当前 DSH 插槽实现的规则,不能按 CSS 的直觉理解成越大越优先。
2. 从会话数据判断,而不是搜索页面文字
isGreetingFinalReply 依次检查:
- 当前展示的不是单独的思考过程。
- 这次模型请求已经
settled,并存在最终消息记录。 - 当前对话轮次已经
closed。 - 当前消息的序号,与本轮
turn-tail记录的收尾消息序号一致。 - 同一轮里,在这条消息之前,存在
learning_greet的工具结果记录。
为什么第二步和第三步都要检查?因为一轮对话里可以先有一次模型请求要求调用工具,再有下一次模型请求生成回复。一次模型请求结束,不代表整轮对话已经结束。
为什么还要比较 turn-tail?同一轮中可能有不止一个 AI 消息节点,需要确认当前节点确实是这一轮的最后回复。
工具结果检查还会递归遍历 subCalls,所以可以识别嵌套工具调用中的 learning_greet。条件仅查询当前轮次,不会因为上一轮问候过,就把后面所有回复都改成卡片。
这个判断表示“本轮出现了该工具的结果记录”,没有额外要求结果成功。因此错误调用后的最终解释也可能带卡片,卡片本身不代表工具执行成功。
3. 加外观,正文仍交给原生组件
下面是包装部分的简化片段。props、OriginalAssistant 和样式定义来自完整实现。
// 订阅会话数据;数据变化后重新计算是否需要外观。
const framed = props.useChat(snapshot =>
isGreetingFinalReply(props.node, snapshot, props.groupPart),
)
return (
<div style={framed ? replyStyle : undefined}>
{/* 图标只是装饰,不插入模型原文。 */}
{framed && <div aria-hidden="true">👋</div>}
{/* Markdown、代码块等仍由 DSH 原生组件处理。 */}
<OriginalAssistant key="body" {...props} />
</div>
)
修改边框、背景、圆角和间距,应当看 replyStyle;修改哪些消息命中,应当看 isGreetingFinalReply;修改注册和卸载逻辑,应当看 registerGreetingReply。
完整实现同时保留原生组件需要的 chat 文案字典和 presentation Hooks 注入。只拿到组件函数却遗漏这些依赖,可能导致原生组件无法正常工作。
外层结构和正文组件的 key 保持稳定,使流式输出结束后添加卡片时尽量维持同一个正文组件。复制、分支等位于独立 turn-tail 中的操作继续由宿主管理。插件卸载时清理订阅和包装注册,原生消息注册仍然存在。
由于判断来自会话数据,历史记录加载齐全后也可以重新得出结果;没有加载到相关工具记录时先保留原样,不能误判成一张问候卡片。
八、打包、安装与“为什么还是旧代码”
这个项目至少有四份需要区分的内容:
src 源代码
↓ pnpm run build
dist 构建产物
↓ pnpm pack
.tgz 安装包
↓ 在 DSH 中安装或更新
DSH 实际加载的插件副本
修改 src 并不会自动改写已经安装的插件。即使本地 dist 已更新,宿主仍可能加载之前安装的包。
pnpm pack 是 pnpm 自带的打包命令。当前项目另行配置了:
{
"scripts": {
"build": "pnpm run typecheck && node scripts/build.mjs",
"test": "pnpm run build && node --test tests/*.test.mjs",
"prepack": "pnpm run build"
}
}
因此当前项目执行 pnpm pack 时,会通过 prepack 先构建。构建失败需要先处理,不能把旧包当成新产物安装。
下面是操作步骤示例,目录要换成自己的插件目录:
# 进入包含 package.json 的插件目录。
Set-Location ./mydsh-plugin
# 执行当前项目定义的测试;脚本会先构建。
pnpm test
# 生成安装包;具体路径以命令输出为准。
pnpm pack
按当前包名与版本,默认包名是 mydsh-plugin-1.0.7.tgz。安装包应位于 DSH 服务进程可访问的文件系统:如果浏览器和服务端不在同一台机器,本机路径不一定对服务端有意义。
安装界面提供本地安装入口时,填写宿主可读取的绝对路径,并检查安装结果。升级按当前 DSH 界面提示处理;这里的界面提示需要先卸载再安装新版。改完源码、重新打包、更新安装、按提示重载或刷新,是不同的步骤。
为什么浏览器端还要专门构建
服务端入口输出 ESM 的 dist/index.js。浏览器入口则由构建脚本转换后,包装进 DSH 的 window.__ModuleLoader__.load 工厂中,输出 dist/client.js。
客户端依赖通过宿主模块加载机制取得,包括 React。不能仅把普通 ESM 文件改名成 client.js,也不应随意打包另一份 React 进来。
package.json 中的 exports["./client"]、dsh.client 和服务端的 dsh.bundle.patch 分别参与客户端入口暴露、客户端依赖装配和宿主插件配置。仅仅写了一个 .tsx 文件,宿主不会自动发现并启用它。
九、日志应该在哪里看
| 想观察的内容 | 放日志的位置 | 查看位置 |
|---|---|---|
| 服务端插件是否激活 | 服务端 apply |
启动 DSH 的 PowerShell 或 VS Code 终端 |
| 工具是否真的调用 | execute(args, exec) |
同一个服务端终端 |
| 工具返回了什么 | execute 返回前,或 output.render |
服务端终端 |
| 浏览器插件是否注册 | 客户端 apply |
浏览器开发者工具 Console |
| 为什么最终回复没加卡片 | isGreetingFinalReply、包装组件 |
浏览器 Console 或断点 |
DSH 页面里内嵌的“终端工具”与启动服务的终端不是同一个日志出口。
如果完全看不到服务端日志,先检查插件是否启用、是否装了最新版、当前看的是否是实际运行服务的终端。看到注册日志却没有调用日志,则继续检查这次对话是否真的调用了 learning_greet。
之前打印 ctx 时出现一排 function,是因为打印了 typeof ctx.tools.register:它本来只回答“这是什么类型”。更适合学习的输出是给方法加上明确的访问路径:
console.log('[mydsh-plugin] 工具服务', {
'ctx.tools.register': ctx.tools.register,
'ctx.tools.get': ctx.tools.get,
'ctx.tools.execute': ctx.tools.execute,
})
.name 可以辅助观察函数名,但绑定和构建处理可能改变它,不能把函数内部名字当成稳定 API。日常调试优先打印必要字段;整个 ctx 内容繁多,还可能包含不适合写入日志的配置。
十、怎样判断自己真的做对了
可以分四层验收,每层回答一个明确问题。
| 层次 | 检查内容 | 能说明什么 |
|---|---|---|
| 工具业务 | 姓名去空格、空白姓名失败、返回问候内容 | 工具逻辑符合预期 |
| 注册与清理 | 激活后能查到工具,卸载后撤销注册 | 生命周期没有残留 |
| 前端条件 | 当前轮问候后的最终回复命中,其他工具、下一轮和思考部分不命中 | 卡片作用范围正确 |
| 实际对话 | 真实宿主加载安装包,确有调用记录,最终回复显示卡片 | 安装、执行与展示链路连通 |
还应检查流式输出过程中不会过早把中间消息当成最终回复,以及卸载后恢复原生展示。
本文已验证的范围是当前源码、构建配置和注册关系的核对;本次文章整理没有重新运行当前 1.0.7 的完整测试,也没有新做真实模型端到端验收。 保留这一区分,避免把“看过代码”写成“实际运行全部通过”。
当前版本关闭了自定义工具卡片。如果测试仍断言必须注册 GreetCard,需要同步调整该预期;之前开启工具卡片时的测试结果,不能直接代表关闭注册后的版本。
十一、以后想改功能,先找哪一个文件
| 需求 | 阅读和修改入口 |
|---|---|
| 换工具名、参数或业务结果 | src/index.ts 中的 defineTool |
| 调整传给模型的工具内容 | src/index.ts 中的 output.render |
| 恢复或改造工具调用卡片 | src/client.tsx 中的 GreetCard 和 tool.call.toolview 注册 |
| 修改最终 AI 回复卡片外观 | src/assistant-reply.tsx 中的 replyStyle 和包装组件 |
| 改为其他工具触发卡片 | hasGreetingResult 与 isGreetingFinalReply |
| 排查扩展何时安装、何时卸载 | registerGreetingReply |
| 排查浏览器入口加载失败 | scripts/build.mjs 与 package.json 的客户端声明 |
| 检查业务与展示回归 | tests/ 中的工具、客户端和回复卡片测试 |
建议阅读顺序是:先看 execute 理解一次业务调用,再看 apply 理解注册,接着看 output.render 理解结果转换,最后看两个浏览器展示入口。这样每次只增加一个新概念。
这个问候工具可以逐步扩展成订单查询、发布检查或学习进度查询。业务逻辑仍放在服务端;给模型的结果转换放在 output.render;人看到的展示放在浏览器组件;是否装饰最终回复则由明确的会话条件决定。
源码依据与进一步阅读
本文的教学插件代码位置以上表为准;它是本地教学包,不是假设已经存在于官方仓库中的示例。宿主实现核对基于 DeepSeek Harness 提交 639ed015397290b3745d163aafe02ffee4aa3f84,可沿以下入口继续阅读:
- 工具定义及工具 UI 的开发说明:理解工具输出与 Web 展示的分工。
- 客户端模块装配说明:理解客户端清单和模块工厂。
- 聊天节点渲染注册:查看
assistant-step、turn-tail的原生注册及注入。 - 原生 AI 消息组件:理解为什么包装后仍能使用宿主的正文渲染。
- 工具调用树展示:理解专属工具组件与默认展示的选择。
- 插槽注册机制:核对注册优先级、订阅与清理机制。
消息扩展复用了当前宿主的原生注册及注入方式。升级 DSH 后,应重新核对这些接口并做回归验证,不能把这份版本相关的实现当作永久不变的渲染 API。
