从零写一个 DSH 插件:在左侧菜单栏加一个「测试」

从零写一个 DSH 插件:在左侧菜单栏加一个「测试」

这篇博客记录一次真实的、从零到跑通的插件开发过程:在 DeepSeek Harness(下称 DSH)Web 界面的左侧菜单栏里加一个「测试」入口,点进去是一个自己的页面。

适合第一次接触 DSH 插件开发的读者。文中所有代码都是这次实际装进 web profile 并验证生效的版本,不是伪代码。


0. 先给结论

如果你的问题只有三句,答案在这里:

  1. 这确实就是一个插件。 左侧一个入口 + 一个页面 = 一个插件包(DSH 里叫 bundle)。不是改配置、不是改前端源码,而是一个独立目录 + 一份 package.json + 一个补丁文件 + 一段浏览器端代码。
  2. 插件目录可以放在任何地方。 它不需要住在 DSH 的安装目录里,也不需要叫某个固定名字。安装时给它一个绝对路径就行,DSH 会在 profile 里建立一个链接指回你的目录。
  3. 「插件位置」不靠坐标,靠插槽。 你永远不写 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>
  )
}

这段代码解释了五件事:

  1. 你不画按钮 ,你只提供"图标组件",宿主传给你 size(展开 16 / 折叠 18)和 active(是否选中)。
  2. 标签是元数据 (label),不是组件渲染的内容;折叠成图标栏时标签隐藏、改用 tooltip。
  3. 顺序来自 order,升序。
  4. 选中态 靠 activePanelId === id,所以 id 必须和 main 的 key 完全一致。
  5. 点击只是 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 返回同一个函数),直接放进 label thunk 里最省事。

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": []
}

它做了四件事:

  1. 在 profile 里 pnpm add link:<你的目录> ------ 建立 junction 链接;
  2. 把包名写进 package.json 的 dsh.profile.bundles;
  3. 让 Loader 激活 test-panel 那一行;
  4. 重新组装浏览器的 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. 这次真实踩到的坑(连同别人也会踩的)

  1. pnpm 必须在宿主进程的 PATH 上。 终端里有没有不重要,宿主继承的是它启动时的环境。报错原文就是 'pnpm' 不是内部或外部命令。

  2. plugin_manager 每次调用都要审批。 别在循环里反复调,先把要做的动作想清楚。

  3. Windows 上用 Get-Content 直接读 UTF-8 中文会乱码 ,还会把 JSON 误判成"坏文件"(我第一次校验就"失败"了,实际文件是好的)。正确做法是显式按 UTF-8 读:

    powershell 复制代码
    [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
  4. 别 require Harness 内部 Client 包 (dsh-client-ui-primitives 之类)。版本一变就崩,而且组件一抛错,整个插槽空白。按钮、图标、Tooltip 都自己写,样式只用 token。

  5. 中文别硬编码。 走 ctx.locale.register + t(),否则英文用户看到中文。

  6. 两个插槽缺一不可。 只注册入口 = 点了空白;只注册页面 = 没有入口。

  7. id 复用 = 替换。 用 plugins、schedules 这类官方 id 会顶掉官方界面。

  8. 「更多」永远最后。 它是外壳画的,不由 order 控制,所以排不到它下面。想进那个菜单就换插槽。

  9. 页面自己负责滚动和背景。 中间栏不提供 padding/滚动容器,忘了就会"内容被裁掉"或"背景透出"。

  10. 工作区目录删了/挪了,链接就断了。 因为 profile 里是 junction,不是拷贝。挪了之后重新 install_bundle 一次即可。

  11. files 在本地链接安装时不过滤。 别以为写了 files 就万事大吉;发布到 npm 时要重新核对打包清单。

  12. 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 → 上面的目录)。

相关推荐
吹什么轩40 分钟前
constexpr 完全指南:从 const 的两种含义,到编译期计算的四次演进
java·前端·javascript
LabVIEW开发1 小时前
LabVIEW 局部变量与数据流:这道 CLAD 题为什么答案是 Undefined
java·开发语言·labview
路溪非溪1 小时前
C++标准库之智能指针
linux·开发语言·c++
衍生星球2 小时前
【Java 语言】— 面向对象:构造方法(无参、有参构造)
java·开发语言
yuniko-n2 小时前
【JVM】对象内存布局
java·开发语言·jvm
何中应2 小时前
【Go-1】变量&常量定义
开发语言·后端·golang
CarIise2 小时前
Spring 框架AOP与 XML 基础
java·开发语言
geovindu2 小时前
rust:N-Barrier Pattern
开发语言·后端·设计模式·rust·屏障模式·并发型模式
AI宇宙狂想家2 小时前
开源 AI 视频工具进化史:从难用到好用,OpenMontage 改了什么
开发语言·前端·人工智能·开源·ai创业·openmontage