## 先说结论

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 页面验收。

## 预览效果
![效果图](https://www.ittt.cc/static/uploads/article/2026/08/a73ad3913aab8f1c626a4fe359a02e2b.png)

## 先理解 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` |

加载链路如下：

```text
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 服务依赖。两者不是同一份配置，通常都需要声明。

## 创建插件目录

本文使用以下目录：

```text
/Users/cc/Sites/harness/dsh-plugin/
```

最终结构如下：

```text
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
```

初始化目录：

```bash
mkdir -p /Users/cc/Sites/harness/dsh-plugin/src/client
cd /Users/cc/Sites/harness/dsh-plugin
```

## 编写 package.json

这个包既是 bundle，也是 Web Client 插件。核心 manifest 可以写成：

```json
{
  "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 冒烟测试。

```bash
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`：

```js
/** Host loader entry for the browser-only UI plugin. */
export function apply() {}
```

创建 `cordis.patch.yml`：

```yaml
- 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` 需要注册到页面的模块加载器：

```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. 用 `banner`、`intro` 和 `footer` 包装 `window.__ModuleLoader__.load()`。

核心配置如下：

```ts
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`：

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

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

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

但只写这两句不够可靠。

Harness 的用户设置会异步加载已保存的 `light`、`dark` 或 `system` 偏好。如果插件先调用 `setTheme()`，设置服务随后完成 adoption，自定义主题可能又被切回用户原来的偏好。表现就是：WebGL 动画已经出现，但整个 Harness 仍然使用白色背景和黑色文字。

本文采用的处理方式是监听 `theme/change`，只要活动主题不是插件主题，就重新激活它：

```ts
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、状态条和浮动提示。注册方法如下：

```ts
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 的直接子节点。

核心代码如下：

```tsx
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>
    </>
  )
}
```

这样形成两个视觉层：

```text
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-*` 标识。本文使用以下选择器：

```css
: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`，因此动画在栏目背后，不会降低正文和按钮对比度。

移动端需要缩小外框留白和圆角：

```css
@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 的处理过程可以概括为：

```text
二维 value noise
    ↓
五层 FBM
    ↓
两组低频场交叉扭曲坐标
    ↓
生成宽阔褶皱高度场
    ↓
对高度场做有限差分，得到近似法线
    ↓
漫反射 + 少量高光
    ↓
深海蓝、钴蓝、电光蓝混色
```

运行时还要处理性能和生命周期：

- 设备像素比封顶为 `1.6`，避免高分屏无上限增加片元数量；
- 标签页隐藏时停止申请新动画帧；
- `prefers-reduced-motion: reduce` 时只绘制固定帧；
- WebGL 初始化或 shader 编译失败时保留 CSS 渐变背景；
- 卸载时取消 `requestAnimationFrame`，删除 buffer 和 program；
- 监听器全部由同一个 disposer 移除。

输出透明 Canvas 时应使用预乘颜色：

```glsl
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 中属于未定义行为，应写成：

```glsl
float fadeOut = 1.0 - smoothstep(0.80, 1.0, normalized.x);
```

不要写成 `smoothstep(1.0, 0.80, normalized.x)`，即使某个浏览器和显卡暂时显示正常。

## 构建并检查插件

在插件目录安装依赖并构建：

```bash
cd /Users/cc/Sites/harness/dsh-plugin
pnpm install
pnpm run build
```

检查以下文件已经生成：

```bash
ls -lh lib/client.js lib/client.js.map
```

再执行类型、语法和模块装载检查：

```bash
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` 用于确认最终包至少包含：

```text
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

```bash
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`：

```bash
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
```

打开：

```text
http://127.0.0.1:3080
```

检查 `--dump-config` 输出中同时出现 bundle 分层标记和插件行：

```text
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`：

```bash
cd /Users/cc/Sites/harness/dsh-plugin
pnpm run build
pnpm run typecheck
pnpm run smoke
```

然后重启 `dsh web`，再强制刷新浏览器：

```text
macOS: Command + Shift + R
Windows / Linux: Ctrl + Shift + R
```

虽然某些开发环境会在普通刷新时读取到新 bundle，重启服务仍是最稳妥的验证方式，因为 Web profile 的共享 HMR 可能没有启用。

如果修改了包名、`dsh.bundle`、`cordis.patch.yml` 或 `dsh.client` 模块清单，应重新执行安装命令，让 profile manifest 与新的包元数据一致：

```bash
dsh plugin --profile web add /Users/cc/Sites/harness/dsh-plugin
```

## 从 tarball 加载

如果不想让目标机器直接链接源码目录，可以先打包：

```bash
cd /Users/cc/Sites/harness/dsh-plugin
pnpm run build
pnpm pack
```

再安装生成的 tarball：

```bash
dsh plugin --profile web add ./dsh-deepsea-ui-0.1.0.tgz
```

tarball 必须已经包含 `lib/client.js`。与 GitHub 源码安装不同，预构建 tarball 不需要在安装期间执行构建脚本，也不需要授权依赖包运行 `prepare`。

## 卸载插件

卸载分为“从 profile 停用”和“删除本地源码”两件事。通常只需要第一步。

### 从 web profile 移除

先停止正在运行的 Web 服务。终端前台运行时按：

```text
Ctrl + C
```

使用全局 CLI 卸载：

```bash
dsh plugin --profile web remove dsh-deepsea-ui
```

从源码运行 CLI 时使用：

```bash
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 列表仍然引用插件，下一次启动会因为找不到组合包而失败。

### 验证已经卸载

输出最终配置：

```bash
dsh --profile web --dump-config
```

从源码运行时：

```bash
pnpm dsh --profile web --dump-config
```

输出中不应再出现：

```text
dsh-deepsea-ui
deepsea-ui
```

重新启动 Web UI：

```bash
dsh web
```

或：

```bash
pnpm dsh web
```

最后强制刷新浏览器。旧页面已经执行过 Client bundle，即使磁盘上的 profile 完成卸载，当前标签页在重新加载前仍可能保留主题、Canvas 和样式。

### 删除本地源码

确认 profile 已经移除插件后，如果不再需要开发目录，才删除：

```text
/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 页面：

```bash
cd /Users/cc/Sites/harness/dsh-plugin
pnpm run preview
```

默认跳转到：

```text
http://127.0.0.1:3080
```

Harness 使用其他地址时：

```bash
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"]` 路径写错。执行：

```bash
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 渲染。

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

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

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

## 参考资料

- [DeepSeek Harness 官方仓库](https://github.com/deepseek-ai/deepseek-harness)
- [DeepSeek Harness 架构说明](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.zh.md)
- [插件打包与安装](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.zh.md)
- [Client 模块加载器说明](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/modules/README.md)
- [Client Runtime 与插槽注入](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/README.md)
