先说结论

DeepSeek Harness 的 Web 界面可以通过独立插件定制,而且不需要 fork 官方 UI 包,也不需要改动 Agent Loop。

本文会从零实现一个名为 dsh-deepsea-ui 的 UI 插件。它完成四件事:

  1. 注册一套深蓝暗色主题;
  2. 使用原创 WebGL shader 绘制缓慢流动的丝绸背景;
  3. 通过 Harness 的 shell.overlay 插槽获得完整生命周期,再用 React Portal 把 Canvas 放到真实 AppFrame 的内容下方;
  4. 把插件打包成可安装 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 至少需要完成三件事:

  1. 输出浏览器 CJS bundle;
  2. 把 React、ReactDOM 和 Harness 平台模块保留为 external;
  3. bannerintrofooter 包装 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 入口需要依赖 slotstheme

export const inject = ['slots', 'theme']

最直观的主题注册方式是:

const dispose = ctx.theme.register(DEEPSEA_THEME)
ctx.theme.setTheme(DEEPSEA_THEME.id)

但只写这两句不够可靠。

Harness 的用户设置会异步加载已保存的 lightdarksystem 偏好。如果插件先调用 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 导出 injectapply
  • 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.jsdsh.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.bundlecordis.patch.ymldsh.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 会同时完成两项清理:

  1. 从 profile 的 dependencies 中移除 dsh-deepsea-ui
  2. 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 页面检查过亮度、文字对比度和交互;
  • adddump-config、启动、remove 和卸载后恢复都走通;
  • 发布包包含 lib/client.js,不依赖用户机器旁边恰好存在 Harness 源码。

总结

DeepSeek Harness 的 UI 插件不是简单往页面追加一个 <script>。它同时经过 profile bundle、Host Loader、Client 模块扫描、Cordis 服务注入、插槽注册和 React 渲染。

本文实现的关键经验可以概括为五点:

  1. dsh.bundlecordis.patch.yml 让插件成为可安装配置层;
  2. dsh.clientexports["./client"] 交付浏览器模块;
  3. 用 Theme Service 注册并管理主题生命周期;
  4. shell.overlay 获得组合生命周期,再用 React Portal 把动画放到内容下方;
  5. dsh plugin addremove 管理 profile,不手工修改依赖和 bundle 列表。

真正值得保留的不是某一种深蓝配色,而是这套开发方式:扩展现有 UI,保留原有功能,把所有副作用纳入插件生命周期,并始终在真实 Harness 页面验证最终结果。

参考资料