DSH 插件的 Host 与 Client:为什么「只加载了 Host」,以及要不要自己写 Client 加载器
本文基于
@deepseek-ai/dsh@0.2.1-rc.5与dsh-starter起步模板(pnpm@10.34.5、Node^22.19.0 || >=24.0.0)整理。不同版本的插槽名与SlotMap声明请以官方包为准。
TL;DR
Host + Client不是两个插件,而是同一个 npm 包的两个「面」:Host 跑在服务端 Node 进程,Client 跑在浏览器里的官方 Web 页面。- 项目源码里找不到 Client 加载逻辑是正常的 。装载由官方包里的
ClientModuleRegistry、Host 路由/plugins/<id>/client.js、浏览器里的__ModuleLoader__与 cordis Loader 完成,这些代码都在node_modules/@deepseek-ai/*和官方 Web 前端里,不在你的仓库中。 - 因此,绝大多数情况下不需要自己写加载器,只需要让插件包符合官方约定的格式,并重新打包注册。
- 排查的第一步永远是:页面 boot 清单里有没有
<包名>/client.js。没有 → 查声明 / 构建 / 注册;有 → 查客户端代码执行与插槽注册。
1. 背景:这是什么项目
dsh-starter 是一个不 Fork 官方源码 的 DeepSeek Harness 扩展起步项目。官方 @deepseek-ai/dsh-* 包按精确版本从 npm 安装,项目自己只提供 Host 插件与 Web 客户端插件,通过官方 UI 插槽(slots)挂进原生界面:
arduino
官方 @deepseek-ai/dsh-* (npm 精确版本 0.2.1-rc.5)
│
├─ Host 侧:cordis 插件树 ── 你的插件行(cordis.patch.yml)
│
└─ Web 侧:官方 Composer / Sidebar / Conversation
└─ 插槽 ← 你的客户端模块(slots.register)
pnpm dev 会依次:构建插件 → 用官方 CLI 从官方 Web 默认配置 脚手架出 Profile(.dsh-home/profiles/starter)→ 把每个插件打成内容寻址的 .tgz 并注册 → 启动官方 dsh web。项目里没有任何一份 harness 服务端实现。
模板自带三个插件,正好覆盖三种组合:
| 插件 | 包名 | 形态 | 效果 |
|---|---|---|---|
starter/ |
dsh-starter-plugin |
Host + Client | 侧栏「示例」图标 + main 面板「第一个插槽面板」 |
expert-team/ |
dsh-expert-team-plugin |
Host + Client(Host 仅占位) | 输入框左侧「专家团」浮层,召唤后跳转 Starter 面板 |
host-only/ |
dsh-host-only-plugin |
仅 Host | 提供 hostHeartbeat 服务与生命周期,无任何 UI |
构建与注册都是自动发现的:packages/plugins/ 下每个含 package.json 的目录算一个插件 (pnpm-workspace.yaml 里声明了 packages/plugins/*),不需要在根 package.json 里逐个登记。
2. Host + Client 到底是什么
2.1 一个包,两个面
bash
packages/plugins/starter/ ← 一个插件、一个包
├── package.json ← 声明两个入口
├── cordis.patch.yml ← 让官方把包插进 Profile 插件树
├── src/index.ts ──→ dist/index.js (Host,Node ESM)
└── src/client.tsx ──→ dist/client.browser.js (Client,浏览器 CJS)
| Host 面 | Client 面 | |
|---|---|---|
| 运行位置 | dsh web 的 Node 进程(cordis 插件树) |
浏览器里的官方 React Web 壳,同一个页面 |
| 入口 | exports["."] → dist/index.js |
exports["./client"] → dist/client.browser.js |
| 打包 | format: esm、platform: node、packages: external |
format: cjs、platform: browser,React 与 @deepseek-ai/* 全部外置 |
| 装载体 | cordis.patch.yml 写进 Profile,官方加载器调用 apply(ctx) |
window.__ModuleLoader__.load({ id, factory }),官方 client-modules 列进 boot 清单 |
| 声明字段 | dsh.bundle.patch |
dsh.client |
| 典型能力 | ctx.provide 服务、ctx.effect 生命周期、logger、文件 / 进程 / 会话 |
ctx.slots 注册 UI、ctx.layout.selectPanel |
| 可观测证据 | 终端 stdout 日志 | 页面 boot 清单里的 <包名>/client.js |
两边代码结构很像(都是 name / inject / apply(ctx),都 import type { Context } from '@deepseek-ai/cordis'),但两个 ctx 属于不同的运行时 :Host 的 ctx 来自服务端 cordis 上下文,Client 的 ctx 是浏览器侧上下文,注入的是 slots、layout 这类浏览器能力。
2.2 一个便于记住的比方
把官方 dsh web 想象成一家已经装修好的店:侧栏、输入框、主区域都是店里的固定挂钩(插槽)。想在店里挂一块自己的牌子,不能直接冲进去挂,而要:
- 先去后台管理处登记:「我这个包要在店里挂东西」→ Host 面;
- 管理处查登记册,把那块牌子送进店里挂上 → Client 面。
所以:Host 让官方认识你,Client 让用户看见你。
2.3 为什么不能只有 Client
关键在发现机制:官方 ClientModuleRegistry 是扫描 Host loader entries 来找 dsh.client 声明的,不是自己扫描浏览器目录。少了 cordis.patch.yml 与 src/index.ts,浏览器模块永远不会出现在 boot 清单里------哪怕包里确实有 client.browser.js。
这就是模板里「没有仅 Client 形态 」的由来。expert-team 的 UI 全在浏览器端,仍保留了一个几乎空的 Host 入口:
ts
// packages/plugins/expert-team/src/index.ts(节选)
export const name = 'dsh-expert-team-plugin'
export const inject = [] as const
export function apply(ctx: Context): void {
ctx.effect(() => {
ctx.logger('dsh-expert-team-plugin').debug('expert team UI mounted from dsh-expert-team-plugin/client')
return () => {}
}, 'dsh-expert-team-plugin.lifecycle')
}
2.4 两面不能直接互相调用
一个在服务器上,一个在浏览器里,是两个独立程序。starter 的 Host 面提供了 greeting() 服务,浏览器组件调不到;要做 client ↔ host 数据交换,需要官方的 remote / API 层,模板刻意没有实现。
插件之间也不互相 import,而是靠运行时约定 协作。expert-team 想跳到 starter 的面板,做法是查 main 插槽里有没有约定好的 key:
tsx
const available = ctx.slots
.entriesOfSlot('main')
.some(entry => entry.options.key === STARTER_PANEL)
if (!available) return false
ctx.layout.selectPanel(STARTER_PANEL as MainPanelId)
3. 怎么区分一段代码是 Host 还是 Client
不是靠「代码看起来像不像」,项目里留有明确标记,按可靠程度看三处。
3.1 看 package.json(最权威)
| 字段 | 含义 | 属于哪面 |
|---|---|---|
exports["."] → dist/index.js |
主入口 | Host |
exports["./client"] → dist/client.browser.js |
浏览器入口 | Client |
dsh.bundle.patch |
「把我这行写进 Profile 登记册」 | Host |
dsh.client |
「我还有界面代码,请发给浏览器」 | Client |
starter/package.json 四个字段都有 → 双面;host-only/package.json 只有 exports["."] 与 dsh.bundle.patch → 仅 Host。
3.2 看源文件与所用 API
约定很死:src/index.ts 是 Host,src/client.tsx 是 Client。构建脚本就是按这两个文件是否存在来判定产出:
js
const hasHost = existsSync(join(pluginDir, 'src/index.ts'))
const hasClient = existsSync(join(pluginDir, 'src/client.tsx'))
| Host 面会出现 | Client 面会出现 |
|---|---|
ctx.provide(...)、ctx.effect(...)、ctx.logger |
ctx.slots.register(...)、ctx.layout.selectPanel(...) |
process.stdout、setInterval 等 Node 能力 |
JSX / <svg>、window、document |
inject = [] |
inject = ['slots']、['slots', 'layout'] |
文件头 Host entry |
文件头 Browser entry、type-only 的 dsh-client-ui-*/client 导入 |
3.3 看产物与运行时证据
dist/index.js:ESM、Node、依赖外置 → Host;dist/client.browser.js:被window.__ModuleLoader__.load({...})包起来的 CJS → Client;- 跑起来后:终端日志 是 Host 的证据,页面加载清单里出现
<包名>/client.js是 Client 的证据。模板的pnpm smoke正是分别验证这两条。
3.4 两个易错点
- 别用
apply(ctx)或import type { Context } from '@deepseek-ai/cordis'判断 :两面都这么写,区别在ctx里用了什么能力。 - 别被注释内容骗了 :
expert-team/src/index.ts通篇在解释客户端模块如何被发现,但它仍是 Host 面。判断看字段与产物,不看它谈论了什么。
严格来说应交叉验证三处:
package.json是「声明」、源文件是「实际写了什么」、dist是「最终打出来的东西」。三者对不上(例如删了client.tsx却忘了删dsh.client),就会出现「声明有 Client、浏览器收不到」的情况。
4. Client 模块究竟是怎么被加载的
这条链路很重要,因为它直接回答了「要不要自己写加载器」。
scss
┌─ 你的仓库 ─────────────────────────────────────────────────┐
│ cordis.patch.yml → 告诉官方「把我加进 Host 插件树」 │
│ package.json → dsh.client 声明 + exports["./client"] │
│ src/index.ts → Host 面,构建成 dist/index.js │
│ src/client.tsx → 构建成带 __ModuleLoader__ 外壳的 CJS │
└────────────────────────────────────────────────────────────┘
│ scripts/dev.mjs:pack → dsh plugin add -w → 注册进 Profile
▼
┌─ 官方 @deepseek-ai/dsh(node_modules,不在你的仓库)─────────┐
│ ① runProfile() 按 Profile 依赖 + patch 装配 cordis 插件树 │
│ → 每个插件的 Host apply(ctx) 在这里执行 │
│ ② ClientModuleRegistry(inject: ['loader'])扫 loader entries │
│ → 挑出声明了 dsh.client 的包,算出模块顺序 │
│ ③ 生成 window.__DSH_BOOT__,并把 script / global 行注入 HTML │
│ ④ Host 路由 /plugins/<id>/client.js 返回打包产物(cookie 认证)│
└────────────────────────────────────────────────────────────┘
│ HTTP
▼
┌─ 官方 Web 前端(浏览器)────────────────────────────────────┐
│ ⑤ 每个 client.js 只执行 window.__ModuleLoader__.load(...) │
│ → 登记 { id, factory },模块体(含 CSS)此时并不执行 │
│ ⑥ cordis Loader 按 inject 顺序 import → 执行 factory │
│ → 得到 module.exports,调用客户端模块的 apply(ctx) │
│ ⑦ ctx.slots.register() 把组件写进对应插槽的条目表 │
│ ⑧ 官方 owner 组件渲染到那一格时按 order / priority 取条目 │
│ → React 挂载,界面出现 │
└────────────────────────────────────────────────────────────┘
几个关键细节:
- Host 是入场券 :
ClientModuleRegistry的输入是 Host 插件树,没有 loader entry 就拼不出模块表。 - 前端代码也是 Host 给的 :
/plugins/<id>/client.js由 Host 路由提供,浏览器不会自己去磁盘找插件包。 - 惰性工厂 :脚本执行时只登记 factory,模块体在第一次
import时才执行并缓存。 - 模块 id 是包名 :外壳里的
id用的是package.json的name,不是client.tsx里export const name的值;页面清单里出现的字符串是<包名>/client.js,而不是client.browser.js。 - 共享单例 :客户端打包时
react/react-dom/@deepseek-ai/*全部外置,由官方页面提供,绝不能内联第二份,否则 hooks 报错。
一次点击「示例」图标的完整链路,就是第 ⑧ 步的官方壳渲染 <button>、调用 layout.selectPanel(id),再由 main 插槽按 key 找到你的面板组件。这也是为什么 sidebar.panellist 的 id 必须与 main 的 key 完全一致。
5. 「项目完全没有处理 Client 插件的逻辑」→ 要不要自己写
5.1 结论先说
大概率不用写,而且仓库里搜不到加载逻辑是正常的。 在模板项目里,跟 Client 装载有关的自研代码只有三处,且都不是加载器:
scripts/build-plugin.mjs:把client.tsx打包成window.__ModuleLoader__.load({...})外壳;scripts/dev.mjs:打包 tgz、注册进 Profile、启动官方dsh web;scripts/smoke.mjs:检查 页面清单里有没有<包名>/client.js。
真正执行加载的是官方包与官方 Web 前端。你要写的不是「加载逻辑」,而是「一个符合官方格式的插件」。
5.2 30 秒自测
在目标项目里确认三件事:
package.json里有没有@deepseek-ai/dsh(以及对应的dsh-client-ui-*);- 启动命令是不是
dsh ... web,或node node_modules/@deepseek-ai/dsh/lib/bin.js --profile ... --port ...; - 页面源代码里有没有
window.__DSH_BOOT__或__ModuleLoader__。
5.3 三种情况对照
| 实际情况 | 要不要自己写 | 具体做什么 |
|---|---|---|
A. 跑的是官方 dsh web,只是插件只有 Host 面 |
不用写加载器 | 补 dsh.client 声明、src/client.tsx、依赖与 tsconfig,重新构建注册(见第 6 节) |
B. 跑官方 dsh web,但构建脚本只会打 Host、不产出 client.browser.js |
要写,但只是打包脚本 | 照 build-plugin.mjs 第 60--93 行补一段 esbuild 配置:CJS + browser + external React/@deepseek-ai/* + window.__ModuleLoader__.load({...}) 外壳,不是写运行时加载器 |
| C. 项目自己用 cordis 起 Node 服务、页面也是自己画的,没跑官方 Web 壳 | 要写,但代价很大 | 见 5.4 |
5.4 情况 C 为什么通常不划算
如果连官方 Web 壳都没有,需要自己实现的远不止「加载 client」:
- Host 侧:扫已加载插件的
dsh.client→ 生成模块清单 → 注入 HTML → 提供/plugins/<id>/client.js静态路由与认证; - 浏览器侧:实现
window.__ModuleLoader__(load({ id, factory })惰性登记 + import 执行缓存),并实现require对react/@deepseek-ai/*外置依赖的解析; - 按
inject顺序实例化客户端 cordis fiber,调用每个模块的apply(ctx); - 提供浏览器侧
ctx.slots/ctx.layout,以及会按order取条目并渲染的官方 owner 组件------否则ctx.slots.register()注册了也没人接收。
这基本等于重写官方 Web 客户端。更务实的做法是接入官方 dsh web + Profile 机制,让自己的插件走标准插槽。
6. 实操:把「仅 Host」插件改成 Host + Client
以模板里的 host-only/ → 双面为例,照 starter/ 抄即可。
6.1 保留 Host 那一半
src/index.ts 与 cordis.patch.yml 必须留着(原因见 2.3)。
6.2 改 package.json,补齐四处
jsonc
{
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./client": { "types": "./dist/client.d.ts", "default": "./dist/client.browser.js" },
"./package.json": "./package.json"
},
"files": ["dist", "cordis.patch.yml"],
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web", "inject": [] }
}
}
devDependencies 里补 react、@types/react 和用到的 @deepseek-ai/dsh-client-ui-*,版本必须与官方包完全一致 (此处 0.2.1-rc.5)。files 必须包含 dist,否则产物进不了 tgz。
6.3 改 tsconfig.json
host-only/tsconfig.json 只有 "lib": ["ES2022"] 与 "types": ["node"],写 JSX 会直接编译失败。复制 starter/tsconfig.json,关键是:
jsonc
{
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
构建脚本每构建完一个插件都会跑一次 tsc,编译失败会中断整个 build,dist 可能只剩一半产物。
6.4 新建 src/client.tsx
照 starter/src/client.tsx 的固定外壳:type-only 导入对应插槽包以合并 SlotMap,export const inject = ['slots'] as const,在 apply(ctx) 里用 ctx.slots.inject(插槽名, () => ctx.slots.register(...)) 包住注册。三条硬规则:
sidebar.panellist的id===main的key;- 只画 UI,不要自己造按钮(官方壳拥有
button、tooltip、选中态与无障碍属性); - 成对注册、成对回收,卸载后界面回到官方原样。
6.5 安装依赖并构建
bash
pnpm install
pnpm build
pnpm typecheck
成功标志 :终端出现 built <包名> [host+client]。只有 [host] 说明 src/client.tsx 没被识别(文件名或路径不对)。
6.6 重新注册并验收
bash
pnpm clean
pnpm dev
pnpm smoke
看到 registering <包名> ... 才是真的重新注册;smoke: ok --- ... served N client module(s) 表示客户端模块已进入页面清单。最后打开带 token 的链接,检查侧栏图标与主面板。
客户端模块没有 HMR:官方 client-modules 在 boot 时按清单装载。改动后的流程固定是「重建 → 重新打包 → 重启服务 → 刷新页面」。
7. 排查手册
7.1 第一步:先分叉,别混着查
在页面 HTML 或 DevTools → Network 里搜 <包名>/client.js:
- 搜不到 → 服务端根本没把模块发给浏览器,问题在声明 / 构建 / 注册(7.2 的 L0--L3);
- 搜得到、界面没变化 → 模块已送达,问题在模块执行 / 插槽注册 / React(L4)。
模板的 smoke.mjs 就是自动做这个判断:从所有 package.json 里筛出 manifest.dsh?.client,逐个断言 <name>/client.js 出现在 HTML 中。
7.2 分层检查表
| 层 | 检查什么 | 成功标志 / 常见失败 |
|---|---|---|
| L0 声明 | package.json 的 dsh.client、exports["./client"]、files |
最常见的漏项就是没有 dsh.client |
| L1 产物 | dist/client.browser.js 是否存在、开头是不是 window.__ModuleLoader__.load;pnpm pack 的 tgz 里有没有它 |
不在 dist → build 没跑或 tsc 挂了;在 dist 不在 tgz → files 漏了 dist |
| L2 注册 | pnpm dev 日志是否出现 registering <包名>;Profile 的 package.json 里 dependencies[包名] 的 file: 路径 |
一直显示 all N plugin(s) already registered → 改了源码没重新 build/pack |
| L3 传输 | 页面清单 / Network 里有没有 <包名>/client.js;dsh --profile starter --dump-config 看插件 id |
没有 → 回到 L0/L2;Profile 有 id 但清单没有 → 检查包是否真的被重新解包 |
| L4 执行 | Console 有无报错;window.__ModuleLoader__ 里有没有模块 id;slots.register 的插槽名 / id 是否正确 |
React 被内联导致 hooks 报错、插槽名拼错、id 与 key 不一致、inject 少写了 'slots' |
按阶段看故障现象(来自模板的 docs/tech-share-slots.html 时序表):
| 阶段 | 典型现象 | 优先检查 |
|---|---|---|
| 组前端清单 | Host 正常、页面正常,就是没模块 | loader entry 是否有 dsh.client、bundle 路径是否存在 |
| 浏览器装配 | Console 报 loaded without registering / 白屏 |
打包外壳是不是 load({ id, factory })、模块 id 是否与清单一致 |
| 插槽注册 | 模块加载了,但界面没变化 | 插槽名拼写、sidebar.panellist.id 与 main.key 是否一致 |
7.3 最常见的坑
- 只改源码不重新 pack :
dev.mjs用 tgz 的 sha256 内容哈希 当路径,只有内容变了才重新执行dsh plugin add。必须pnpm build后再pnpm dev,只刷新浏览器无效。 - pnpm 命中旧解包目录 :同名同版本会复用旧目录。
dev.mjs已主动删除profileDir/node_modules/<包名>;手动 add 时要自己删,且必须带-w(Profile 是单包 pnpm workspace),否则报ERR_PNPM_ADDING_TO_ROOT。 - 旧产物残留 :
build-plugin.mjs每次先删index.js、client.browser.js再生成;中途tsc失败会留下「只有一半」的 dist。拿不准就pnpm clean(会删.dsh-home、.artifacts、各插件dist)后全量重建。 - 删掉 Host 入口做「纯客户端插件」:不支持,L0 全对也不会进清单。
- React 被内联 :客户端打包必须外置
react/react-dom/@deepseek-ai/*,否则模块一执行就报 hooks 错误,表现为「加载了但没 UI」(属于 L4 而非 L3)。 - 找错字符串 :boot 清单里是
<包名>/client.js,不是client.browser.js。 - Profile 残缺 / 服务起不来 :删掉
.dsh-home重新pnpm dev,脚本会重新脚手架。
7.4 两条独立的验收证据
pnpm smoke 把 Host 与 Client 分开验证,设计思路值得借鉴:
- Client :所有声明了
dsh.client的插件,<name>/client.js都出现在官方 boot 清单里; - Host :终端输出里出现
[dsh-host-only-plugin] applied(host-only没有 UI,特意打印一行标记让加载可观测)。
8. 速查清单
ini
[ ] 插件包保留 src/index.ts + cordis.patch.yml(Host 入场券)
[ ] package.json 有 exports["./client"]、dsh.client、files 含 dist
[ ] 依赖版本与官方 @deepseek-ai/dsh-* 完全一致
[ ] tsconfig 有 jsx: react-jsx 和 DOM lib
[ ] src/client.tsx 用 slots.inject → slots.register,id === main.key
[ ] pnpm build 输出 [host+client]
[ ] 客户端打包 external 了 react / @deepseek-ai/*
[ ] pnpm dev 日志出现 registering <包名>
[ ] 页面清单能搜到 <包名>/client.js
[ ] pnpm smoke 通过,界面回到官方原样的验收反向成立(删插件无残留)
9. 小结
Host + Client 描述的是同一个包在两个运行时里的两份入口,不是两个插件,也不是传统意义上的前后端通信:
- Host 负责登记与服务端能力(服务、生命周期),也是官方发现浏览器模块的唯一入口;
- Client 负责界面,通过官方插槽把组件填进原生骨架;
- 装载链路完全由官方包和官方 Web 前端实现,业务项目只需要产出符合约定的包并注册进 Profile。
所以遇到「只加载了 Host」,正确的顺序是:先分清「模块没送到浏览器」还是「送到了但没渲染」,再按声明 → 产物 → 注册 → 传输 → 执行逐层排查;只有在根本没有使用官方 dsh web 与 Web 壳时,才需要考虑自己实现加载器,而那通常意味着要重写半个官方客户端,不划算。
附录 A:术语速查
| 术语 | 含义 |
|---|---|
| Profile | 官方 CLI 脚手架出的独立 pnpm 项目,插件以 file: 依赖注册进去 |
| loader entry | cordis.patch.yml 插入 Profile 的那一行,官方据此加载插件 Host 入口 |
ClientModuleRegistry |
官方 Host 侧服务,扫 loader entries 的 dsh.client,产出前端模块清单 |
__DSH_BOOT__ |
注入 HTML 的浏览器模块清单(rev / release / entries / batches) |
__ModuleLoader__ |
浏览器侧惰性 CJS 模块表,load({ id, factory }) 只登记不执行 |
| 插槽(slot) | 官方 UI 壳预留的挂载点,如 sidebar.panellist(list)、main(keyed) |
| list / keyed | list 槽用 id + order 排序去重;keyed 槽用 key 命中,两者需成对 |
附录 B:文中涉及的文件
| 文件 | 作用 |
|---|---|
README.md |
项目定位、三个关键契约、常见问题 |
packages/plugins/README.md |
插件形态约定、最小清单、常用插槽表 |
package.json / pnpm-workspace.yaml |
官方包精确版本、工作区与 onlyBuiltDependencies |
scripts/build-plugin.mjs |
按源文件判定两面、esbuild 打包、__ModuleLoader__ 外壳、tsc |
scripts/dev.mjs |
Profile 脚手架、内容哈希打包、注册、启动 dsh web |
scripts/smoke.mjs |
无头验收:boot 清单 + Host stdout 标记 |
scripts/clean.mjs |
清理 .dsh-home、.artifacts、各插件 dist |
docs/tech-share-slots.html |
启动时序与浏览器装配的完整架构图、故障阶段表 |