不改一行官方源码:用一个插件把 DeepSeek Harness 换成「平安one」

0 阅读11分钟

环境:@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 / scopeowner props
侧栏 Logo(展开行 + 折叠轨共用)sidebar.brand.marksingle / root{ size: number }
侧栏品牌名(仅展开态)sidebar.brand.namesingle / root无
空会话首页大 Logoconversation.hero.brand.marksingle / 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(演示纯服务端)。把品牌代码塞进其中任何一个都能跑起来,但不该这么做:

  1. 职责与生命周期错配。品牌是全局、长期存在的能力,绑到某个 demo 插件上,等于「删掉示例功能就丢掉品牌」。模板的验收标准恰恰是:删掉任意插件后,界面必须回到与官方安装包一致的状态。
  2. single 槽是独占资源。同一个 single 槽里,同一 priority(默认 0)的第二次注册会在注册期直接抛错。把品牌集中在一个插件里持有,才便于独立启用、禁用、灰度,也避免将来多个插件抢同一格。
  3. 新建成本几乎为零。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
客户端打包内联了 Reacthooks 报错,「加载了但没 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。