10
0
0

从一个问候工具到 AI 回复卡片:前端开发者的 DSH 插件入门实战

2026-10-03
2026-10-04
文章摘要
|

我们做了一个叫 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 的代码仍在,但它的注册语句已被注释。

因此,看到默认工具调用区域和下面的问候回复卡片同时存在,是符合当前实现的。

二、一张图理解整条调用流程

DSH 插件调用流程:从插件注册、工具执行、模型结果到最终回复卡片

图 1:问候工具的执行与展示流程。上半部分是 Node.js 服务端,下半部分是浏览器中的消息展示;图中展示成功调用路径,失败时需按错误结果处理。

读图时,沿箭头看四件事:

  1. 先注册,再调用。 apply(ctx) 把工具注册到宿主;真正发起 learning_greet 调用后,才进入 execute(args, exec)。
  2. 先完成业务,再生成模型内容。 execute 返回 { message: ... },结果通过校验后由 output.render 转成内容块,供模型继续生成回复。
  3. 工具展示与 AI 回复分开。 工具调用区域展示调用记录;AI 最终回复是模型在获得结果后生成的另一段内容,两者不需要逐字相同。
  4. 卡片由浏览器按条件添加。 本轮结束、当前节点确为最终回复,且前面存在 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 依次检查:

  1. 当前展示的不是单独的思考过程。
  2. 这次模型请求已经 settled,并存在最终消息记录。
  3. 当前对话轮次已经 closed。
  4. 当前消息的序号,与本轮 turn-tail 记录的收尾消息序号一致。
  5. 同一轮里,在这条消息之前,存在 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,可沿以下入口继续阅读:

消息扩展复用了当前宿主的原生注册及注入方式。升级 DSH 后,应重新核对这些接口并做回归验证,不能把这份版本相关的实现当作永久不变的渲染 API。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!