Ctrl K
ComunidadGuías…

docs/plan/2026-09-11-embed-sdk-history-session-list-technical-design.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Embed SDK 对话浮窗 · 历史会话列表技术方案

- 日期:2026-09-11
- 状态:已实施(分支 feat/embed-sdk-history-session-list)
- 涉及端:backend-ts(后端)、sdk/embed(Web Embed SDK)
- 不涉及:desktop UI、admin、android、agent-cli 的功能变更(仅 SDK 构建产物随 desktop 部署)

---

## 1. 需求背景

Web Embed SDK(`MaoChat.init()`)目前采用「每用户每 agent 一个常驻会话」模型:
sessionId 持久化在 localStorage(`sdk/embed/src/core/session-manager.ts`),用户点「新对话」
会新建会话并替换本地记录,旧会话保留在服务端(desktop 会话列表可见),但浮窗内**没有任何
入口可以回到旧会话**——旧会话一旦被替换,对浮窗用户来说等于丢失。

随着 SDK 在业务系统中的使用加深,用户在同一业务页面内会累积多轮独立的对话诉求
(例如先后问两个不相关的问题,期间还想回头追问前一轮)。需要一个历史会话列表,
支持在浮窗内直接切换回历史会话继续对话。

## 2. 需求描述

### 2.1 一句话需求

SDK 对话浮窗增加历史对话列表功能,支持切换到历史会话继续对话;列表只显示
**当前用户、当前 agent、由 SDK 创建**的会话。

### 2.2 要做的

1. **会话来源标记**:`session` 表新增来源字段;SDK 创建会话时标记 `source=embed`,
   以此作为历史列表的过滤依据。
2. **存量常驻会话惰性回填**:SDK 启动恢复 localStorage 中记录的常驻会话时,后端将该
   存量会话补标为 `embed`(更早被「新对话」替换掉的旧会话无法可靠识别,**不追溯**)。
3. **后端列表接口扩展**:`GET /v1/sessions` 新增可选过滤参数 `agentId`、`source`,并支持
   不依赖 groupKey 的 `offset/limit` 分页;不传新参数时行为完全不变。
4. **浮窗历史列表面板**:标题栏「新对话」旁新增「历史」入口,以覆盖层形式展开会话列表;
   列表项为「标题 + 相对时间」,按 `updated_at` 倒序,每页 20 条,滚动到底自动加载下一页;
   列表顶部提供「新对话」项。
5. **会话切换**:点击列表项切换会话——本地 unsubscribe 旧会话 → subscribe 新会话,
   进行中任务在服务端继续跑完不中断;切回时通过 REST 重拉历史完成对账;
   localStorage 常驻会话指针同步更新。
6. **放开运行中限制**:流式输出进行中,「新对话」按钮与历史切换均允许操作(两者规则统一)。
7. **每次打开面板刷新列表**,不缓存跨打开周期的列表数据。

### 2.3 明确不做的

- ❌ 会话搜索、删除、重命名、置顶、归档、未读标记(列表只有「看 + 切」两个动作)。
- ❌ 显示 desktop 端创建的会话(`source` 过滤保证)。
- ❌ 按宿主站点(嵌入页域名)隔离会话——同一用户同一 agent 在所有接入站点共享列表。
- ❌ 追溯标记历史遗留的已替换会话。
- ❌ 切换会话时终止或提示终止进行中的任务。
- ❌ 跨浏览器页签的实时列表同步(沿用现有 TabsCoordinator 语义:localStorage 指针更新,
  其他页签下次操作时自然读到新值)。
- ❌ desktop / admin 端会话列表的任何改动(它们看不到 `source` 字段差异,行为不变)。
- ❌ 边路任务(side task):列表只返回主会话,后端过滤 `session_type`。

## 3. 技术选型与现状分析

### 3.1 为什么用「来源字段」而不是「按 agent 过滤全部会话」

- `session` 表目前没有任何字段区分会话来自哪个端(desktop / embed SDK / 机器人通道)。
  需求方已确认列表只含 SDK 自建会话,因此必须落库标记,客户端侧无法从现有数据推导。
- 仅按 `userId + agentId` 过滤会把 desktop 创建的工作会话混进浮窗列表,干扰性强,
  已被需求方明确排除。
- 不采用「按宿主站点隔离」:需要宿主传站点标识并存库,成本高,且当前无此隔离诉求。

### 3.2 为什么扩展现有 `GET /v1/sessions` 而不是新建专用接口

- 现有路由已具备 `requireUserId` 归属过滤、`enrichSessions`(agent 名/待审批计数等聚合)、
  groupKey 分页实现;扩展查询参数复用全部既有逻辑,改动集中在
  `session.routes.ts` / `session.service.ts` / `session.repository.ts` 三处。
- 新建 `/v1/sessions/embed` 需要复制路由、enrich、分页三套逻辑,后续双处维护,无收益。

### 3.3 切换会话复用的既有机制

| 既有机制 | 位置 | 在本方案中的用途 |
| --- | --- | --- |
| WS subscribe/unsubscribe 帧 | `ws-client.ts` / `StreamingWsHandler` | 切换时换绑事件流 |
| REST 重拉历史 + 对账 | `controller.ts`(断线重连对账) | 切回会话时还原消息与工具状态 |
| `session_snapshot` 终态对账 | controller | 处理切换期间已结束的任务残留状态 |
| localStorage 常驻指针 | `session-manager.ts` | 切换后更新「最近会话」,下次打开默认恢复 |
| TabsCoordinator | `core/tabs.ts` | 多页签下避免重复建会话,切换后指针一致 |

## 4. 详细设计

### 4.1 数据模型(迁移 `V109__add_session_source.sql`)

```sql
ALTER TABLE `session`
  ADD COLUMN `source` VARCHAR(16) NOT NULL DEFAULT 'web'
  COMMENT '会话来源:web=桌面/Web 端创建,embed=Embed SDK 创建'
  AFTER `session_type`;

CREATE INDEX idx_session_user_agent_source_updated
  ON `session` (`user_id`, `agent_id`, `source`, `updated_at`);
```

- 列值枚举:`web`(默认,覆盖 desktop/常规 Web 创建)、`embed`。机器人通道会话
  (feishu/weixin)沿用默认值 `web`,本方案不为其引入新枚举。
- 已有的 `user_id` 查询路径不受影响;新索引服务 embed 列表查询的过滤+排序。

### 4.2 后端接口

#### 4.2.1 创建会话带来源:`POST /v1/sessions`

- 请求体新增可选字段 `source?: 'embed'`;缺省落 `web`。
- 仅接受 `embed` / 不传 两个取值,其他值返回参数错误(不做白名单扩展点)。
- 落库写入 `session.source`;会话 VO(`session-vo.ts`)透出 `source` 字段。

#### 4.2.2 存量惰性标记

- SDK 恢复 localStorage 常驻会话走 `GET /v1/sessions/:id`。方案:SDK 在 boot 恢复
  成功后,若该会话 VO 的 `source !== 'embed'`,调用新增的
  `PUT /v1/sessions/:id/source`(body: `{ source: 'embed' }`,仅允许 `web → embed`
  单向迁移,归属校验复用 `requireSessionOwner`)打标记。
- 该接口不暴露给 desktop/admin 使用,桌面端不感知。

#### 4.2.3 列表接口扩展:`GET /v1/sessions`

新增可选 query 参数:

| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `agentId` | int | 过滤指定 agent 的会话 |
| `source` | string | 过滤来源,仅接受 `web` / `embed` |
| `offset` / `limit` | int | 分页;与 `agentId` 或 `source` 任一同时使用时生效,`limit` 上限 50,默认 20 |

行为约定:

- 三个新参数**全部不传**时,走现有逻辑(groupKey 分支或全量列表),响应结构不变,
  desktop 不受影响。
- 传 `source=embed`(SDK 场景固定同时传 `agentId`)时:
  - 过滤条件:`user_id = 当前用户 AND agent_id = ? AND source = 'embed'
    AND session_type 为主会话 AND deleted = 0`;
  - 按 `updated_at DESC` 排序;
  - 响应结构:`{ items, total, offset, limit, hasMore }`,items 复用 `enrichSessions`;
  - 无 groupKey、keyword、status 参与(embed 列表不需要)。

### 4.3 SDK 会话层(`sdk/embed/src/core/`)

#### 4.3.1 `session-manager.ts`

- `createSession()` 请求体增加 `source: 'embed'`。
- 新增 `listSessions(offset: number): Promise<EmbedSessionPage>`:
  `GET /sessions?agentId={agentId}&source=embed&offset={offset}&limit=20`。
- 新增 `markSourceEmbed(id)`:恢复常驻会话后按 4.2.2 调用(VO 已是 embed 则跳过)。
- 既有 `resolveSession()` 语义不变:仍返回 localStorage 指向的「最近会话」。

#### 4.3.2 会话切换(`controller.ts`)

新增 `switchSession(id: number)`,步骤:

1. 若目标 id 等于当前会话 id,直接收起面板,不做任何网络操作。
2. `ws.unsubscribe(旧会话)` → `ws.subscribe(新会话)`(复用现有断线重连的
   全量 re-subscribe 数据结构,保证重连后订阅的是新会话)。
3. 清空内存中的消息/工具状态 → `GET /sessions/{id}` 校验可访问 →
   REST 拉取历史消息(复用现有历史映射逻辑 `loadHistory`)。
4. 等待 `session_snapshot` 到达完成终态对账(复用现有机制);
   若原会话有进行中任务,任务在服务端继续执行,切换期间事件不再接收。
5. `sessionManager.writeStoredSessionId(新id)` 更新常驻指针。
6. 失败路径:任一步骤抛错则回退订阅到原会话并提示「切换失败」,不产生半切换状态。

#### 4.3.3 放开运行中限制

- `ChatPanel.vue` 中「新对话」按钮移除 `:disabled="running"`;
- `controller.newSession()` / `switchSession()` 内部不做 running 拦截。

### 4.4 SDK UI(`sdk/embed/src/ui/`)

- **入口**:`ChatPanel.vue` 标题栏「新对话」按钮左侧新增「历史」图标按钮(沿用
  `mao-panel__btn` 样式),点击切换面板可见性。
- **面板**:`HistoryPanel.vue`(新增),绝对定位覆盖消息区(不遮挡标题栏与输入区):
  - 顶部固定「新对话」项(图标 + 文案),点击等价于现有 newSession 后收起面板;
  - 列表项:会话标题(超长省略)+ 相对时间(如「3 分钟前」「昨天」);
    当前活跃会话高亮标识;
  - 滚动容器触底时按 `offset` 递增加载下一页;`hasMore=false` 后显示「没有更多了」;
  - 首次打开与每次重新打开均重新拉取第一页;加载中显示既有三点脉动样式。
- **新增 SDK 实例方法**:`MaoChatInstance.toggleHistory()`(可选暴露,对齐现有
  open/close/toggle 命名),同时更新 `types.ts`。
- 文案、主题色、圆角等全部复用 `theme.ts` / `style.css` 既有变量,不引入新样式体系。

### 4.5 边界与并发

| 场景 | 处理 |
| --- | --- |
| 列表为空 | 面板居中显示「暂无历史对话」,仅保留顶部「新对话」项 |
| 会话在列表加载后被删除(另一端操作) | 点击时 `GET /sessions/{id}` 报 3002/1002 → 从列表移除该项并提示「会话已不存在」 |
| 多页签同时打开 | 各页签独立拉列表与切换;localStorage 指针最后写入者生效,TabsCoordinator 既有约束不变 |
| 运行中切换后再切回 | 依赖 `session_snapshot` 对账 + REST 历史重拉,不维护切换期间的事件缓存 |
| `limit` 超限 / 非法 `source` | 后端 400 参数错误;SDK 侧固定传合法值 |
| SSO 模式 | 无差异:列表接口走同一 REST 鉴权;scope 仅影响 localStorage key,不影响服务端数据 |

## 5. 实现步骤

1. **迁移与实体**:新增 `V109__add_session_source.sql`;`session/types.ts` Session
   增加 `source` 字段;repository 创建/查询 SQL 适配。
2. **后端接口**:`POST /v1/sessions` 支持 `source`;`PUT /v1/sessions/:id/source`;
   `GET /v1/sessions` 扩展 `agentId`/`source`/`offset`/`limit`;`session-vo.ts` 透出 `source`。
3. **后端测试**:routes/service/repository 单测——新参数过滤正确性、不传参数行为回归、
   分页边界、source 非法值、PUT source 单向迁移与归属校验。
4. **SDK 核心层**:session-manager 扩展(source、listSessions、markSourceEmbed)、
   controller `switchSession` 与运行中限制放开,配套 vitest 单测(切换成功/失败回退/
   目标会话已删除/同会话幂等)。
5. **SDK UI**:HistoryPanel.vue + ChatPanel 接线 + 相对时间工具 + 组件测试。
6. **构建产物与版本**:`sdk/embed/package.json` 版本 `0.1.1 → 0.2.0`;构建并将产物
   同步至 `desktop/public/embed/`(`mao-chat.js` + 版本化副本 `mao-chat.v0.2.0.js`)。
7. **文档同步**:CHANGELOG.md 顶部新版本小节(用户/运维可见改动);README.md / 相关
   SDK 文档;skills/mao-cli 若涉及 SDK 说明则同步。

## 6. 落地清单

### 6.1 文件改动清单

| 层 | 文件 | 动作 |
| --- | --- | --- |
| 迁移 | `backend-ts/db/migration/V109__add_session_source.sql` | 新增 |
| 后端 | `backend-ts/src/session/types.ts` | 修改(Session.source) |
| 后端 | `backend-ts/src/session/session.repository.ts` | 修改(创建写 source、列表过滤/分页) |
| 后端 | `backend-ts/src/session/session.service.ts` | 修改(listSessions 扩展参数) |
| 后端 | `backend-ts/src/session/session.routes.ts` | 修改(POST source、PUT source、GET 列表参数) |
| 后端 | `backend-ts/src/session/session-vo.ts` | 修改(VO 透出 source) |
| 后端 | `shared/contracts/src/session.ts` | 修改(契约类型) |
| 后端 | `backend-ts/src/session/*.spec.ts` | 新增/修改(回归 + 新行为测试) |
| SDK | `sdk/embed/src/types.ts` | 修改(InitOptions 不变,Instance 增 toggleHistory) |
| SDK | `sdk/embed/src/core/session-manager.ts` | 修改 |
| SDK | `sdk/embed/src/core/session-manager.spec.ts` | 修改 |
| SDK | `sdk/embed/src/controller.ts` | 修改(switchSession、放开限制、恢复时打标) |
| SDK | `sdk/embed/src/controller.spec.ts` | 修改 |
| SDK | `sdk/embed/src/ui/HistoryPanel.vue` | 新增 |
| SDK | `sdk/embed/src/ui/HistoryPanel.spec.ts` | 新增 |
| SDK | `sdk/embed/src/ui/ChatPanel.vue` | 修改(历史入口、放开新对话禁用) |
| SDK | `sdk/embed/src/ui/ChatPanel.spec.ts` | 修改 |
| SDK | `sdk/embed/package.json` | 修改(0.2.0) |
| 产物 | `desktop/public/embed/mao-chat.js`、`mao-chat.v0.2.0.js` | 重新构建同步 |
| 文档 | `CHANGELOG.md`、`README.md`、`skills/mao-cli/SKILL.md`(如涉及) | 修改 |

### 6.2 验证清单

- `cd backend-ts && npm run build && npm test`:含列表新参数回归(不传参数行为不变)。
- `cd sdk/embed && npm test`(vitest):session-manager / controller / UI 组件测试。
- 手动验证(demo 页 `sdk/embed/demo/index.html`):
  1. 新建 ≥ 2 个会话 → 历史面板可见两条,当前会话高亮;
  2. A 会话流式输出中切到 B → 输入框可用,切回 A → 内容与工具状态完整还原;
  3. 运行中点「新对话」→ 新会话创建,A 任务不受影响;
  4. 滚动加载分页、空列表、目标会话已删除三个边界;
  5. 刷新页面 → 默认恢复最后一次活跃会话;
  6. desktop 会话列表行为与之前完全一致(回归)。

### 6.3 发版与部署

- 后端随常规后端发版(迁移在启动时自动执行,`FLYWAY_ENABLED` 机制不变)。
- SDK 产物部署 = `desktop/public/embed` 更新后执行 `bash scripts/deploy-desktop.sh`
  (静态资源,无需重启后端);宿主页面引用的 `mao-chat.js` 为固定文件名,天然热更新。
- 版本号仅用于产物溯源(版本化副本文件),宿主无需改引用。

## 7. 风险与限制

1. **旧会话不可恢复入口**:上线前被「新对话」替换掉的 SDK 会话不会出现在列表中
   (无法可靠识别来源),这是与需求方确认过的取舍。
2. **切换期间事件丢失是预期行为**:任务在服务端跑完,切换期间的过程事件不补推,
   切回时看到的是结果态;与 desktop 跨会话语义一致。
3. **`source` 为字符串枚举**:后续若新增来源(如机器人通道细分),需同步扩展校验白名单,
   本方案不做预留抽象。