CH 22 · UI 插件:给 dsh 换皮、加面板、塞内容
本章目标
前面几章的插件都在"看不见"的地方干活:注册工具、拦截调用、提供依赖。这一章换到看得见的层面——插件能直接改变 Web UI 的样子和能力。这一章讲清楚 UI 插件能干什么、它是怎么运作的,并动手看一次真实的事件流。
你其实已经用上 UI 插件了
回顾 CH 17,我们装过三个插件,它们全是 UI 插件:
| 插件 | 干了什么 |
|---|---|
| dsh-theme | 给 Web UI 换肤(设置 → 外观里多出主题卡片) |
| dsh-oil-sticky-prompt | 滚动时把最近的用户消息钉在顶部 |
| dsh-better-sidebar | 侧边栏工作台,文件面板直接看目录 |
你当时可能只是"装了个插件",现在明白了:它们都在改 UI。这就是 UI 插件的第一层认知——它负责所有你"看得到"的定制。
UI 插件能干什么
按能力大致分三类:
- 换肤:改颜色、字体、布局风格。最轻的一类,dsh-theme 就是。
- 改布局 / 加面板 / 加功能:动侧边栏、顶栏、输入区,也能直接往界面里加一个完整的功能模块或可视化面板。dsh-better-sidebar、dsh-oil-sticky-prompt 属于这类;你 CH 12 装过的 DSH Skill & MCP Panel 也是——它直接在设置里多出一个"MCP 管理"入口,每台服务器的状态、工具数一眼可见,相当于给 Web UI 加了一个可视化运维面板。
- 给对话贡献内容:在会话里渲染自定义的行(比如把某类工具结果画成卡片、插入特殊节点)。这是最深的一类,官方叫"给 Web Client 贡献业务行"。
前两类很多是纯前端工作;第三类要真正"接进"对话渲染管线。
UI 插件怎么运作:一切从事件流开始
UI 插件不直接操作页面上的 DOM,而是订阅事件流。这是理解 UI 插件最关键的一点。
一条会话从头到尾,每一件事都会变成一条标准事件:用户发了消息、模型开始流式输出、某个 step 开始了、某次工具调用发生了……这些事件像流水一样流过 session/event 这条通道。内置界面靠它们渲染出对话气泡、轨迹、状态;UI 插件也一样,通过 ctx.on('session/event', ...) 订阅同一条流。
官方给的 UI 插件最小示例就是干这个的:
export const name = 'my-ui'
export const inject = ['agents']
export function apply(ctx) {
ctx.on('session/event', (_session, event) => {
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
console.log(event.data.chunk.text) // 模型流式输出的每一段
}
})
}整个机制的骨架:
你输入、模型回复,会话控制器把它们统一翻译成事件流;UI 插件和内置界面订阅同一条流,各取所需。界面是"事件流"的消费方,不是 DOM 的直接操纵者——这也解释了为什么"一切皆插件"能覆盖 UI:连界面都是插件从事件流里"听"出来的。
动手:装个"事件观察器",亲眼看事件流
光看机制不过瘾,我们写一个极简插件,把每一条会话事件打印出来——看看一次对话背后到底发生了多少事。
第 1 步:建目录、写插件
在一个你想放插件的工作区里:
New-Item -ItemType Directory -Path "ui-demo\src" -Force新建 ui-demo\src\event-watch.js:
export const name = 'event-watch'
export function apply(ctx) {
ctx.on('session/event', (_session, event) => {
console.log(`[event] ${event.type}${event.data?.type ? ' / ' + event.data.type : ''}`)
})
}就是订阅 session/event,把每条事件的类型打出来。
第 2 步:声明插件
新建 ui-demo\cordis.yml(路径换成你自己的,空格记得 %20):
- insert:
- id: event-watch
name: 'file:///E:/你的工作区/ui-demo/src/event-watch.js'第 3 步:headless 跑一次
cd 你的工作区目录
dsh --profile headless --patch "./ui-demo/cordis.yml" "只回答:hi"我实际跑出来的终端输出:

就回答一个"hi",背后走了 22 条事件:会话初始化(permission、sandbox、approval)、turn/start、step/start、三次 user/message、请求头、assistant/chunk 一条条流式吐字、assistant/message 落定、step/end、turn/end……
你在 Web UI 里看到的那个对话,就是这一串事件被消费后渲染出来的。事件观察器让我们第一次"看见"了 UI 背后的原料。
让 dsh 自己搭:开发一个看得见的 UI 插件
前面的事件观察器只是"看事件",还没真正改变界面。让 dsh 直接开发一个"能在设置页看到效果"的 UI 插件,它也能干。
直接在 Web UI 的输入框发这条提示词:
在我当前的工作区帮我开发一个能在 Web UI 里看到的简单 UI 插件:往设置页加一个自定义 tab(比如叫"我的插件",页面里放点占位内容就行,不需要复杂功能)。你先去 dsh 官方文档读清楚 UI 插件的机制和设置页怎么加 tab,按官方规范实现,告诉我装到哪个 profile、怎么在界面里看到它(需要重启 dsh 或改配置也一并告诉我)。它会自己去读文档、自己判断技术栈(这类界面插件通常要 TypeScript 和前端构建,它自己会处理)、自己实现、告诉你验收方式。你照着它说的重启 dsh,就能在设置页看到新加的 tab。
这是我实际跑的一次——它从读文档、写插件、装进 profile,到告诉我怎么在界面里看到、怎么退出,一路自己做完,还标出了关键路径:

重启后进设置 → 插件,顶部就多了"我的插件"这个 tab:

一个细节点:这个插件纯 UI,没有 Host 端行为,所以不用改任何 settings.yaml / cordis.yml——一切通过 profile 的 bundles 装载。想回滚也很简单:把它从 bundles 数组里删掉即可。这正是"一切皆插件"的又一次体现:连界面上加一个 tab,都是"装一个插件"的事。
常见坑
| 问题 | 怎么回事 | 怎么处理 |
|---|---|---|
| 事件观察器不打印 | session/event 没订阅对,或插件没加载 | 确认 cordis.yml 路径、ctx.on 写法 |
| 事件很多看不懂 | 一次对话本来就有几十条事件 | 先看 turn/start 到 turn/end 之间,那是主干 |
| 想在对话里直接画内容 | 那要 React renderer,属于进阶 | 先用好事件流和设置项,进阶再看 |
报 Cannot find package | 插件用了外部依赖没装 | 在插件目录 npm install 对应包 |
这一章你学到了什么
- [ ] 能说出 UI 插件的三类能力:换肤 / 布局加面板 / 会话内容贡献
- [ ] 理解"界面是事件流的消费方"这个核心机制
- [ ] 会用
ctx.on('session/event', ...)订阅会话事件 - [ ] 能用事件观察器看到一次对话背后的事件流
- [ ] 知道 UI 插件可以往设置页加自定义 tab
