会话消息窗口化加载(微信式)
- Web:client/web/src/chatMessageWindow.ts、client/web/src/useChatSessions.ts、client/web/src/WorkChatMessageThread.tsx、client/web/src/ChatTurnNav.tsx
来源 docs/core-mechanisms/会话消息窗口化加载.md
表述:用户侧「往上翻很久以前的对话、点某条提问跳回去、再回到最新」应流畅可用;技术实现是 库全量 + 前端有界窗口,不是一次把全部气泡塞进内存。
真值:本文;模型上下文压缩见 对话历史与上下文压缩机制解析(展示窗口 ≠ 送模上下文)。
日期:2026-07-29 状态:已完工(Web 主会话 + 提问索引 + 移动端窗口;辅助面取最近一页) 相关代码:
- 后端:
backend/internal/store/chat.go、backend/internal/api/handlers/chat.go(History/HistoryTurns) - Web:
client/web/src/chatMessageWindow.ts、client/web/src/useChatSessions.ts、client/web/src/WorkChatMessageThread.tsx、client/web/src/ChatTurnNav.tsx - 移动端:
client/mobile/src/lib/chatMessageWindow.ts、client/mobile/app/(app)/chat/[sessionId].tsx
1. 问题
文学创作等长文智能体下,单条回复可达数万字;会话到 几百~上千轮 时:
| 旧做法 | 后果 |
|---|---|
前端 history 只增不减 | 内存与 React 重渲染失控 |
仅 before 分页、无滚动锚点 | 顶部加载 / 点导航第一条 → 乱跳、空白、叠泡 |
| 虚拟列表对「无限膨胀的数组」估高 | 总高度失真,跳转与导航失效 |
对照 微信:库里可有很多年消息;屏幕上只挂载附近一截;翻旧消息时加载并回收对侧;搜索/定位某条时换窗再聚焦。
2. 分层边界
flowchart LR
subgraph ui [前端展示窗口]
W[约 80~150 条 items]
end
subgraph api [History API]
B[before / after / around]
end
subgraph db [数据库]
M[chat_messages 全量]
end
subgraph llm [模型上下文]
S[滚动摘要 + 最近原文]
end
W --> B --> M
M --> S- DB:全量保留(无 TTL);删会话级联删消息。
- 前端窗口:有界;双向加载 + 回收。
- 模型:独立滚动摘要;不要求前端窗口覆盖送模所需全文。
3. 窗口状态机
| 字段 | 含义 |
|---|---|
items | 当前内存中的消息(时间正序) |
olderCursor | 还有更早一页时非空(指向当前窗 最早 一条) |
newerCursor | 还有更新一页时非空(指向当前窗 最晚 一条);在直播沿为 null |
atLiveEdge | 是否贴在会话最新端;为 true 时新消息可直接 append,近底不必再 after |
busy | 分页请求中 |
常量(实现可调):
WINDOW_PAGE_SIZE= 10(首开/回底只拉最近一截)WINDOW_MAX= 40(超过则按加载方向回收对侧)TURN_NAV_PAGE_SIZE= 12(右侧提问导航;顶滚自动再补更早)
转移
| 事件 | 行为 |
|---|---|
| 打开会话 / 回底部 / 发送 | resetToLatest:拉最近一页,atLiveEdge=true,清空对侧游标语义 |
| 近顶 | loadOlder(dir=before)→ prepend → 锚点保位 → 必要时从 尾部 回收 |
近底且 !atLiveEdge | loadNewer(dir=after)→ append → 锚点保位 → 必要时从 头部 回收 |
| 导航点到窗内 id | DOM/虚拟列表精修滚到气泡 |
| 导航点到窗外 id | jumpToMessage:around=id 换窗,再滚到锚点;atLiveEdge 按是否含最新条判定 |
4. API 契约
4.1 消息窗口
GET /api/v1/chat/history
| 参数 | 行为 |
|---|---|
session_id | 必填 |
limit | 默认 100,上限同既有 |
| (无 cursor / around) | 最近 limit 条;older_cursor 有更早则非空;newer_cursor=null;at_live_edge=true |
cursor + dir=before(默认) | 严格早于游标的一页 |
cursor + dir=after | 严格晚于游标的一页 |
around=message_id | 以该消息为中心,前后合计约 limit 条 |
响应:
{
"items": [ /* ChatMessage 时间正序 */ ],
"session_id": "...",
"older_cursor": "…或 null",
"newer_cursor": "…或 null",
"at_live_edge": true,
"next_cursor": "…与 older_cursor 同值(兼容旧客户端)"
}
游标仍为服务端不透明编码(created_at + id)。
4.2 提问导航索引
GET /api/v1/chat/history/turns
| 参数 | 行为 |
|---|---|
session_id | 必填 |
limit | 默认 100,上限 500;Web 打开会话用 12 |
| (无 cursor) | 最近 limit 条用户提问 |
cursor | 严格更早的一页用户提问 |
打开时只拉最近一页;不会在挂载时连环请求。用户在导航内向上滚动到顶,或点顶部「更早」时再请求下一页。
响应:
{
"items": [{ "id": "…", "preview": "首行截断预览", "created_at": "…" }],
"session_id": "...",
"older_cursor": "…或 null",
"next_cursor": "…与 older_cursor 同值"
}
Web 右侧提问导航使用本接口分页;点窗外 id 时再 around 换消息窗。
5. 滚动保位与回收
锚点保位
- 加载前:在消息视口内找第一条可见气泡的
data-message-id(或id=work-chat-user-…),记录offset = el.getBoundingClientRect().top - viewport.top。 - 写入
items后(useLayoutEffect):同一 id 再定位,scrollTop += (newTop - oldTop)使视觉不动。 suppressAutoScroll:加载过程禁止贴底追滚。
回收
loadOlder后若items.length > WINDOW_MAX:从尾部删除多余条,并用新的末条生成newerCursor,atLiveEdge=false。loadNewer后超窗:从头部删除,更新olderCursor。
6. 与流式 / 编辑重发
- 流式气泡:挂在窗口列表外的文档流(不进虚拟行估高),仅
atLiveEdge时用户通常在底部阅读。 - 流式贴底:发送后出现流式气泡时贴底一次;之后仅在用户仍贴底时随 token 追滚。用户上滑(滚轮/触控/拖条)后停止追滚,流式内容在视口外继续追加,回答结束也不强拉。
- 非 live edge 时来了新助手消息:不塞进窗口;沿用「回到底部」入口,
resetToLatest。 - 发送:若
!atLiveEdge,先ensureAtLiveEdge/resetToLatest再发。 - 编辑重发 / truncate:truncate 后整窗
refreshHistory,再发送,避免脏窗口。
7. 端覆盖
| 面 | 行为 |
|---|---|
| Web 工作区 / 帮助主会话 | 完整窗口 + turns 导航 + 锚点 |
| 移动端聊天页 | 双向分页 + 回收 + 发送前拉回最新 + 「回到最新」 |
| 帮助浮层 / 引导面板 / 智能体历史预览 | 取最近一页(50~100),避免一次 200+ 全量进内存 |
8. 验收清单
- 连续加载更早:内存中
items.length稳定在WINDOW_MAX附近。 - 顶加载视觉不跳;点导航窗内第一条落到对应「你」气泡。
around跳转:先换窗再聚焦,无大片空白/叠泡。- 回底部 / 发送:
atLiveEdge=true,流式可读;流式中上滑后不再强拉,回底后可继续追滚。 - 千轮会话仅测「窗口 + API」,不要求一次渲染千条 DOM。
- 右侧导航可独立加载更早提问(不依赖窗口正文)。
- 移动端顶加载后内存有界;非最新时发送会先回到最新。
9. 修订记录
| 日期 | 说明 |
|---|---|
| 2026-07-29 | 初版:微信式窗口、双向分页、锚点、回收、around;turns 索引标 P1 |
| 2026-07-29 | 完工:发送/truncate reset、/history/turns、Web 导航接入、移动端窗口、辅助面改最近页 |
| 2026-07-29 | 首屏改 10 条、窗口上限 40、导航 12 条顶滚续载;修正短行文风估高导致的整屏空白 |
| 2026-07-29 | 流式输出:用户上滑后停止追滚(不再每帧重钉贴底) |