环境:
@deepseek-ai/dsh@0.2.1-rc.5、dsh-starter起步模板、pnpm@10.34.5、Node 22+。文中插槽名与类型以该版本官方包为准,升级前请以新版本的SlotMap声明复核。
TL;DR:改标题和 Logo 不需要 Fork 官方前端,也不需要改 node_modules。官方侧栏与空态页各留了品牌插槽,注册一个占位组件就会替换掉鱼形兜底;整个改动装进一个独立插件包,四条命令即可验收。
1. 从一个很朴素的需求说起
给内部部署的 DeepSeek Harness 换一套白标:侧栏名字改成「平安one」,Logo 换成自家图标。
第一反应通常是去找 index.html、找 logo.svg、找写着产品名的组件。但在 DSH 里,这些都找不到——官方包是从 npm 按精确版本安装的,Web 前端由 dsh web 直接提供,业务仓库里只有自己的插件,没有任何一份 harness 服务端或前端实现。
直接改 node_modules 当然能生效,代价是:官方一升级就失效,改动不可评审,还会给后续每次依赖更新埋雷。
DSH 给的答案是插槽(slots):官方 UI 壳在关键位置预留带类型的挂载点,插件通过 ctx.slots.register() 把组件填进去;壳负责按钮、tooltip、选中态、无障碍属性和几何,插件只负责画自己那一块内容。「万物皆插件」落到这个需求上,就是——品牌也是一个插件。
2. 先定位:界面上的「品牌」到底有几处
这一步比写代码重要。数错了位置,就会出现「侧栏换了、首页还是一条鱼」的尴尬。
| 位置 | 插槽 | kind / scope | owner props |
|---|---|---|---|
| 侧栏 Logo(展开行 + 折叠轨共用) | sidebar.brand.mark | single / root | { size: number } |
| 侧栏品牌名(仅展开态) | sidebar.brand.name | single / root | 无 |
| 空会话首页大 Logo | conversation.hero.brand.mark | single / root | { size: number, className?: string } |
| 浏览器标签页标题 / favicon | 无对应插槽 | — | — |
前三个来自 @deepseek-ai/dsh-client-ui-sidebar 和 @deepseek-ai/dsh-client-ui-conversation 的 SlotMap 声明。侧栏包的类型注释写得很直白:
/**
* Brand mark rendered in the expanded brand row and collapsed rail.
* Declared by this package's `sidebar` entry; deployments may replace
* the shell's fish fallback without replacing the surrounding controls.
*/
'sidebar.brand.mark': {
kind: 'single';
scope: 'root';
owner: SidebarBrandMarkOwnerProps;
};
它的中文 README 也说明:展开的品牌行把 sidebar.brand.mark 与 sidebar.brand.name 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot;没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。
也就是说官方实现是「有注册就渲染注册者,没有才走 fallback」:
renderSlot('sidebar.brand.mark', { size: 24 }, { fallback: <FishLogo size={24} /> })
所以我们不需要「遮蔽」谁,注册即替换。
第四行要单独说:<head> 里的 document.title 和 favicon 不属于任何 React 组件,官方没有为它们提供插槽或配置项。这部分只能作为插件的可逆副作用处理,见第 5 节。
顺带避一个坑:模板里 starter 插件侧栏那行 label: '示例' 只是一个面板入口的名字,不是产品品牌名,改它没用。
3. 决策:新写一个插件,还是改现有插件?
模板原本自带三个示例插件:starter(演示侧栏 + 主面板)、expert-team(演示输入框浮层 + 跨插件跳转)、host-only(演示纯服务端)。把品牌代码塞进其中任何一个都能跑起来,但不该这么做:
- 职责与生命周期错配。品牌是全局、长期存在的能力,绑到某个 demo 插件上,等于「删掉示例功能就丢掉品牌」。模板的验收标准恰恰是:删掉任意插件后,界面必须回到与官方安装包一致的状态。
- single 槽是独占资源。同一个
single槽里,同一priority(默认 0)的第二次注册会在注册期直接抛错。把品牌集中在一个插件里持有,才便于独立启用、禁用、灰度,也避免将来多个插件抢同一格。 - 新建成本几乎为零。
packages/plugins/*由构建与启动脚本自动发现,一个含package.json的目录就是一个插件,不需要在根配置里登记。
结论:新建 brand-pingan 插件,现有三个插件一行不动。
4. 最小插件骨架
packages/plugins/brand-pingan/
├── package.json # 双面 exports + dsh.bundle.patch + dsh.client
├── cordis.patch.yml # 让官方把这一行插进 Profile 插件树
├── tsconfig.json # jsx: react-jsx、DOM lib、declaration
└── src/
├── index.ts # Host 面(几乎是空的)
└── client.tsx # 浏览器面,全部 UI 在这里
为什么纯 UI 也要写 Host 入口
这是 DSH 里最容易反直觉的一条:没有「仅 Client」形态。官方的 ClientModuleRegistry 是扫描 Host loader entries 来找 dsh.client 声明的,不是扫描浏览器目录。少了 cordis.patch.yml 和 src/index.ts,浏览器模块永远不会出现在 boot 清单里,哪怕包里确实打了 client.browser.js。
装载链路简化后是这样:
cordis.patch.yml → Profile 插件树 → Host 扫 dsh.client
→ 生成 __DSH_BOOT__ → 路由 /plugins/<id>/client.js
→ 浏览器 __ModuleLoader__ 登记工厂 → cordis 按 inject 顺序执行 apply(ctx)
→ slots.register 写进条目表 → 官方 owner 渲染时取出并挂载
所以 src/index.ts 只保留一个最小 apply:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'dsh-brand-pingan-plugin'
export const inject = [] as const
export function apply(ctx: Context): void {
ctx.effect(() => {
ctx.logger('dsh-brand-pingan-plugin').debug('brand UI mounted from dsh-brand-pingan-plugin/client')
return () => {}
}, 'dsh-brand-pingan-plugin.lifecycle')
}
cordis.patch.yml 只需要一个 loader entry:
- insert:
- id: dsh-brand-pingan-plugin
name: dsh-brand-pingan-plugin
package.json 里四处关键字段缺一不可:
{
"exports": {
".": { "default": "./dist/index.js" }, // Host
"./client": { "default": "./dist/client.browser.js" } // 浏览器
},
"files": ["dist", "cordis.patch.yml"],
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" }, // 进 Profile
"client": { "platform": "web", "inject": [] } // 进 boot 清单
}
}
devDependencies 里要放 dsh-client-ui-sidebar、dsh-client-ui-conversation、dsh-client-ui-renderer、dsh-client-ui-slots、cordis,版本必须与官方包完全一致(这里是 0.2.1-rc.5)。tsconfig.json 关键是 jsx: "react-jsx"、lib 含 DOM、以及 declaration: true / emitDeclarationOnly: true(构建脚本会跑 tsc 产出 .d.ts)。
5. 核心实现:client.tsx
下面是完整文件,可以直接复制:
/**
* White-label browser entry: replaces the shell's brand marks and name through
* the official single slots, and sets the document title / favicon.
*/
import * as React from 'react'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: merges `sidebar.brand.mark` / `.name` into SlotMap.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
// Type-only: merges `conversation.hero.brand.mark` into SlotMap.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
export const name = 'dsh-brand-pingan-client'
export const inject = ['slots'] as const
const BRAND_NAME = '平安one'
/** A single slot renders its lowest-priority occupant; a negative rank keeps this deployment's brand on top. */
const RANK = -10
/** Inline data URI: the Host only serves client.js, so a bundled image file would have no route. */
const MARK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
+ '<rect width="32" height="32" rx="9" fill="#e8611a"/>'
+ '<circle cx="16" cy="16" r="10.2" fill="none" stroke="#fff" stroke-opacity=".35" stroke-width="1.4"/>'
+ '<path d="M12.6 12.4l4-2.6v12.4" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>'
+ '<path d="M11.4 25h9.2" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>'
+ '</svg>'
const MARK_URI = `data:image/svg+xml,${encodeURIComponent(MARK_SVG)}`
function BrandGlyph({ size, className }: { size: number, className?: string }) {
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 32 32"
aria-hidden="true"
focusable="false"
>
<rect width="32" height="32" rx="9" fill="#e8611a" />
<circle cx="16" cy="16" r="10.2" fill="none" stroke="#fff" strokeOpacity={0.35} strokeWidth={1.4} />
<path
d="M12.6 12.4l4-2.6v12.4"
fill="none"
stroke="#fff"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M11.4 25h9.2" fill="none" stroke="#fff" strokeWidth={2.6} strokeLinecap="round" />
</svg>
)
}
function SidebarMark({ size }: { size: number }) {
return <BrandGlyph size={size} />
}
function HeroMark({ size, className }: { size: number, className?: string }) {
return <BrandGlyph size={size} className={className} />
}
function BrandName() {
return (
<span
style={{
color: 'var(--dsw-alias-label-primary)',
font: '600 14px/1 var(--dsw-font-family, sans-serif)',
letterSpacing: '.02em',
whiteSpace: 'nowrap',
}}
>
{BRAND_NAME}
</span>
)
}
/** Set head-level identity and return a disposer restoring the previous state. */
function applyDocumentIdentity(): () => void {
const previousTitle = document.title
const icon = document.createElement('link')
icon.rel = 'icon'
icon.type = 'image/svg+xml'
icon.href = MARK_URI
icon.dataset.dshBrandPingan = 'true'
// Appending wins over an existing icon; removing ours restores the original.
document.head.appendChild(icon)
document.title = BRAND_NAME
return () => {
document.title = previousTitle
icon.remove()
}
}
export function apply(ctx: Context): void {
// `slots.inject` is declaration-aware: these work whether the sidebar and
// conversation owners are activated before or after this plugin.
ctx.slots.inject('sidebar.brand.mark', () =>
ctx.slots.register({ name: 'sidebar.brand.mark', priority: RANK }, SidebarMark),
)
ctx.slots.inject('sidebar.brand.name', () =>
ctx.slots.register({ name: 'sidebar.brand.name', priority: RANK }, BrandName),
)
ctx.slots.inject('conversation.hero.brand.mark', () =>
ctx.slots.register({ name: 'conversation.hero.brand.mark', priority: RANK }, HeroMark),
)
// The head has no slot. Bind the reversible side effect to the plugin fiber
// when the client context exposes it; otherwise fall back to a plain set.
if (typeof ctx.effect === 'function') {
ctx.effect(() => applyDocumentIdentity(), 'dsh-brand-pingan-plugin.document')
} else {
applyDocumentIdentity()
}
}
六个值得展开的设计点:
① import type {} 不是摆设。 官方用 TypeScript 的声明合并(declare module)把各包拥有的插槽名并进 SlotMap。不写这几行,注册时插槽名直接编译不通过——类型系统替你挡住拼错的插槽名。
② 必须用 slots.inject 包住 slots.register。 这几个 brand 槽是由 sidebar / conversation 包声明的,而「声明即占有」。inject 是声明感知的,保证本插件无论先于还是后于侧栏包激活都能生效;同时它把注册绑在插件 fiber 上,卸载时连同子插槽一起清理。
③ priority: -10。 single 槽按 priority 升序,最小者渲染;同一格、同一 priority 的第二次注册会在注册期抛错。官方这里用的是 fallback 而不是默认注册条目,所以缺省 0 也能生效;给一个负值是为了将来出现别的品牌插件时仍能稳定胜出,也把「我要遮蔽谁」写在明处。
④ Logo 用内联 SVG,favicon 用 data URI。 Host 只暴露 /plugins/<id>/client.js 这一个路由,把 logo.png 放进包里是没有静态资源路径可用的。两处图案要保持一致:一个是 BrandGlyph 的 JSX,一个是 MARK_SVG 字符串。
⑤ <head> 的副作用必须可逆。 document.title 存旧值,<link rel="icon"> 追加在末尾(浏览器取最后一个,卸载后自动回到官方原本的图标),清理函数通过 ctx.effect 挂到插件 fiber 上。typeof ctx.effect === 'function' 是一次防御:万一宿主裁剪过客户端上下文,head 设置降级成一次性赋值,而三个品牌插槽依然正常注册。注意这是head 级全局状态,不是接管官方组件的 DOM,React 重渲染不会覆盖它。
⑥ 颜色走官方变量。 var(--dsw-alias-label-primary)、var(--dsw-font-family) 会跟随主题与暗色模式自动变化,写死颜色会在暗色下翻车。
6. 容易踩的坑
| 坑 | 现象 / 原因 | 正确做法 |
|---|---|---|
| 只改侧栏 | 首页空会话还是鱼形 Logo | 三处 single 槽一起注册 |
把 label: '示例' 当品牌名 | 只是某个面板入口的名字 | 用 sidebar.brand.name |
| 客户端打包内联了 React | hooks 报错,「加载了但没 UI」 | react / react-dom / @deepseek-ai/* 全部 external |
| 官方包版本不一致 | 插槽类型对不上、编译失败 | 所有 @deepseek-ai/* 用同一个精确版本 |
| 只改源码不重新打包 | 界面毫无变化 | 模板用 tgz 内容哈希决定是否重新注册,必须重新 build |
| 以为有 HMR | 刷新浏览器没效果 | 客户端模块在 boot 时按清单装载,改完要重启服务 |
用 return null 压别人的注册 | 别人的条目没有生命周期可言 | 用 priority 显式遮蔽 |
| 把图片文件塞进插件包 | 404 | 内联 SVG / data URI |
还有一个已知限制:document.title 目前只在插件激活时设置一次。如果官方 SPA 会在切换会话时动态改写标题,它会把我们设的值覆盖掉。真遇到这种情况,需要补一个监听 <title> 变化的 MutationObserver,或者接受官方的动态标题策略。这是「没有插槽的位置」必然要付的代价。
7. 验证:四条命令
模板把装载链路做成了可自动化验收的证据链:
pnpm install # workspace 识别新插件并链接依赖
pnpm build # 构建 + 逐个插件 tsc 产出声明
pnpm typecheck # 独立跑一遍类型检查
pnpm smoke # 起服务 → 校验 boot 清单 → 退出
实测输出:
built dsh-brand-pingan-plugin [host+client] -> packages/plugins/brand-pingan/dist
typecheck ok: packages\plugins\brand-pingan
typecheck ok: packages\plugins\expert-team
typecheck ok: packages\plugins\host-only
typecheck ok: packages\plugins\starter
smoke: ok — http://127.0.0.1:51961 served 3 client module(s), and the host-only plugin applied
[host+client] 说明两个面都被构建脚本识别;smoke 里的 3 个客户端模块对应 starter、expert-team 和新增的 brand-pingan(host-only 没有 UI 面,不产生模块)。产物开头应当是:
window.__ModuleLoader__.load({
id: "dsh-brand-pingan-plugin",
factory: function (require) { ... }
});
最后 pnpm dev,打开终端打印的带 token 链接,目视确认:侧栏展开态的名字、侧栏与折叠轨的图标、空会话首页的大图标、浏览器标签页文字与图标。
排查顺序也固定:页面清单里搜不到 <包名>/client.js → 查声明 / 构建 / 注册;搜得到但界面没变 → 查插槽名、priority、React 是否被内联。
8. 换成正式 Logo 与品牌色
- 换图案:改
BrandGlyph(页面图标)和MARK_SVG(favicon)两处,保持一致;有正式 SVG 直接替换路径即可,PNG 则转成 base64 data URI。 - 换名字:只改
BRAND_NAME。 - 换主题色:官方推荐在自有插件里挂一段
:root变量覆盖(如--dsw-alias-*系列),不要改官方组件的内部类名或 DOM 结构。 - 三条红线:不改官方包文件(升级即失效)、不接管官方 DOM(React 会重渲染覆盖)、不复制一份官方组件再改(两份实例 = 两套状态)。
9. 小结
「万物皆插件」在这个需求里真正的含义,不是「什么都往插件里塞」,而是每个可独立开关的关注点,各自成为一个插件:品牌归品牌,示例功能归示例功能,彼此不 import,只通过插槽元数据协作。卸载品牌插件,鱼形兜底、本地构建标签和原来的标签页标题都会自动回来——这正是插槽体系「生命周期成对回收」的价值。
整个过程没有碰官方一行代码,改动集中在一个约 120 行的客户端文件里,并且可以用四条命令证明它真的进了官方的启动清单。
附:环境与参考
- 官方包:
@deepseek-ai/dsh@0.2.1-rc.5、@deepseek-ai/cordis@4.0.5,所有@deepseek-ai/*固定同一精确版本。 - 模板:
dsh-starter,packages/plugins/*由scripts/build-plugin.mjs、scripts/dev.mjs、scripts/smoke.mjs自动发现。 - 契约来源:
node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/types/client/contract/slots.d.ts、node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/types/client/contract/slots.d.ts、node_modules/@deepseek-ai/dsh-client-ui-slots/lib/types/index.d.ts。 - 侧栏行为说明:
@deepseek-ai/dsh-client-ui-sidebar/README.zh.md。