# dsh-ui-turn-rail

> **🌐 语言 / Language：** [English](README.md) · [**简体中文**](README.zh.md)

[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web GUI 的回合进度条插件：钉在会话滚动视口左侧的圆点竖条，已加载回合各一个圆点，支持每轮提问摘要、一次点击深度加载历史、以及 AI 选项的摘要显示。

**GitHub 话题**：`dsh-plugin` · `deepseek-harness`

---

## 功能

- **常驻可见的进度条**。圆点条 sticky 在会话滚动视口上，转录区滚动时始终可见——即使超长对话也不会消失。
- **每个「提问已加载」的轮次一个圆点**，提问尚未进入窗口的轮次用虚线占位点表示。AI 回复与思考过程不视为已加载的提问：提问还没翻页进来的轮次始终是占位点。
- **提问摘要**。悬停圆点可看到该轮第一个提问的单行预览。人工输入（`user` 节点）和从 AI 提供的选项中选择（`steering` 节点）都会生成摘要。
- **一次点击深度加载**。点击占位点后，插件会持续翻页，直到目标回合的提问真正进入窗口（页面边界可能先加载到该轮首个节点、而提问在更早的页里），然后直接跳到该提问所在行。
- **点击已加载圆点**：把转录区滚到该轮提问处。
- **可滚动的圆点窗口**。进度条一次显示约十个圆点，可内部滚动；读者移动时当前轮圆点自动跟入视野。

## 安全

- **纯展示**。进度条只渲染会话快照里已有的数据。它不会产生任何模型可见输入、不会写会话日志、不发网络请求、不接触任何凭据。
- **模型体验**：不进入任何模型请求；Token 影响无；KV 缓存影响无。
- **不新增权限**。宿主插桩（见下）只给聊天视图加一个只读座位；不引入新 RPC、权限或密钥处理。
- **数据都在宿主**。所有派生（轮次顺序、节点→轮次映射、摘要）都基于客户端 `ChatSnapshot` 计算；进度条自身不持有任何会话状态。

## 前置要求

一个 DeepSeek Harness 检出（或已发布的 `@deepseek-ai/dsh-*` 包）且**已应用宿主插桩**——进度条注册进 ui-conversation 聊天视图必须声明的 `conversation.chat.rail` 座位（见下）。没有该座位时插件可以加载但不渲染（座位渲染 `fallback: null`）。

## 安装

插件支持两种安装方式。**两种都不会自动化宿主插桩**——那是针对 harness 检出的源码级补丁（见[宿主插桩](#宿主插桩无法打包的部分)），无论包怎么装都必须应用并重建。

### 方式一——通过 `dsh plugin` 的 bundle 安装（推荐）

本仓库是一个 [DSH bundle](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.md)：`package.json` 声明了 `dsh.bundle.patch` → `./cordis.patch.yml`，因此 profile 插件管理器会把它作为补丁层安装，补丁会把插件插入 web profile 的浏览器花名册。

```sh
# 直接从 git 仓库安装（无需发版 npm）：
dsh plugin --profile web add @huanghanheng/dsh-ui-turn-rail

# 或发布到 npm 后（更短的 spec）：
dsh plugin --profile web add @huanghanheng/dsh-ui-turn-rail
```

`dsh plugin` 在 profile 目录里执行 `pnpm add`，并自动对账 `dsh.profile.bundles`：manifest 声明了 `dsh.bundle.patch` 的依赖会自动加入层栈。

**然后应用宿主插桩**——profile 运行的检出必须有该座位，进度条才会渲染。本仓库附带现成补丁，一条命令应用（然后重建 web 客户端）：

```sh
scripts/apply-instrumentation.sh /path/to/harness-checkout
(cd /path/to/harness-checkout && pnpm run build)
```

### 方式二——手动 npm 安装

包发布到 npm，且构建产物（`lib/`）已提交，因此可以像普通包一样安装：

```sh
# 在应用运行的 profile 目录（或应用本身）里：
npm install @huanghanheng/dsh-ui-turn-rail
# 或：pnpm add @huanghanheng/dsh-ui-turn-rail
```

手动 `npm install` **不会**自动加 bundle 层——需要在 profile manifest 里声明，补丁才会插入插件行：

```json
// $DSH_HOME/profiles/web/package.json
{
  "dependencies": { "@huanghanheng/dsh-ui-turn-rail": "^0.1.0" },
  "dsh": { "profile": { "bundles": ["@huanghanheng/dsh-ui-turn-rail"] } }
}
```

或者直接运行 `dsh plugin --profile web add @huanghanheng/dsh-ui-turn-rail`，它会替你完成安装与 bundle 层对账（等价于方式一）。

**然后应用宿主插桩并重建**，与方式一完全相同。

## 宿主插桩（无法打包的部分）

座位声明在 ui-conversation 的槽位契约里，跳转/翻页手势必须操作 ChatView 的私有滚动锚定——这两者都无法作为插件交付，因此 `dsh plugin add` 无法自动化它。本仓库把确切改动存为 `patches/chat-rail-seat.patch`（基于 harness master `b150a551b8`）；用 `scripts/apply-instrumentation.sh` 或 `git apply --3way` 应用后重建即可。如果你的检出已有漂移，请按下述改动手工应用。以下是确切改动：

### `packages/client/ui-conversation/src/client/contract/slots.ts`

1. SlotMap 条目：
   ```ts
   'conversation.chat.rail': { kind: 'single'; scope: 'session'; owner: ChatTurnRailOwnerProps }
   ```
2. Owner 数据契约：
   ```ts
   export interface ChatTurnRailOwnerProps {
     currentTurn: number | undefined
     loadingOlder: boolean
     hasMore: boolean
     onJump: (key: string) => void
     onLoadOlder: () => Promise<void>
   }
   ```
3. 在 `ChatViewSlotProps` 的 `PropsRenderSlots` 联合里加 `'conversation.chat.rail'`。
4. 把注入的分页契约放宽为返回 promise：
   ```ts
   loadOlder: () => Promise<void>
   ```

### `packages/client/ui-conversation/src/client/apply.ts`

在聊天视图 entry 的 children 里声明该座位：
```ts
children: {
  'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
  'conversation.message.images': { kind: 'single', scope: 'session' },
  'conversation.chat.rail': { kind: 'single', scope: 'session' },
},
```

### `packages/client/ui-conversation/src/client/chat/ChatView.tsx`

1. 用于滚动位置归因的节点键→轮次映射：
   ```ts
   const chat = useSession(s => s.chat)
   const turnByKey = useMemo(() => {
     const map = new Map<string, number>()
     for (const turn of chat.timeline.turnOrder) {
       for (const key of chat.locations.getTurn(turn)) map.set(key, turn)
     }
     return map
   }, [chat])
   ```
2. 在滚动处理器里跟踪 `currentTurn`（从 `turnOf(anchorKey)` 或钉在底部时的最新轮次设置）。
3. `loadOlderAnchored` 返回分页 promise：
   ```ts
   const loadOlderAnchored = (): Promise<void> => {
     // ...原有分页锚定逻辑...
     return loadOlder()
   }
   ```
4. 用 owner 数据渲染座位：
   ```tsx
   {renderSlot('conversation.chat.rail', {
     currentTurn,
     loadingOlder,
     hasMore,
     onJump: jumpTo,
     onLoadOlder: loadOlderAnchored,
   }, { fallback: null })}
   ```

## 开发

```sh
pnpm install
pnpm vitest run tests/        # 单元测试（模型 + 组件，jsdom）
pnpm bundle                   # tsdown 客户端打包（需要 harness 的 clientBundle preset）
```

测试覆盖回合模型（tick 派生、人工/AI 选项摘要、占位点）和组件（跳转、跨页边界的一次点击翻页、当前轮跟随、窗口滚动）。

## 许可证

MIT
