先说结论
DeepSeek Harness 的 Web 界面可以通过独立插件定制,而且不需要 fork 官方 UI 包,也不需要改动 Agent Loop。
本文会从零实现一个名为 dsh-deepsea-ui 的 UI 插件。它完成四件事:
- 注册一套深蓝暗色主题;
- 使用原创 WebGL shader 绘制缓慢流动的丝绸背景;
- 通过 Harness 的
shell.overlay插槽获得完整生命周期,再用 React Portal 把 Canvas 放到真实 AppFrame 的内容下方; - 把插件打包成可安装 bundle,并支持通过 profile 加载、更新和卸载。
最终插件不会替换 Harness 原有的会话、工作区、工具调用、审批、设置或输入框。它只改变视觉层,因此上游 UI 继续负责产品功能,插件负责主题和背景。
本文以 DeepSeek Harness 0.1.0-rc.5 源码为开发基线。项目仍处于开发者预览阶段,Client 插槽、构建产物和包元数据后续可能变化。升级 Harness 时,应重新执行构建和真实 Web 页面验收。
预览效果

先理解 Harness UI 插件怎样被加载
一个可安装的 Harness UI 插件同时存在于 Node 和浏览器两侧。
| 层 | 作用 | 本文对应文件 |
|---|---|---|
| Host 入口 | 让 Cordis Loader 能挂载这个包 | index.js |
| Bundle 配置层 | 向 profile 插入插件行 | cordis.patch.yml |
| Client 入口 | 注册主题、插槽和 React 组件 | src/client/index.tsx |
| 浏览器产物 | 由 Harness 模块加载器获取并执行 | lib/client.js |
| npm manifest | 声明 bundle、Client 模块和导出 | package.json |
加载链路如下:
dsh plugin add
↓
把包加入 web profile 的 dependencies 和 bundles
↓
应用 cordis.patch.yml
↓
Host Loader 挂载 dsh-deepsea-ui
↓
Client 模块扫描器发现 dsh.client
↓
浏览器下载 /plugins/dsh-deepsea-ui/client.js
↓
Client Cordis 执行 apply(ctx)
↓
主题、插槽、React Portal 和 WebGL 背景开始工作
这里有一个容易混淆的地方:dsh.client.inject 描述浏览器模块依赖,而 Client 代码导出的 inject 描述 Cordis 服务依赖。两者不是同一份配置,通常都需要声明。
创建插件目录
本文使用以下目录:
/Users/cc/Sites/harness/dsh-plugin/
最终结构如下:
dsh-plugin/
├── package.json
├── pnpm-lock.yaml
├── index.js
├── cordis.patch.yml
├── tsconfig.json
├── tsdown.config.ts
├── lib/
│ ├── client.js
│ └── client.js.map
├── scripts/
│ ├── preview.mjs
│ └── smoke.mjs
└── src/client/
├── index.tsx
├── DeepSeaBackdrop.tsx
├── DeepSeaBackdrop.module.css
├── deepsea-shader.js
├── css-modules.d.ts
└── harness.d.ts
初始化目录:
mkdir -p /Users/cc/Sites/harness/dsh-plugin/src/client
cd /Users/cc/Sites/harness/dsh-plugin
编写 package.json
这个包既是 bundle,也是 Web Client 插件。核心 manifest 可以写成:
{
"name": "dsh-deepsea-ui",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"exports": {
".": "./index.js",
"./client": "./lib/client.js",
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
}
},
"scripts": {
"build": "tsdown",
"smoke": "node scripts/smoke.mjs",
"typecheck": "tsc --noEmit"
}
}
几个字段各有明确职责:
dsh.bundle.patch表示这是一个可加入 profile 的组合包;exports["./client"]指向构建后的浏览器入口;dsh.client.platform限定它只在 Web 表面加载;dsh.client.inject让 Client 模块图先准备 runtime、layout 和 theme;- 根导出
.指向 Host 入口,不能只提供浏览器文件。
构建依赖包括 TypeScript、tsdown、Lightning CSS、React 和 ReactDOM。React 与 ReactDOM 在浏览器运行时由 Harness 平台模块提供;开发依赖用于本地类型检查和 bundle 冒烟测试。
pnpm add -D \
typescript \
tsdown \
lightningcss \
react@18 \
react-dom@18 \
@types/react@18 \
@types/react-dom@18
添加 Host 入口和 bundle patch
UI 插件没有 Host 业务逻辑,但 Loader 仍然需要一个合法入口。
创建 index.js:
/** Host loader entry for the browser-only UI plugin. */
export function apply() {}
创建 cordis.patch.yml:
- insert:
- id: deepsea-ui
name: dsh-deepsea-ui
id 是配置树中的行标识,name 必须是 profile 能通过 Node 模块解析找到的包名。
不要把本地源码相对路径写进这个 patch。通过 dsh plugin add /absolute/path/to/plugin 安装后,profile 会维护本地链接,patch 仍然只引用包名。
生成 Harness 能识别的浏览器 bundle
普通 ESM 文件不能直接作为 Harness Client 插件产物。lib/client.js 需要注册到页面的模块加载器:
window.__ModuleLoader__.load({
id: "dsh-deepsea-ui",
factory: (require) => {
// bundled CommonJS module
return module.exports;
}
});
因此,tsdown.config.ts 至少需要完成三件事:
- 输出浏览器 CJS bundle;
- 把 React、ReactDOM 和 Harness 平台模块保留为 external;
- 用
banner、intro和footer包装window.__ModuleLoader__.load()。
核心配置如下:
const ID = 'dsh-deepsea-ui'
const CLIENT_EXTERNALS = [
'react',
'react/jsx-runtime',
'react-dom',
'react-dom/client',
'@deepseek-ai/cordis',
'@deepseek-ai/dsh-client-runtime/client',
'@deepseek-ai/dsh-client-ui-slots',
]
export default {
name: `${ID}/client`,
entry: { client: 'src/client/index.tsx' },
outDir: 'lib',
format: 'cjs',
platform: 'browser',
dts: false,
sourcemap: true,
clean: true,
deps: {
neverBundle: CLIENT_EXTERNALS,
alwaysBundle: (id: string) =>
CLIENT_EXTERNALS.includes(id) ? undefined : true,
},
outputOptions: {
entryFileNames: 'client.js',
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(ID)}, factory: (require) => {`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
footer: 'return module.exports; } });',
},
}
本文插件还在 tsdown 配置中加入了一个 CSS Module 转换插件。它使用 Lightning CSS 生成哈希类名,把压缩后的 CSS 写入 <style data-plugin="dsh-deepsea-ui">,再导出类名映射。
这样做有两个好处:
- 插件只需要交付一个
lib/client.js; - Client 模块卸载时,Harness 可以识别并移除这个插件拥有的样式标签。
注册主题时要处理异步设置覆盖
Client 入口需要依赖 slots 和 theme:
export const inject = ['slots', 'theme']
最直观的主题注册方式是:
const dispose = ctx.theme.register(DEEPSEA_THEME)
ctx.theme.setTheme(DEEPSEA_THEME.id)
但只写这两句不够可靠。
Harness 的用户设置会异步加载已保存的 light、dark 或 system 偏好。如果插件先调用 setTheme(),设置服务随后完成 adoption,自定义主题可能又被切回用户原来的偏好。表现就是:WebGL 动画已经出现,但整个 Harness 仍然使用白色背景和黑色文字。
本文采用的处理方式是监听 theme/change,只要活动主题不是插件主题,就重新激活它:
ctx.effect(() => {
const dispose = ctx.theme.register(DEEPSEA_THEME)
const enforce = (): void => {
if (ctx.theme.getTheme().active.id !== DEEPSEA_THEME.id) {
ctx.theme.setTheme(DEEPSEA_THEME.id)
}
}
const off = ctx.on('theme/change', enforce)
enforce()
return () => {
off()
dispose()
}
}, 'deepsea-ui: enforced theme registration')
这段代码有三个关键点:
- 所有注册和监听都进入
ctx.effect(),卸载时能够精确撤销; - 事件处理器先比较当前主题,避免
setTheme()触发递归循环; - 注销时先移除监听,再注销主题,防止主题注销事件又把它激活。
这种实现意味着:插件启用期间始终使用它自己的主题。若插件只想提供一套可选皮肤,而不想强制启用,则不应监听并纠正 theme/change,而应把主题选择权留给用户。
为什么不能直接把 Canvas 放在 shell.overlay
shell.overlay 是 AppFrame 提供的全屏列表插槽,适合 Toast、状态条和浮动提示。注册方法如下:
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: 'deepsea-environment',
order: -100,
}, DeepSeaBackdrop))
使用 ctx.slots.inject() 而不是直接 register(),是因为 shell.overlay 由 layout 插件声明。inject() 会等待这个插槽存在,并在插槽声明被替换或插件卸载时正确清理注册。
但是,overlay 默认位于所有栏目的上方。即使 Canvas 设置了透明度,它仍然会在文字和按钮上方合成,结果更像一层蓝色滤镜,而不是真正背景。浅色主题下尤其容易出现整页被“洗白”的问题。
简单设置负 z-index 也不能解决,因为 Canvas 仍然受 overlay stacking context 限制,无法可靠穿到外部内容后面。
用 React Portal 把动画放到 AppFrame 底层
最终实现仍然通过 shell.overlay 获得组件生命周期,但用 ReactDOM 的 createPortal() 把背景节点渲染为 AppFrame 的直接子节点。
核心代码如下:
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
export function DeepSeaBackdrop() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [frame, setFrame] = useState<HTMLElement | null>(null)
useEffect(() => {
const element = document.querySelector<HTMLElement>(
"[data-slot='root'] > *",
)
setFrame(element)
}, [])
useEffect(() => {
const canvas = canvasRef.current
if (canvas === null) return
return startDeepSeaShader(canvas, { opacity: 0.82 })
}, [frame])
return (
<>
{frame === null ? null : createPortal(
<div className={css.backdrop} aria-hidden="true">
<canvas ref={canvasRef} className={css.canvas} />
<div className={css.depthWash} />
</div>,
frame,
)}
<div className={css.hudLayer} aria-hidden="true">
{/* 坐标刻度与状态标识仍留在 overlay */}
</div>
</>
)
}
这样形成两个视觉层:
AppFrame
├── WebGL backdrop z-index: 0
├── sidebar z-index: 1
├── conversation z-index: 1
├── details z-index: 1
└── shell.overlay z-index: 20
└── HUD / Toast / 其他浮层
React Portal 仍由当前组件拥有。组件卸载时,React 会删除 portal 节点,WebGL effect 的 disposer 会取消动画帧、移除事件监听器,并释放 buffer 和 program。
不要依赖 CSS Module 的哈希类名
Harness 自带 UI 使用 CSS Modules,构建后的类名类似 _6TylQG_frame。这些哈希值不是稳定扩展接口,插件不应该据此覆盖样式。
布局和插槽节点提供了更稳定的 data-* 标识。本文使用以下选择器:
:global([data-slot='root'] > *) {
width: calc(100% - 36px) !important;
height: calc(100% - 36px) !important;
margin: 18px !important;
overflow: hidden !important;
border: 1px solid rgb(157 191 255 / 14%);
border-radius: 18px;
isolation: isolate;
background: rgb(3 12 31 / 72%);
}
:global([data-slot='sidebar'] > *),
:global([data-slot='conversation'] > *),
:global([data-slot='details'] > *) {
position: relative;
z-index: 1;
}
isolation: isolate 为 AppFrame 建立独立层叠环境。背景节点使用 z-index: 0,三个产品栏目使用 z-index: 1,因此动画在栏目背后,不会降低正文和按钮对比度。
移动端需要缩小外框留白和圆角:
@media (max-width: 820px) {
:global([data-slot='root'] > *) {
width: calc(100% - 12px) !important;
height: calc(100% - 12px) !important;
margin: 6px !important;
border-radius: 12px;
}
}
编写 WebGL 丝绸背景
本文没有下载或热链 DeepSeek 官网资源。背景只是参考其深蓝、低频、丝绸褶皱的视觉方向,shader 和运行代码均独立实现。
片元 shader 的处理过程可以概括为:
二维 value noise
↓
五层 FBM
↓
两组低频场交叉扭曲坐标
↓
生成宽阔褶皱高度场
↓
对高度场做有限差分,得到近似法线
↓
漫反射 + 少量高光
↓
深海蓝、钴蓝、电光蓝混色
运行时还要处理性能和生命周期:
- 设备像素比封顶为
1.6,避免高分屏无上限增加片元数量; - 标签页隐藏时停止申请新动画帧;
prefers-reduced-motion: reduce时只绘制固定帧;- WebGL 初始化或 shader 编译失败时保留 CSS 渐变背景;
- 卸载时取消
requestAnimationFrame,删除 buffer 和 program; - 监听器全部由同一个 disposer 移除。
输出透明 Canvas 时应使用预乘颜色:
float alpha = u_opacity * vignette * edgeFade;
gl_FragColor = vec4(color * alpha, alpha);
如果使用 gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA),却输出未乘 alpha 的 RGB,动画叠加到页面时可能异常明亮。
smoothstep() 的两个边界也必须保持从小到大。反向边界在 GLSL 中属于未定义行为,应写成:
float fadeOut = 1.0 - smoothstep(0.80, 1.0, normalized.x);
不要写成 smoothstep(1.0, 0.80, normalized.x),即使某个浏览器和显卡暂时显示正常。
构建并检查插件
在插件目录安装依赖并构建:
cd /Users/cc/Sites/harness/dsh-plugin
pnpm install
pnpm run build
检查以下文件已经生成:
ls -lh lib/client.js lib/client.js.map
再执行类型、语法和模块装载检查:
pnpm run typecheck
node --check lib/client.js
pnpm run smoke
pnpm pack --dry-run
smoke 脚本应模拟 window.__ModuleLoader__.load(),确认:
- 注册 id 是
dsh-deepsea-ui; - bundle 导出
inject和apply; - CSS Module 被注入并带有插件标识;
- React 和 ReactDOM external 能由传入的
require解析。
pnpm pack --dry-run 用于确认最终包至少包含:
cordis.patch.yml
index.js
lib/client.js
lib/client.js.map
package.json
README.md
只通过 tsc --noEmit 并不能证明插件可用。Harness 实际获取的是 lib/client.js,所以浏览器 bundle 和模块加载器冒烟测试不可省略。
加载本地插件
加载前先构建插件。下面分别给出全局 CLI 和源码 checkout 两种命令。
使用已经安装的 dsh CLI
cd /Users/cc/Sites/harness/dsh-plugin
pnpm install
pnpm run build
dsh plugin --profile web add /Users/cc/Sites/harness/dsh-plugin
dsh --profile web --dump-config
dsh web
add 会把本地目录以链接依赖加入 web profile,并把 dsh-deepsea-ui 追加到 profile 的 bundle 列表。
从 DeepSeek Harness 源码运行
进入 Harness 仓库,把 dsh 改为 pnpm dsh:
cd /Users/cc/Sites/harness/deepseek-harness
pnpm dsh plugin --profile web add /Users/cc/Sites/harness/dsh-plugin
pnpm dsh --profile web --dump-config
pnpm dsh web
打开:
http://127.0.0.1:3080
检查 --dump-config 输出中同时出现 bundle 分层标记和插件行:
dsh-deepsea-ui
id: deepsea-ui
name: dsh-deepsea-ui
如果 dump-config 中没有这行,说明问题发生在 profile 或 bundle patch;如果配置存在但浏览器没有效果,再检查 lib/client.js 和 dsh.client。
更新正在开发的本地插件
本地目录通过 dsh plugin add /absolute/path 安装时,profile 通常保存的是链接。只修改 TS、TSX、CSS 或 shader 时,不需要每次重新执行 plugin add,但必须重新生成 lib/client.js:
cd /Users/cc/Sites/harness/dsh-plugin
pnpm run build
pnpm run typecheck
pnpm run smoke
然后重启 dsh web,再强制刷新浏览器:
macOS: Command + Shift + R
Windows / Linux: Ctrl + Shift + R
虽然某些开发环境会在普通刷新时读取到新 bundle,重启服务仍是最稳妥的验证方式,因为 Web profile 的共享 HMR 可能没有启用。
如果修改了包名、dsh.bundle、cordis.patch.yml 或 dsh.client 模块清单,应重新执行安装命令,让 profile manifest 与新的包元数据一致:
dsh plugin --profile web add /Users/cc/Sites/harness/dsh-plugin
从 tarball 加载
如果不想让目标机器直接链接源码目录,可以先打包:
cd /Users/cc/Sites/harness/dsh-plugin
pnpm run build
pnpm pack
再安装生成的 tarball:
dsh plugin --profile web add ./dsh-deepsea-ui-0.1.0.tgz
tarball 必须已经包含 lib/client.js。与 GitHub 源码安装不同,预构建 tarball 不需要在安装期间执行构建脚本,也不需要授权依赖包运行 prepare。
卸载插件
卸载分为“从 profile 停用”和“删除本地源码”两件事。通常只需要第一步。
从 web profile 移除
先停止正在运行的 Web 服务。终端前台运行时按:
Ctrl + C
使用全局 CLI 卸载:
dsh plugin --profile web remove dsh-deepsea-ui
从源码运行 CLI 时使用:
cd /Users/cc/Sites/harness/deepseek-harness
pnpm dsh plugin --profile web remove dsh-deepsea-ui
remove 会同时完成两项清理:
- 从 profile 的 dependencies 中移除
dsh-deepsea-ui; - 从
dsh.profile.bundles中移除对应配置层。
不要只手工删除 node_modules 链接。那样 profile 的 bundle 列表仍然引用插件,下一次启动会因为找不到组合包而失败。
验证已经卸载
输出最终配置:
dsh --profile web --dump-config
从源码运行时:
pnpm dsh --profile web --dump-config
输出中不应再出现:
dsh-deepsea-ui
deepsea-ui
重新启动 Web UI:
dsh web
或:
pnpm dsh web
最后强制刷新浏览器。旧页面已经执行过 Client bundle,即使磁盘上的 profile 完成卸载,当前标签页在重新加载前仍可能保留主题、Canvas 和样式。
删除本地源码
确认 profile 已经移除插件后,如果不再需要开发目录,才删除:
/Users/cc/Sites/harness/dsh-plugin
删除源码目录不是卸载 profile 的替代操作。顺序必须是先 dsh plugin remove,确认配置不再引用插件,再处理本地文件。
插件卸载后为什么能够恢复原 UI
可逆生命周期是 Cordis 插件模型的重要部分。
本文插件卸载时会依次发生:
theme/change监听器被移除;- 自定义主题注册被注销;
shell.overlay条目被撤销;- React Portal 节点被卸载;
- WebGL 动画帧和事件监听器被取消;
- GPU buffer 和 program 被释放;
- 模块加载器移除插件拥有的 CSS 标签。
因此插件不需要在卸载脚本中手工恢复每一个 DOM 样式。真正需要做的是保证所有副作用都进入 Cordis effect 或 React effect,并返回准确 disposer。
不要再维护一套假的预览 UI
开发 UI 插件时,很容易单独做一个静态页面,先把理想布局画出来。这种页面可以用于探索视觉方向,但不能作为插件验收结果。
静态 mock 通常会伪造侧栏、会话、工具数据和遥测面板,而真实 Harness 页面由多个插件和运行状态组装。两者即使颜色相同,DOM、功能和当前会话状态也不会一致。
本文最终取消了独立 mock。pnpm run preview 只把 4173 作为跳转入口,打开 3080 的真实 Harness 页面:
cd /Users/cc/Sites/harness/dsh-plugin
pnpm run preview
默认跳转到:
http://127.0.0.1:3080
Harness 使用其他地址时:
DSH_WEB_URL=http://127.0.0.1:4000 pnpm run preview
UI 插件的最终验收必须发生在真实服务、真实 Client 模块和真实产品状态中。
常见问题
dump-config 中没有插件
先确认 package.json 包含 dsh.bundle.patch,再确认 cordis.patch.yml 插入的 name 与包名完全一致。重新执行 dsh plugin --profile web add <path>。
启动时报找不到 lib/client.js
插件源码尚未构建,或者 exports["./client"] 路径写错。执行:
pnpm run build
ls -lh lib/client.js
Canvas 出现,但页面仍然是浅色
通常是自定义主题先激活,随后又被异步加载的用户设置覆盖。检查是否监听 theme/change,以及事件处理器是否在活动主题变化后重新激活插件主题。
页面变成浅蓝色,文字对比度很低
Canvas 仍然位于 shell.overlay 的内容上方。检查背景是否通过 Portal 渲染到 AppFrame,三个内容栏是否使用更高的 z-index。
修改源码后浏览器没有变化
浏览器加载的是 lib/client.js,不是 src/client/index.tsx。重新构建、重启 dsh web,再强制刷新浏览器。
remove 后界面仍保留插件样式
先确认 Web 服务已经重启,再确认浏览器标签页已经重新加载。一个已经执行过的页面不会因为另一个终端修改 profile 就自动撤销内存中的 Client 插件。
WebGL 不可用
插件应把 WebGL 当作增强效果,而不是功能依赖。初始化失败时返回空 disposer,并保留 CSS 深蓝渐变。聊天、工具和设置功能不能依赖 shader 是否成功。
完成标准
一个可交付的 Harness UI 插件至少应完成以下检查:
- Host 入口可被 Loader 导入;
- bundle patch 能通过
--dump-config看到; exports["./client"]指向真实存在的构建产物;- Client bundle 使用 Harness 模块加载器包装格式;
- 跨插件运行时协作只通过服务和插槽;
- 注册、事件、动画和 GPU 资源都有 disposer;
- 动画尊重 reduced motion,并在隐藏标签页暂停;
- 样式使用稳定的
data-slot,不依赖 CSS Module 哈希; - 在真实
dsh web页面检查过亮度、文字对比度和交互; add、dump-config、启动、remove和卸载后恢复都走通;- 发布包包含
lib/client.js,不依赖用户机器旁边恰好存在 Harness 源码。
总结
DeepSeek Harness 的 UI 插件不是简单往页面追加一个 <script>。它同时经过 profile bundle、Host Loader、Client 模块扫描、Cordis 服务注入、插槽注册和 React 渲染。
本文实现的关键经验可以概括为五点:
- 用
dsh.bundle和cordis.patch.yml让插件成为可安装配置层; - 用
dsh.client和exports["./client"]交付浏览器模块; - 用 Theme Service 注册并管理主题生命周期;
- 用
shell.overlay获得组合生命周期,再用 React Portal 把动画放到内容下方; - 用
dsh plugin add和remove管理 profile,不手工修改依赖和 bundle 列表。
真正值得保留的不是某一种深蓝配色,而是这套开发方式:扩展现有 UI,保留原有功能,把所有副作用纳入插件生命周期,并始终在真实 Harness 页面验证最终结果。