适用对象:浏览器端 UI 插件(在 dsh Web GUI 里加 Tab、面板、渲染会话节点的那类插件,如
dsh-token-usage)。 这是对dsh-plugin-dev-guide.zh.md(host 侧 Cordis 插件速查)的补充:host 插件三形态/生命周期/事件等基础概念请先读那份。 事实来源:deepseek-harness仓库源码 +docs/subsystems/client-modules.md+docs/cookbook/adding-a-conversation-node.md+ 本工作区dsh-token-usage实战(含一次线上踩坑的修复记录)。
| 形态 | 运行位置 | 入口 | 声明 |
|---|---|---|---|
| host 插件 | Node(agent 主循环、工具、适配器……) | lib/index.js 的 apply(ctx) |
普通 Cordis 插件,cordis.yml 挂载 |
| web 客户端插件 | 浏览器(Web GUI 里跑 React) | lib/client.js 的浏览器 bundle |
package.json 里 dsh.client 声明 |
一个 UI 插件其实是双面包:
@deepseek-ai/dsh-client-ui-xxx/
├── lib/index.js # node half:host loader 挂载它(空 apply 即可,纯浏览器插件)
└── lib/client.js # client half:浏览器执行,真正干活的部分
host loader 只负责把 node half 挂起来;client-modules 服务扫描到 dsh.client 声明后,才会把 client half 编进浏览器的启动图(window.__DSH_BOOT__),由浏览器按需拉取执行。
- host 侧(
packages/client/modules/src/index.ts):扫描 host loader 的 entries,找到声明了dsh.client的包 → 读exports["./client"]定位 bundle → 组成WebBootEntry行({ id(=包名), url: '/plugins/<id>/client.js?rev=<内容hash>', rev, inject?, immediately? })→ 以window.__DSH_BOOT__形式注入 index.html 的<head>。 - 浏览器侧(
packages/client/modules/src/client/system.ts):ClientModuleSystem一张 lazy CJS 表。bundle 脚本执行时只做一件事——window.__ModuleLoader__.load({ id, factory })把工厂登记进表里,除此之外没有任何副作用。真正的副作用(注册 slot、注入样式)都住在factory闭包里,在首次 require/import 物化时才跑。 - 物化:loader 以
factory(require)调用工厂,工厂里的require由模块表应答,解析顺序固定:平台 seed(react/cordis 等)→ shell 自有模块 → 已物化记录 → 已登记的其他工厂(递归物化)→ 大声抛错。最后那个抛错是"跨插件 value import"构建期门禁的运行期镜像。 - boot 完成:
loader.await()+ 全 fiber ACTIVE 扫描;任何一个 entry import 失败 / apply 抛错 / 缺服务 PENDING,就整页停在 "Failed to load plugins"。
推论(全程适用的三条铁律):
- 跨插件协作只能走 cordis 服务(
ctx.slots/ctx.conversationEvents/ctx.locale…),禁止 value-level 跨插件 import(类型 import 会被擦除,是安全的)。 - 平台模块必须 external,bundle 里不能内联 react/cordis 的副本,否则出现两份运行时实例。
- 注册即 effect:
ctx.effect(...)/ctx.slots.register等注册的资源随插件卸载自动回滚,不需要手写清理。
| 参考 | 路径 | 学什么 |
|---|---|---|
| 会话 Tab 插件(与 dsh-token-usage 同形态,首选样板) | packages/client/ui-trajectory |
node half、client apply 样板、节点定义、视图 builder、CSS modules、跨包类型合并 |
| 根布局 + 声明子 slot + 服务面 | packages/client/ui-layout |
register 一个 single slot、declare module 合并 SlotMap、在 @deepseek-ai/cordis 的 Context 上声明服务 |
| conversation.view slot 契约(Tab 环) | packages/client/ui-conversation/src/client/contract/slots.ts |
conversation.view 是 { kind: 'list'; scope: 'session' },按 order 升序渲染 |
| 节点定义如何注册(cookbook) | docs/cookbook/adding-a-conversation-node.md |
Definition 的 match/start/update/buildViewNode 写法 |
| 打包预设(bundle 的官方蓝本,含 banner/intro/footer) | packages/client/tsdown.client.ts |
见 §6,§6 的坑就是对照它发现的 |
| 平台模块表(external 唯一事实源) | packages/client/web/src/platform.ts |
react 家族 + cordis + ui-slots + web-react + ui-primitives + ui-attachment + schema-form |
| 模块系统 node half(扫描/组图/serve) | packages/client/modules/src/index.ts |
dsh.client 声明如何被解析、boot 图怎么生成 |
| 模块系统 browser half(loader 表) | packages/client/modules/src/client/system.ts |
factory 怎么被调用(只传 require)、物化/缓存/循环检测 |
| bundle 形态的官方测试 | packages/client/ui-trajectory/tests/client-bundle.client.spec.ts |
官方如何验证产物的 handoff/require/exports(§7 冒烟测试即仿此) |
| 相关官方文档 | docs/subsystems/client-modules.md、docs/cookbook/adding-a-package.md、docs/development.md |
协议细节、打包、开发环境 |
| 参考 | 路径 | 说明 |
|---|---|---|
| Token 用量分析 Tab(本仓库本体) | 同仓库 src/、build.mjs、scripts/ |
不 fork 仓库、以 esbuild 独立构建、经 dsh plugin add 目录/npm 安装的完整实战;§6/§8 的坑都来自它 |
# 在一个 client 包内:
ls packages/client/ui-trajectory/src/client/ # 标准文件布局
sed -n '1,60p' packages/client/ui-trajectory/src/client/index.ts # apply 样板
sed -n '255,290p' packages/client/tsdown.client.ts # banner/intro/footer 三件套
cat packages/client/web/src/platform.ts # external 平台模块表
grep -n 'DshClientDeclaration' packages/client/modules/src/index.ts # dsh.client 契约dsh-my-ui-plugin/
├── package.json # dsh.client 声明 + exports["./client"](§4)
├── tsconfig.json # module ESNext、moduleResolution bundler、jsx react-jsx、
│ # allowImportingTsExtensions、verbatimModuleSyntax
├── build.mjs # esbuild:node half + client bundle(§6)
├── .gitignore # node_modules/ lib/
└── src/
├── index.ts # node half:/** Host loader entry ... */ export function apply() {}
└── client/
├── index.ts # apply(ctx):locale + 节点定义 + 视图 + slot 注册(§5)
├── locales.ts # NS 常量 + zh/en 词表
├── <name>-contract.ts # 视图数据类型 + declare module 合并快照表
├── <name>-step-definition.ts # conversationEvents 节点定义(事件 → 事实节点)
├── <name>-snapshot-builder.ts # conversationViews 视图 builder(折叠成快照)
├── <Name>View.tsx # 面板组件(useSession / useProjection / props 注入)
└── styles.ts # 自包含样式(免 CSS modules 管线);仓库内包用 .module.css
关键点:
- node half 只是占位:
export function apply(): void {},纯浏览器插件没有 host 行为。但不能没有——host loader 要 import 它。 - 包内相对导入用显式
.ts后缀(verbatimModuleSyntax+allowImportingTsExtensions,esbuild 直接吃 TS)。 - 跨包 import 一律
import type:@deepseek-ai/cordis、@deepseek-ai/dsh-client-runtime/client、@deepseek-ai/dsh-client-ui-conversation/client在你的源码里只允许出现在import type(或import type {}副作用合并)里。这是为了把declare module合并和类型带进编译,同时让 bundle 里一个 value import 都不留。
版本匹配铁律:devDependencies 里的 @deepseek-ai/* 类型版本,必须等于你的目标 dsh web 版本。校验:node -e "console.log(require('/…/node_modules/@deepseek-ai/dsh/package.json').version)"(或 dsh --version)。类型版本漂移的典型症状:字段 undefined、定义对不上——不会在构建期报错,而是跑到浏览器里才炸。
以 ui-trajectory / dsh-token-usage 为准:
import type { Context } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only:把 locale 插件的 Context 合并(ctx.locale)拉进程序
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only:'conversation.view' SlotMap 行(由拥有者包声明)必须在程序里,register 才能通过类型
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { en, NS, zh } from './locales.ts'
import { registerMyView } from './my-snapshot-builder.ts'
import { registerMyStepDefinition } from './my-step-definition.ts'
import { MyView, type MyViewInjected } from './MyView.tsx'
/** 需要的服务:conversation slot、两个 registry、Session 分页、locale。缺一个就 PENDING。 */
export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale']
export function apply(ctx: Context): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'my-plugin: dictionaries')
const t = ctx.locale.bind(NS) // 绑定命名空间
registerMyStepDefinition(ctx) // 事件 → 事实节点
registerMyView(ctx) // 视图 target builder
ctx.slots.inject('conversation.view', () => ctx.slots.register({
name: 'conversation.view',
id: 'my-view',
order: 20, // 升序排 Tab;chat=0/trajectory=10
locale: NS,
label: () => t('view.myView'), // 必须用 thunk,跟随运行时语言切换
inject: (sessionId: SessionId): MyViewInjected => { // 业务注入面(可选)
// 返回组件会用到的回调(loadOlder 之类)
},
}, MyView))
}要点:
label传函数而不是字符串,语言切换时才不用重新注册。ctx.slots.inject('conversation.view', () => …)的注册包在 effect 触发器里——插件卸载时 Tab 自动消失。- 节点定义(
conversationEvents.register)负责把会话事件(如assistant/message带usage)折叠成带稳定 id 的事实节点(last-wins 语义:同名(turn, step)重试以新盖旧);视图 builder(conversationViews.register)把窗口里的节点折叠成快照,useSession(s => s.views.get('myView'))消费。 - 想深挖节点定义/事件回放的约束(稳定业务 id、可重放、不能依赖活内存),读
docs/cookbook/adding-a-conversation-node.md。
官方用 tsdown(tsdown.client.ts),独立插件用 esbuild 对齐同一产物形态。产物必须满足:
window.__ModuleLoader__.load({ id: "<包名>", factory: (require) => { var module = { exports: {} }; var exports = module.exports;
"use strict";
…cjs 代码(含 module.exports = __toCommonJS(...))…
return module.exports; } });
//# sourceMappingURL=client.js.map
逐项对照(左=官方 tsdown,右=esbuild):
| tsdown outputOptions | esbuild | 必须 |
|---|---|---|
banner: 'window.__ModuleLoader__.load({ id, factory: (require) => {' |
banner.js 同内容 |
✅ |
intro: 'var module = { exports: {} }; var exports = module.exports;' |
塞进 banner 开头(factory 体内) | ✅ 最容易漏 |
footer: 'return module.exports; } });' |
footer.js 同内容 |
✅ |
external: [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION] |
external 同表(@deepseek-ai/dsh-client-runtime/client 是免拆例外,也要 external) |
✅ |
define 的 NODE_ENV |
define: { 'process.env.NODE_ENV': … } |
视依赖而定 |
// build.mjs(要点节选)——dsh-token-usage 实战版
const ID = '@deepseek-ai/dsh-client-ui-my-plugin'
const PLATFORM_MODULES = [
'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/cordis',
'@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives', '@deepseek-ai/dsh-client-ui-attachment',
'@deepseek-ai/dsh-client-schema-form', '@deepseek-ai/dsh-client-runtime/client',
]
await build({
entryPoints: ['src/client/index.ts'],
outfile: 'lib/client.js',
bundle: true,
format: 'cjs', // ← 必须是 cjs:产物按 lazy CJS 表运行
platform: 'browser',
target: 'es2022',
sourcemap: true,
external: PLATFORM_MODULES,
define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production') },
banner: { js: `window.__ModuleLoader__.load({ id: ${JSON.stringify(ID)}, factory: (require) => { var module = { exports: {} }; var exports = module.exports;` },
footer: { js: 'return module.exports; } });' },
})为什么必须有
var module = { exports: {} }(踩坑 #1,本次线上事故): 官方 loader(client/system.ts)物化时调用factory(require)——只传 require,不传 module。而 esbuild 的 CJS 输出会直接写module.exports = __toCommonJS(...),工厂体内module未声明 → 浏览器抛ReferenceError: module is not defined→ 整页 "Failed to load plugins"。官方 tsdown 用intro兜住这个声明,esbuild 没有 intro,必须并进 banner。漏掉它的后果不是"功能缺失",是启动直接失败。
检查产物是否合格:
head -c 300 lib/client.js # 应看到 load({...}) 与 var module 声明
grep -c 'var module = { exports' lib/client.js # ≥ 1
grep -o 'require("[^"]*")' lib/client.js | sort -u # 只该有平台模块(本插件通常只剩 react 系)仿照官方 client-bundle.client.spec.ts,用模拟 loader 直接跑 lib/client.js(Node 里用 vm 执行即可,不依赖 jsdom):
// smoke-loader.mjs —— 验证点:登记 id / 物化不抛 / 导出 apply+inject / require 只命中白名单
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
const code = readFileSync('lib/client.js', 'utf8')
let registered = null
const context = {
window: { __ModuleLoader__: { load: ({ id, factory }) => { registered = { id, factory } } } },
console,
}
vm.createContext(context)
vm.runInContext(code, context)
if (registered === null) throw new Error('bundle 没有调用 __ModuleLoader__.load')
console.log('registered id:', registered.id)
const seed = { 'react': { default: {} }, 'react/jsx-runtime': { jsx: () => null, jsxs: () => null, Fragment: 'f' } }
const seen = new Map()
// 与 ClientModuleSystem.materialize 一致:factory 只收到 require
const exports_ = registered.factory((spec) => {
seen.set(spec, (seen.get(spec) ?? 0) + 1)
if (seed[spec] !== undefined) return seed[spec]
throw new Error(`require("${spec}") missed the module table`)
})
console.log('requires:', [...seen.keys()].join(', '))
if (typeof exports_.apply !== 'function' || !Array.isArray(exports_.inject)) {
throw new Error('exports 缺 apply/inject')
}
console.log('SMOKE TEST PASSED') // 然后在浏览器里再验 UI这个测试在修复前后对照上极有价值:旧产物在这里抛
ReferenceError: module is not defined,新产物通过——把"改没改对"压缩到 1 秒内。
官方安装命令 dsh plugin(推荐,在哪里执行都可以,首次自动初始化 profile):
# 1) 构建
npm install && npm run build
# 2) 装进 profile(二选一,都会自动加入 bundle 层并应用其 patch)
dsh plugin --profile <profile> add @my-scope/dsh-client-ui-my-plugin # npm 包
# 或本地目录:
dsh plugin --profile <profile> add /abs/path/to/plugin # 目录 = link 语义
# 3) 若包声明了 dsh.bundle.patch(bundle.patch.yml,内容为 insert 列表)则已自动挂载,
# 无需手改配置;未声明的包需在 cordis.patch.yml 追加:
# - insert:
# - id: my-plugin
# name: '@my-scope/dsh-client-ui-my-plugin'
# 4) 重启 dsh web(launcher 停止再启动,或 kill 后重跑)-
目录依赖 = pnpm link 语义(
link:/path/to/plugin):改源码npm run build后重启即生效,无需拷贝/同步(这是开发期最快的迭代环)。 -
更新:
dsh plugin --profile <p> update <pkg>;卸载:dsh plugin --profile <p> remove <pkg>(依赖与层列表一并摘除,官方教程原文见docs/user/develop/basic/publish.zh.md)。 -
旧式手工安装(file: 拷贝)仅作参考:profile package.json 写
file:依赖再pnpm install;file:是拷贝语义——改了源码必须npm run build后把lib/同步到 profile 的拷贝(或重跑 pnpm install)。校验是否同步:src=/path/to/plugin/lib; dst=~/.dsh/profiles/<p>/node_modules/@my-scope/dsh-client-ui-my-plugin/lib for f in client.js client.js.map index.js; do [ "$(sha256sum $src/$f | cut -d' ' -f1)" = "$(sha256sum $dst/$f | cut -d' ' -f1)" ] \ && echo "$f: 一致" || echo "$f: 不一致(需要同步)" done
-
缓存:bundle URL 带
?rev=<内容hash>,bundle 一变 rev 就变、浏览器自动失效;万一首屏还是旧错误状态,硬刷新一次即可,无需清站点数据。 -
HMR:dev 环境(仓库内开发图)webserver stat-poll bundle 并广播
rebuiltSSE,client-hmr插件逐 fiber 热换;profile 生产方式重启最省事。 -
改动生效链路:改源码 → build → 重启 web(目录/link 安装无需同步;file: 安装需先同步)。
| # | 坑 | 症状 | 修法 |
|---|---|---|---|
| 1 | bundle 缺 CJS module/exports 声明(esbuild 没 intro) |
启动页 "Failed to load plugins";console:failed to import loader entry …: module is not defined |
§6:banner 里 factory 体内声明 var module = { exports: {} }; var exports = module.exports; |
| 2 | 跨插件 value import(把 @deepseek-ai/dsh-* 的代码卷进自己 bundle) |
仓库内:构建期被 purity gate 拒;独立插件:运行期 require("…") missed the module table / 双份 React 实例 |
只允许 import type;运行时协作走 cordis 服务 |
| 3 | external 表与平台模块表漂移(多 extern 或少 extern) | 少 extern:bundle 里内联了平台代码;多 extern:require 解答不了 module table | 以 packages/client/web/src/platform.ts 为准,外加 @deepseek-ai/dsh-client-runtime/client 例外 |
| 4 | 类型版本 ≠ 运行版本(deps 写 rc.6,机器上跑 rc.5) | 编译期不报,浏览器里字段 undefined / 行为错位 | dsh 安装版本与 devDependencies 严格一致 |
| 5 | dsh.client 声明缺失 / platform 写错 |
包被当普通包,浏览器端没有任何表现,也没有报错 | 对照 §4 JSON 逐字段 |
| 6 | exports["./client"] 缺失 |
host 组合期:client bundle not found(并提示先 build) |
package.json exports 加上 ./client |
| 7 | client inject 服务名写错(如 conversationView 少了 s) |
box 停在 boot 页;audit 报 pending (waiting for service: …) |
服务名以官方源码 inject 数组为准:slots conversationEvents conversationViews sessions locale |
| 8 | file: 依赖拷贝不同步 |
改了代码重启没效果;或 profile 里是旧 bundle 继续报错 | 换用 dsh plugin add <目录>(link 语义)开发;仍用 file: 则做 §8 的 sha256 一致性校验 |
| 9 | 忘了 node half / node half 没 apply |
host loader import 失败:failed to import loader entry(import 阶段,非物化阶段) |
src/index.ts 保留 export function apply(): void {} |
| 10 | label 传字符串不传函数 |
Tab 文案不跟随语言切换 | label: () => t('view.xxx') |
| 11 | 产物里 "use strict" 不再是指令序言(module 声明在它前面) |
无;sloppy 模式同样执行 | 可忽略,官方 intro 同样插在代码前 |
| 12 | lib/ 没进 .gitignore |
提交了过时产物,别人拉到旧 bug | .gitignore 加 lib/ |
| 现象(浏览器 console / boot 页) | 阶段 | 指向 |
|---|---|---|
Failed to load plugins + failed to import loader entry <id> (<pkg>): module is not defined |
物化 | 坑 #1:bundle 形态不对 |
failed to import loader entry …(非 module/require 类错误) |
import/apply | node half 缺失(#9)或 apply 里抛错(定位到具体行) |
require("…") missed the module table |
物化 | external 漂移(#3)或跨插件 value import(#2) |
…: pending (waiting for service: …) |
激活 | inject 服务名错误(#7) |
client bundle not found; run pnpm run build before launch |
host 组合 | 没构建或 exports["./client"] 缺失(#6) |
| 页面正常但 Tab 不出现 | 运行期 | slot id 冲突、order 没生效、locale/slot register 抛错——看 apply 阶段 console |
| 改了代码重启没变化 | 部署 | file: 拷贝未同步(#8)或浏览器缓存(§8) |
outputOptions: {
entryFileNames: 'client.js',
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
footer: 'return module.exports; } });',
intro: 'var module = { exports: {} }; var exports = module.exports;',
// ^ esbuild 没有 intro —— 独立插件把它并进 banner 开头(§6)
},
external: [...CLIENT_EXTERNALS], // PLATFORM_MODULES + '@deepseek-ai/dsh-client-runtime/client'- host 侧插件基础:
dsh-plugin-dev-guide.zh.md(本工作区) - 官方 cookbook:
docs/cookbook/adding-a-package.md、docs/cookbook/adding-a-conversation-node.md、docs/cookbook/extension-cookbook.md - 子系统:
docs/subsystems/client-modules.md、docs/development.md - 官方笔记(含问题复盘,都值得读):
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md、.agents/notes/archived/bug-fix/2026-07-23-demo-web-builds-client-bundles.md
{ "name": "@deepseek-ai/dsh-client-ui-my-plugin", "type": "module", "main": "lib/index.js", "exports": { ".": { "default": "./lib/index.js" }, "./client": { "default": "./lib/client.js" }, // ← client-modules 靠它定位 bundle "./package.json": "./package.json" }, "dsh": { "client": { "inject": [ // 包名数组 = 图依赖边(参考二期加载) "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation" ], "platform": "web" // 非 "web" 会被扫描器忽略 // "immediately": true // 可选:boot 一阶段预取;UI 插件一般不加(懒加载) } }, "files": ["lib/index.js", "lib/client.js", "README.md"], "devDependencies": { "esbuild": "^0.25.0", "typescript": "^5.5.0", "react": "^18.2.0", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "0.1.0-rc.6", // ← 版本必须与运行的 dsh 一致(见踩坑 #4) "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6", // ... 你类型依赖到的 @deepseek-ai/* 包 } }