从零写一个 DSH 插件:在左侧菜单栏加一个「测试」
这篇博客记录一次真实的、从零到跑通的插件开发过程:在 DeepSeek Harness(下称 DSH)Web 界面的左侧菜单栏里加一个「测试」入口,点进去是一个自己的页面。
适合第一次接触 DSH 插件开发的读者。文中所有代码都是这次实际装进
webprofile 并验证生效的版本,不是伪代码。
0. 先给结论
如果你的问题只有三句,答案在这里:
- 这确实就是一个插件。 左侧一个入口 + 一个页面 = 一个插件包(DSH 里叫 bundle)。不是改配置、不是改前端源码,而是一个独立目录 + 一份
package.json+ 一个补丁文件 + 一段浏览器端代码。 - 插件目录可以放在任何地方。 它不需要住在 DSH 的安装目录里,也不需要叫某个固定名字。安装时给它一个绝对路径就行,DSH 会在 profile 里建立一个链接指回你的目录。
- 「插件位置」不靠坐标,靠插槽。 你永远不写
left: 12px、也不用document.querySelector去插 DOM。你声明"我往sidebar.panellist这个插槽里放一样东西",宿主就在它自己的布局里给你留位置。所以"只要注册就行,不用管位置"这句话对一半:像素级位置确实不用管,但"注册到哪个插槽"本身就是位置的全部决定权。
1. 背景:DSH 说的"插件"到底是什么
DSH 的口号是"万物皆是插件",这句话在架构上是字面意思:界面的每一块、每一个工具、每一条命令,都是某个插件注册出来的。
对写插件的人,只要记住三层概念:
| 概念 | 是什么 | 在我们这个例子里 |
|---|---|---|
| bundle(插件包) | 一个 package.json 里带 dsh.bundle.patch 的 npm 包。它通过一份 YAML 补丁往 Loader 里插入插件行 |
@local/dsh-test-panel |
| 插件行(Loader entry) | Loader 配置里的一行 { id, name, config },被宿主加载、激活、可被启用/停用 |
test-panel |
| profile(配置档) | 一套"装了什么、启用什么"的组合。DSH 可以有多套 profile,互不影响 | web(就是你正在用的这个) |
一个 bundle 可以有"两个半身":
- Host 半身 :
index.js,跑在 Node 进程里。负责工具、服务、配置、会话数据。 - Client 半身 :
client.js,跑在浏览器里。负责画界面。由package.json的dsh.client声明。
我们这个插件只画界面,所以 Host 半身是空的 apply() {},Client 半身是全部内容。
一次完整的加载链路
你的目录 test-panel/
package.json ── dsh.bundle.patch → cordis.patch.yml → 插入一行 Loader entry
└─ dsh.client → 声明"我有一个浏览器端模块"
│
▼
宿主(Node)启动 / 重载时:
扫描已启用的 Loader 行 → 组装 boot graph(启动图)
│
▼
浏览器:按 boot graph 请求 /plugins 下的 bundle
→ 执行 window.__ModuleLoader__.load({ id, factory })
→ 只是"登记"一个工厂,模块体还没跑(lazy)
│
▼
第一次真正需要它时(materialize):factory(require) 执行
→ 拿到 { inject, apply }
→ apply(ctx) 执行
│
▼
apply 里 ctx.slots.register(...) → 插槽里出现你的组件
→ 宿主渲染 → 你看到「测试」那一行和那个页面
三个细节值得记住:
factory里不要产生副作用。真正的资源注册放在apply(ctx)里,用ctx.effect(...)包起来,返回清理函数。dsh.client.immediately: true表示"在启动第一阶段就注册",不等公共批次。我们要在开机时就往侧边栏加一行,所以必须立即。dsh.client.inject是"信息性的包名依赖",不是 Cordis 服务注入,作用只是排定激活顺序(让声明插槽的那些包先起来)。
2. 成品长什么样
侧边栏最终是:
插件 · 自动化任务 · 测试 · 更多
点「测试」后,中间栏出现一个页面:标题「测试」、一段说明、一个「点我一下」按钮和点击次数。
安装完成后,profile 里的真实状态(后面第 8 节会解释每一个字段):
jsonc
// C:\Users\yan\.dsh\profiles\web\package.json
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {
"@local/dsh-test-panel": "link:C:/Users/yan/Documents/deepseek-harness/default-workspace/test-panel"
},
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"@local/dsh-test-panel"
]
}
}
}
而 C:\Users\yan\.dsh\profiles\web\node_modules\@local\dsh-test-panel 是一个 junction(目录链接),指回工作区里的源目录:
Junction → C:\Users\yan\Documents\deepseek-harness\default-workspace\test-panel
这就是为什么"目录放哪都行":真正被 Node 解析的位置是 profile 的 node_modules,而它只是一个指回你目录的链接。
3. 一个插件包的最小结构
test-panel/
├─ package.json # 清单:我是包、我有补丁、我有浏览器端
├─ cordis.patch.yml # 往 Loader 插一行
├─ index.js # Host 半身(这里为空)
├─ client.js # Client 半身:所有界面逻辑
├─ locale/
│ ├─ zh.json # 插件管理页显示的名称/描述(中文)
│ └─ en.json # 同上(英文)
└─ icon.svg # 插件管理页的图标
3.1 package.json
json
{
"name": "@local/dsh-test-panel",
"version": "1.0.0",
"private": true,
"description": "测试面板:在左侧边栏新增"测试"入口,并提供一个可交互的测试页面。",
"type": "module",
"exports": {
".": "./index.js",
"./client": "./client.js",
"./locale/*.json": "./locale/*.json",
"./icon": "./icon.svg"
},
"files": [
"index.js", "client.js", "cordis.patch.yml", "locale/*.json", "icon.svg"
],
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": {
"platform": "web",
"immediately": true,
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-ui-sidebar"
]
}
}
}
逐字段说明:
| 字段 | 作用 | 注意 |
|---|---|---|
name |
包名,必须唯一 | @local/... 是本地作用域,只在你的 profile 里有意义 |
exports["."] |
Host 入口 | 必须是可被 import 的 ESM |
exports["./client"] |
浏览器端入口 | dsh.client 存在时宿主会把它当 bundle 发出去 |
exports["./icon"] |
插件图标 | 也支持在 package.json 顶层写 icon: "./x.svg" |
exports["./locale/*.json"] |
展示文案 | 插件管理页不必激活插件就能读到 |
files |
打包时的白名单 | 本地目录安装是链接目录,files 不过滤,所以新增文件别忘同步这里 |
dsh.bundle.patch |
补丁文件路径 | 少了它,这个包只是个普通 npm 包,不会进 Loader |
dsh.client.platform |
客户端平台 | Web 消费 web |
dsh.client.immediately |
启动第一阶段注册 | 侧边栏入口必须开机就位 |
dsh.client.inject |
激活顺序依赖 | 只排序,不提供服务 |
dsh.client.external |
额外需要的模块表请求 | 我们只用 react,属于基线,不用写 |
3.2 cordis.patch.yml
yaml
- insert:
- id: test-panel
name: '@local/dsh-test-panel'
YAML 补丁是 Loader 的配置方言:insert 往当前层插入行。id 是你以后改配置、启停时用的把手;name 是包名。一个 bundle 可以插入多行(比如一个"页面"插件和一个"后台任务"插件)。
3.3 index.js
js
/**
* Host half of the test-panel bundle.
*
* The sidebar row and the panel page are drawn by the Client module
* (`client.js`); on the Host side the bundle only needs to exist so the
* profile can mount it and list it in Plugin Manager.
*/
export function apply() {}
空的。因为这次没有 Host 侧能力。但文件不能省 :exports["."] 指向它,Loader 会真的加载这个行。
3.4 locale/*.json
json
{
"meta": {
"title": "测试面板",
"description": "在左侧边栏新增"测试"入口,并打开一个可交互的测试页面。"
}
}
这是给插件管理页 看的展示元数据,和运行时界面文案是两套东西(后者在 client.js 里注册,见第 5 节)。两个都写,用户才在"装了什么"和"界面上写了什么"两处都看到人话。
3.5 icon.svg
自己画的一个烧瓶("测试"的隐喻),64×64,圆角底板 + 描边瓶身 + 一滴液体。要求:SVG/PNG/JPEG/WebP,≤256 KiB,路径必须在包内,不能用外链。
4. 核心机制一:插槽(Slot)系统
这是理解 DSH 界面插件最重要的概念。
4.1 一句话模型
宿主声明插槽,插件往插槽里塞组件。宿主决定插槽长在哪、占多大、周围有什么,插件只决定塞什么。
插槽有三种 kind,注册参数也不同:
| kind | 含义 | 注册时必须给 | 例子 |
|---|---|---|---|
single |
单占位,只能一个 | (无) | sidebar.brand.mark、settings.header |
list |
有序列表 | id(必填)、order(可选)、label(可选) |
sidebar.panellist、settings.section |
keyed |
按 key 派发 | key(必填) |
main、plugins.tab |
我们用到两个:
sidebar.panellist(list):左侧的全局面板行。main(keyed):中间栏,按侧边栏选中的面板 id 派发。
4.2 左侧边栏的真实结构
从插槽拓扑里可以读出(这是 Slots.listSubTree 的实际输出,节选整理):
sidebar (single) 整个左栏
├─ sidebar.brand.mark (single) 品牌图标
├─ sidebar.brand.name (single) 品牌名
├─ sidebar.toggle.badge (single) 折叠按钮里的小红点
├─ sidebar.panellist (list) ★ 全局面板行:插件 / 自动化任务 / 测试
├─ sidebar.panellist.actions (list) ★ 「更多」菜单里的行
├─ sidebar.workspaces (single) 会话/工作区列表
├─ sidebar.settings (single) 设置区
└─ sidebar.footer.action (list) 设置旁边的动作
sidebar.panellist 的插槽说明原文是:
Global panel icons. Each list id addresses the matching main panel; the sidebar owns the button and resolves its label from list metadata.
翻成人话:这个列表里的每一个 id,同时在 main 里对应一个面板;按钮由侧边栏自己画,标签从注册元数据里取。 所以注册一行 = 自动获得"图标 + 标签 + 点击切换面板"的完整行为,你一个 <button> 都不用写。
4.3 面板行是怎么渲染的
侧边栏内部(dsh-client-ui-sidebar 的构建产物)大致是这样:
js
// 从插槽注册里收集面板列表,按 order 升序
const next = ctx.slots.entriesOfSlot('sidebar.panellist')
.map(({ options }) => ({ id: options.id, order: options.order ?? 0,
label: resolveSlotLabel(options.label) ?? options.id }))
.sort((a, b) => a.order - b.order)
// 每一行:按钮 + 图标插槽 + 标签
function PanelRow({ id, label, wide, ... }) {
const active = usePanelInfo((info) => info.activePanelId === id) // 当前是否选中
return (
<button onClick={() => selectPanel(id)}>
<span aria-hidden="true">
{renderSlot('sidebar.panellist', { size: wide ? 16 : 18, active }, { only: id })}
</span>
{wide && <span>{label}</span>}
</button>
)
}
这段代码解释了五件事:
- 你不画按钮 ,你只提供"图标组件",宿主传给你
size(展开 16 / 折叠 18)和active(是否选中)。 - 标签是元数据 (
label),不是组件渲染的内容;折叠成图标栏时标签隐藏、改用 tooltip。 - 顺序来自
order,升序。 - 选中态 靠
activePanelId === id,所以id必须和main的key完全一致。 - 点击只是
selectPanel(id),中间栏换不换内容,取决于main插槽里有没有这个 key。
中间栏的派发就一行:
js
function MainPanel({ usePanelInfo, renderSlot }) {
return renderSlot('main', {}, { entryKey: usePanelInfo((info) => info.activePanelId) ?? 'conversation' })
}
注意 ?? 'conversation':没选面板时显示会话。这就是为什么"点开插件页"和"回到会话"能自然切换------它们只是同一个 keyed 插槽的两个 key。
4.4 为什么「测试」排不到「更多」下面
同一段渲染里,面板行之后才是「更多」按钮:
js
<nav>
{panels.filter(/* 未被取消固定的 */).map(({ id, label }) => <PanelRow ... />)}
{hasEntryActions && <PanelMoreMenu ... />}
</nav>
「更多」不是 sidebar.panellist 的成员,而是外壳自己画的第 N+1 个按钮,永远渲染在列表之后。所以:
- 「测试」的
order: 20(插件 0 / 自动化任务 10 / 测试 20)→ 出现在「自动化任务」之后。 - 它不可能出现在「更多」之后,除非去改外壳源码。
「更多」这个按钮的显示条件是:sidebar.panellist.actions 这个列表里至少有一条注册(官方由 entry-customization 提供"浏览插件/创建插件"两项)。也就是说「更多」是给别人往里加菜单行用的:
ts
// 该插槽给行组件注入的 ownerProps
interface SidebarPanelActionsOwnerProps {
entries: readonly SidebarPanelActionEntry[] // 可自定义面板,按注册顺序
onOpen: (id: string) => void // 打开面板,不改固定状态
onPinnedChange: (id: string, pinned: boolean) => void
onDismiss: () => void // 关掉菜单
}
如果你想的是"点开更多,菜单里多一行测试",那是往 sidebar.panellist.actions 注册,并在行里调 onOpen('test-panel')(或 ctx.layout.selectPanel('test-panel'))。同一个面板,两种入口,代码只差一个插槽。
5. 核心机制二:注册就是"声明式副作用"
5.1 标准写法
js
ctx.slots.inject('sidebar.panellist', () =>
ctx.slots.register(
{ name: 'sidebar.panellist', id: 'test-panel', order: 20, locale: 'test-panel',
label: () => t('panel') },
TestPanelIcon,
),
)
三个关键点:
① 为什么要 inject 包一层?
ctx.slots.inject(ownerKey, callback) 的意思是"当 ownerKey 这个插槽的声明存在时,执行 callback 并接管它的返回值"。这样写有两个好处:
- 宿主还没声明插槽时不会报错,等它一出现就装上去;
- 声明消失(比如那个插件被卸载、或被折叠)时自动卸载,再次出现时自动重装 。你不需要写任何
if (slot exists)的兼容代码。
回调里返回的 ctx.slots.register(...) 就是这个注册的销毁器(disposer)。
② name 必须等于插槽名。
看起来冗余(外层已经写了 sidebar.panellist),但它让注册对象是自洽的:日志、错误信息、销毁都靠它。main 的注册里就写成 name: 'main', key: 'test-panel'。
③ locale 决定了组件能不能拿到 t。
注册时声明 locale: NS,框架就给组件注入一个 t(key, params)。所以页面组件签的是 function TestPanel({ t }),不需要自己 import 语言服务。
5.2 label 为什么要写成函数
插槽文档对 label 的说明是:
Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.
写成 label: () => t('panel') 而不是 label: '测试',切换界面语言时侧边栏文字会跟着变,不需要重新注册。侧边栏还专门订阅了 locale 变化,重新读一遍所有面板的 label。
5.3 注册之外还有资源:ctx.effect
字典是资源,要在插件卸载时撤掉:
js
const t = ctx.locale.bind(NS)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'test-panel: dictionaries')
规则很简单:凡是"注册/订阅/定时器/监听器",都放进 ctx.effect 或 ctx.on,让返回值成为清理函数。 这样插件被停用、profile 切换、热更新时不会留下野资源。
5.4 选项速查
| 选项 | 适用 | 说明 |
|---|---|---|
name |
全部 | 插槽名,必填 |
id |
list | 你的格子 key。重复会替换别人的格子 ,所以别用 plugins 这种官方 id |
key |
keyed | 派发用的 key,等同面板 id |
order |
list | 升序位置,默认 0 |
label |
list | 字符串或函数;函数每次投影都重读(配合 i18n) |
locale |
全部 | 声明字典命名空间 → 组件获得 t |
inject |
全部 | 返回要给组件的自定义 props(我们的 main 没用到,官方页面常用) |
6. 逐行读我们写的 client.js
6.1 模块外壳
js
window.__ModuleLoader__.load({
id: '@local/dsh-test-panel',
factory(require) {
const React = require('react')
const h = React.createElement
// ...
return { inject: ['slots', 'locale'], apply(ctx) { /* ... */ } }
},
})
id必须等于包名,loader 用它登记工厂。require是模块表:react属于基线,直接可用。不要require('@deepseek-ai/dsh-client-ui-primitives')或任何 Harness 内部 Client 包 ------它们随版本变,而且一旦组件抛错,你那个插槽会直接变空白(控制台会写slot entry crashed in '<slot>')。自己写按钮、自己写图标,只依赖主题 token。- 返回
{ inject, apply }:inject声明需要的服务(slots注册插槽、locale注册字典)。 - 为什么不用 JSX:这个文件是浏览器直接执行的产物格式 ,没有编译步骤。要 JSX 就得自己上构建工具产出同样的外壳;手写
React.createElement反而最省事。
6.2 常量与字典
js
const NS = 'test-panel' // 字典命名空间
const PANEL_ID = 'test-panel' // 面板 id:侧边栏行和 main 的 key 共用
const zh = { panel: '测试', title: '测试', counterClicks: '已点击 {count} 次', /* ... */ }
const en = { panel: 'Test', title: 'Test', counterClicks: 'Pressed {count} times', /* ... */ }
两个约定:
- 界面文案一律走字典,不要硬编码中文,否则英文界面里会冒出中文。
- 插值语法是
{name},调用时t('counterClicks', { count }),非字符串会被String()转换。
6.3 样式:只用主题 token
js
const styles = {
root: { height: '100%', overflowY: 'auto', padding: '28px 32px 48px',
background: 'var(--dsw-alias-bg-base)',
color: 'var(--dsw-alias-label-primary)' },
card: { border: '1px solid var(--dsw-alias-border-l1)', borderRadius: 12,
background: 'var(--dsw-alias-bg-layer-1)' },
// ...
}
用到的 token 一共这些(都能从 Theme.listTokens 查到):
| token | 用途 |
|---|---|
--dsw-alias-bg-base |
应用底色 |
--dsw-alias-bg-layer-1 / -2 |
卡片 / 按钮底色 |
--dsw-alias-border-l1 / -2 |
边框 |
--dsw-alias-label-primary / -secondary |
主/次文字 |
--dsw-alias-brand-primary |
悬停与焦点色 |
字面颜色只允许出现在图标美术里。 用 token 的好处:明暗主题自动跟随,将来 token 改名最多是"颜色不太对",不会渲染崩掉。
需要 :hover / :focus-visible 这种伪类时,在组件里渲染一个 <style> 元素(卸载时自动消失):
js
const CSS = [
'.dsh-test-panel-action:hover { border-color: var(--dsw-alias-brand-primary); }',
'.dsh-test-panel-action:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }',
].join('\n')
// 组件里
h('div', { className: 'dsh-test-panel', style: styles.root },
h('style', {}, CSS),
/* ... */)
类名带自己的前缀,避免和宿主类名撞车。
6.4 侧边栏图标组件
js
function TestPanelIcon({ size = 18 }) {
return h('svg', {
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
'aria-hidden': 'true', focusable: 'false', style: { display: 'block' },
},
h('path', { d: 'M9.4 3.5h5.2M10.8 3.5v5.2L6.35 17.4a2.55 2.55 0 0 0 2.25 3.6h6.8a2.55 2.55 0 0 0 2.25-3.6L13.2 8.7V3.5',
stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }),
h('path', { d: 'M8.05 14.7h7.9', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }))
}
- 只吃
size;active不强制用(想区分选中态可以换stroke浓度)。 stroke="currentColor"是关键:颜色继承按钮当前文字色,明暗主题自动正确,不用判断模式。aria-hidden+focusable="false":装饰性图标不要污染无障碍树。
6.5 页面组件
js
function TestPanel({ t }) {
const tr = typeof t === 'function' ? t : (key) => key // 兜底,避免整个插槽白屏
const [count, setCount] = React.useState(0)
const status = count === 0 ? tr('counterIdle') : tr('counterClicks', { count })
const header = h('header', {},
h('h1', { style: styles.title }, tr('title')),
h('p', { style: styles.intro }, tr('intro')))
const card = h('section', { style: styles.card },
h('h2', { style: styles.cardTitle }, tr('counterTitle')),
h('p', { style: styles.cardBody }, tr('counterBody')),
h('div', { style: styles.row },
h('button', { type: 'button', className: 'dsh-test-panel-action',
onClick: () => setCount((v) => v + 1) }, tr('counterAction')),
h('span', { role: 'status', 'aria-live': 'polite', style: styles.status }, status)))
return h('div', { className: 'dsh-test-panel', style: styles.root },
h('style', {}, CSS),
h('div', { style: styles.inner }, header, card,
h('p', { style: styles.footnote }, tr('footnote'))))
}
几个"正经组件"的细节,和"能不能通过审查"直接相关:
React.useState让页面可交互------这才叫验证了"能渲染",静态文字验证不了。- 计数用
role="status"+aria-live="polite",读屏会播报变化。 <button type="button">:别让它在表单里变成提交按钮。- 根节点
height: 100%; overflowY: auto:中间栏不给你滚动容器,页面自己负责滚动。这是新手最容易漏的一条(漏了内容一多就会被裁掉)。
6.6 apply:三步注册,顺序有讲究
js
apply(ctx) {
const t = ctx.locale.bind(NS)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'test-panel: dictionaries') // ① 字典
ctx.slots.inject('main', () => ctx.slots.register( // ② 页面
{ name: 'main', key: PANEL_ID, locale: NS }, TestPanel))
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register( // ③ 侧边栏入口
{ name: 'sidebar.panellist', id: PANEL_ID, order: 20, locale: NS,
label: () => t('panel') }, TestPanelIcon))
}
- 先字典再注册 :
label的 thunk 和组件的t都要求字典已存在。ctx.effect的立即执行保证了这一点。 - 两个插槽缺一不可 :只注册
sidebar.panellist→ 有一行可点,但中间栏是空的;只注册main→ 页面存在但没有任何入口能到它。 bind(NS)返回的是稳定引用(同一命名空间重复 bind 返回同一个函数),直接放进labelthunk 里最省事。
7. 换个角度:从"位置"理解注册
现在可以精确回答"位置由什么决定"了。一个界面插件的位置由 4 个变量决定,全部在注册那一行里:
| 变量 | 决定什么 | 我们的值 |
|---|---|---|
| 插槽名 | 出现在界面的哪个区域 | sidebar.panellist(左栏面板行) |
| kind 对应的格子键 | 占据哪个格子 | id: 'test-panel' |
| order | 同区域内的第几个 | 20 |
| key / id 与派发方是否一致 | 点击后中间栏能不能接上 | main 的 key 也是 test-panel |
反过来,"不用管"的部分也很明确:
- 不用管像素坐标、宽高、边距、周围有什么;
- 不用管按钮长什么样(宿主画的);
- 不用管折叠/展开、tooltip、键盘可访问性(宿主处理);
- 不用管什么时候挂载、什么时候销毁(
inject+ 自动 disposer 处理)。
一句话:注册不是"往页面上插东西",而是"占一个宿主的座位"。座位在哪由插槽决定,座位号由 id/key 决定,排第几由 order 决定。
replaceRisk 是什么
插槽拓扑里每个插槽还有一个 replaceRisk:
none:加东西安全(我们用的sidebar.panellist就是)。shadows-shipped-ui:如果你用同一个id/key注册,会顶掉官方那一个 。比如往sidebar.panellist注册id: 'plugins',插件页入口就变成你的了。
这不是 bug,是设计:id 复用 = 替换那个格子。知道这点,你就既能扩展也能改造(也能一不小心把自己的界面搞没)。
8. 安装:plugin_manager 到底做了什么
8.1 前置条件(我们在这里卡了一次)
DSH 的插件安装用 pnpm 在 profile 里做依赖操作。第一次安装直接失败:
'pnpm' 不是内部或外部命令,也不是可运行的程序或批处理文件。
原因:这台机器上根本没装 pnpm,而且 Node 的全局前缀在 C:\Program Files\nodejs(要管理员)。关键认知:pnpm 必须在 DSH 宿主进程的 PATH 上,不只是你的终端里。 因为执行安装的是宿主进程,它继承的是自己启动时的环境变量。装好 pnpm(12.16.1)后,宿主立刻就能用了,因为那个目录本来就在它的 PATH 里。
8.2 安装调用
plugin_manager({ action: "install_bundle", target: "<插件目录的绝对路径>" })
返回:
jsonc
{
"stage": "enable",
"target": "@local/dsh-test-panel",
"enabled": true,
"changed": true,
"application": "applied", // ← 只有这个字段说明"已经生效"
"packageResult": { "exitCode": 0, "output": "+ @local/dsh-test-panel <- ../../../Documents/.../test-panel" },
"bundle": "@local/dsh-test-panel",
"version": "1.0.0",
"warnings": []
}
它做了四件事:
- 在 profile 里
pnpm add link:<你的目录>------ 建立 junction 链接; - 把包名写进
package.json的dsh.profile.bundles; - 让 Loader 激活
test-panel那一行; - 重新组装浏览器的 boot graph(所以不用重启,浏览器端已经注册好了)。
8.3 审批模型
plugin_manager 的每个动作(包括 list_plugins)都需要审批,因为它会改 profile:profile 是全局的,改动影响所有会话,而且可以执行包的安装脚本。这是有意设计的门槛,不是麻烦。
8.4 改代码之后怎么生效
- 因为 profile 里是链接,你改工作区的文件,profile 里立刻就是新内容。
- 刷新页面通常能拿到新 bundle;但宿主对已安装包 有模块代际缓存,涉及
package.json、补丁、新增文件的改动,刷新不一定够,必要时重启dsh web。 - 想边改边看,需要源码检出 +
pnpm run dev:web这类 watch 通道(这次是安装版,没有走这条路)。
8.5 卸载
plugin_manager({ action: "remove_bundle", target: "@local/dsh-test-panel" }),或者在界面的「插件」页里停用/卸载。停用只关掉激活,卸载才移除链接和依赖声明。
9. 验证:我怎么确认它真的生效了
这部分值得单独学,因为**"装完了"和"能用"是两件事**。
9.1 先看插槽,而不是看日志
DSH 提供了一组只读的检查器(cordis_inspect_list 列出能力,cordis_inspect_query 查询)。装完之后查两个插槽:
Slots.listSubTree({ root: "sidebar.panellist" })
→ occupants: [ {id: "plugins", order: 0}, {id: "schedules", order: 10},
{id: "test-panel", order: 20, active: true} ]
Slots.listSubTree({ root: "main" })
→ occupants: [ {key: "plugins"}, {key: "conversation"},
{key: "schedules"}, {key: "test-panel", active: true} ]
active: true 表示这个注册在活着的页面里已经装上了,不是"配置里写了"。这比翻日志可靠得多。
9.2 其他有用的检查
| 想知道 | 查什么 |
|---|---|
| 有哪些插槽、每个插槽要什么参数 | Slots.listSubTree(不带 root 看全局树) |
| 主题 token 有哪些 | Theme.listTokens |
| 某个插件的 Config schema | Config.listConfigs → { entry } |
| 这个 Agent 能用哪些工具 | Tool.listTools |
| 安装了哪些 bundle | plugin_manager 的 list_bundles(要审批) |
9.3 视觉验证的边界
这次的环境没有浏览器控制能力 ,所以我能确认的是:JS 语法、package.json/JSON/语言资源合法、SVG 是有效 XML、两个插槽注册已激活、样式只用 token、没有引用 Harness 内部 Client 包。
不能确认的是像素级效果(比如暗色主题下卡片对比度、按钮悬停的观感)。这类事情只能你自己打开页面看一眼。诚实地说明这个边界,比声称"我验证过了"重要。
9.4 自查清单(下次照这个走)
-
node --check client.js通过 -
package.json、locale/*.json是合法 JSON(注意编码,见下节坑 3) -
exports里./client、./icon、./locale/*.json都在 -
files包含所有运行时文件 -
cordis.patch.yml的name和包名一致 -
main的key和sidebar.panellist的id完全一致 - 界面文案全部走字典(中英都有)
- 样式只用
--dsw-alias-*/--dsw-specific-* - 没有引入 Harness 内部 Client 包
- 页面自己处理滚动(
height: 100%; overflowY: auto) - 装完后两个插槽的 occupant 都出现且
active: true
10. 回答那几个具体问题
Q1:写一个侧边栏入口"是不是就是创建一个插件"?
是。而且要多完整有多完整:一个独立包、一个 Loader 行、一个可启停的 profile 条目、一个插件管理页里的条目(带名称/描述/图标)。它不是"给现有插件加配置",也不是改前端源码。
Q2:插件可以放在任何地方吗?
分三层看:
| 层面 | 自由度 |
|---|---|
| 源目录在哪 | 基本任意。给 install_bundle 一个绝对路径即可,安装后是链接。放在工作区的好处是文件沙箱允许你直接改,且不会误删 |
| 包名 | 任意合法 npm 名。@local/... 只是本地命名习惯 |
| UI 出现在哪 | 不自由,由插槽决定 。选 sidebar.panellist 就只能出现在左栏面板行;选 settings.section 就只能进设置页 |
| 在哪个 profile 生效 | 由安装的目标 profile 决定。另一个 profile 要单独装 |
还可以注意:同一个包可以往多个插槽注册 。比如同时注册 sidebar.panellist(入口)、settings.section(设置页)、conversation.composer.dock(输入框下方的小组件),代码只多几个 inject。反过来,想彻底换个位置,改的也只是那一个插槽名和对应的格子键。
Q3:是不是只要注册就行,不用管插件的位置?
对一半,而且是容易误导的那一半。
- ✅ 不用管:坐标、尺寸、间距、层级、滚动容器、按钮外观、折叠态、可访问性、挂载/销毁时机。
- ❌ 必须管:注册到哪个插槽 (区域)、用什么 kind 的格子键 (
id还是key)、order (顺序)、键值是否和派发方一致 (否则点了没反应)、id 是否撞了官方条目(会顶掉别人)。
用一句话概括这个心智模型:
你在给宿主"报名",不是在给浏览器"摆积木"。
11. 这次真实踩到的坑(连同别人也会踩的)
-
pnpm必须在宿主进程的 PATH 上。 终端里有没有不重要,宿主继承的是它启动时的环境。报错原文就是'pnpm' 不是内部或外部命令。 -
plugin_manager每次调用都要审批。 别在循环里反复调,先把要做的动作想清楚。 -
Windows 上用
Get-Content直接读 UTF-8 中文会乱码 ,还会把 JSON 误判成"坏文件"(我第一次校验就"失败"了,实际文件是好的)。正确做法是显式按 UTF-8 读:powershell[System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) -
别
requireHarness 内部 Client 包 (dsh-client-ui-primitives之类)。版本一变就崩,而且组件一抛错,整个插槽空白。按钮、图标、Tooltip 都自己写,样式只用 token。 -
中文别硬编码。 走
ctx.locale.register+t(),否则英文用户看到中文。 -
两个插槽缺一不可。 只注册入口 = 点了空白;只注册页面 = 没有入口。
-
id复用 = 替换。 用plugins、schedules这类官方 id 会顶掉官方界面。 -
「更多」永远最后。 它是外壳画的,不由
order控制,所以排不到它下面。想进那个菜单就换插槽。 -
页面自己负责滚动和背景。 中间栏不提供 padding/滚动容器,忘了就会"内容被裁掉"或"背景透出"。
-
工作区目录删了/挪了,链接就断了。 因为 profile 里是 junction,不是拷贝。挪了之后重新
install_bundle一次即可。 -
files在本地链接安装时不过滤。 别以为写了files就万事大吉;发布到 npm 时要重新核对打包清单。 -
PNG 截图验证这事别硬来。 在没有浏览器控制的环境里,最靠谱的验证是插槽 occupant + 语法 + 清单;视觉留给用户。
12. 想再往下走,可以做什么
- 同一个包多入口 :再注册
settings.section(设置页)、conversation.composer.dock(输入框下方的小组件)、conversation.chat.turnTail(每轮对话末尾的卡片)。 - Host 侧能力 :
index.js里ctx.tools.register(...)可以加模型能调用的工具;ctx.effect里挂服务;用Config提供可在cordis.patch.yml里调的参数。 - 界面操作与工具共用一套逻辑:DSH 有专门的实践文档讲"UI 动作和 Agent 工具共享应用操作",避免两套实现漂移。
- 发布 :包名改成正式作用域(如
@yourname/dsh-test-panel),pnpm publish之后install_bundle直接给包名或版本范围,不再需要本地路径。 - 需要额外模块 :
dsh.client.external里声明,否则模块表不给。 - 多语言 :
addLanguage+ 字典注册,就能加第三种界面语言。
13. 一页速查:给侧边栏加一个入口的最小改动
只要三处:
jsonc
// package.json
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web", "immediately": true,
"inject": ["@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-sidebar"] }
}
yaml
# cordis.patch.yml
- insert:
- id: my-entry
name: '@local/my-entry'
js
// client.js(factory 内部,省略样板)
apply(ctx) {
const t = ctx.locale.bind('my-entry')
ctx.effect(() => ctx.locale.register('my-entry', { zh, en }), 'my-entry: dictionaries')
ctx.slots.inject('main', () => ctx.slots.register(
{ name: 'main', key: 'my-entry', locale: 'my-entry' }, MyPage)) // 页面
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
{ name: 'sidebar.panellist', id: 'my-entry', order: 20, locale: 'my-entry',
label: () => t('panel') }, MyIcon)) // 入口
}
然后 plugin_manager({ action: "install_bundle", target: "<绝对路径>" }),用 Slots.listSubTree 确认两个 occupant 都出现。
附:这次实际用到的文件
| 文件 | 作用 |
|---|---|
| package.json | 清单与 dsh 声明 |
| cordis.patch.yml | 插入 Loader 行 |
| index.js | Host 半身(空) |
| client.js | Client 半身:注册 + 页面 + 图标 |
| locale/zh.json、locale/en.json | 插件管理页展示文案 |
| icon.svg | 插件图标 |
装好的位置:C:\Users\yan\.dsh\profiles\web\node_modules\@local\dsh-test-panel(junction → 上面的目录)。