全部文档

会话消息窗口化加载(微信式)

- 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.gobackend/internal/api/handlers/chat.goHistory / HistoryTurns
  • Web:client/web/src/chatMessageWindow.tsclient/web/src/useChatSessions.tsclient/web/src/WorkChatMessageThread.tsxclient/web/src/ChatTurnNav.tsx
  • 移动端:client/mobile/src/lib/chatMessageWindow.tsclient/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,清空对侧游标语义
近顶loadOlderdir=before)→ prepend → 锚点保位 → 必要时从 尾部 回收
近底且 !atLiveEdgeloadNewerdir=after)→ append → 锚点保位 → 必要时从 头部 回收
导航点到窗内 idDOM/虚拟列表精修滚到气泡
导航点到窗外 idjumpToMessagearound=id 换窗,再滚到锚点;atLiveEdge 按是否含最新条判定

4. API 契约

4.1 消息窗口

GET /api/v1/chat/history

参数行为
session_id必填
limit默认 100,上限同既有
(无 cursor / around)最近 limit 条;older_cursor 有更早则非空;newer_cursor=nullat_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. 滚动保位与回收

锚点保位

  1. 加载前:在消息视口内找第一条可见气泡的 data-message-id(或 id=work-chat-user-…),记录 offset = el.getBoundingClientRect().top - viewport.top
  2. 写入 items 后(useLayoutEffect):同一 id 再定位,scrollTop += (newTop - oldTop) 使视觉不动。
  3. suppressAutoScroll:加载过程禁止贴底追滚。

回收

  • loadOlder 后若 items.length > WINDOW_MAX:从尾部删除多余条,并用新的末条生成 newerCursoratLiveEdge=false
  • loadNewer 后超窗:从头部删除,更新 olderCursor

6. 与流式 / 编辑重发

  • 流式气泡:挂在窗口列表外的文档流(不进虚拟行估高),仅 atLiveEdge 时用户通常在底部阅读。
  • 流式贴底:发送后出现流式气泡时贴底一次;之后仅在用户仍贴底时随 token 追滚。用户上滑(滚轮/触控/拖条)后停止追滚,流式内容在视口外继续追加,回答结束也不强拉。
  • 非 live edge 时来了新助手消息:不塞进窗口;沿用「回到底部」入口,resetToLatest
  • 发送:若 !atLiveEdge,先 ensureAtLiveEdge / resetToLatest 再发。
  • 编辑重发 / truncate:truncate 后整窗 refreshHistory,再发送,避免脏窗口。

7. 端覆盖

行为
Web 工作区 / 帮助主会话完整窗口 + turns 导航 + 锚点
移动端聊天页双向分页 + 回收 + 发送前拉回最新 + 「回到最新」
帮助浮层 / 引导面板 / 智能体历史预览取最近一页(50~100),避免一次 200+ 全量进内存

8. 验收清单

  1. 连续加载更早:内存中 items.length 稳定在 WINDOW_MAX 附近。
  2. 顶加载视觉不跳;点导航窗内第一条落到对应「你」气泡。
  3. around 跳转:先换窗再聚焦,无大片空白/叠泡。
  4. 回底部 / 发送:atLiveEdge=true,流式可读;流式中上滑后不再强拉,回底后可继续追滚。
  5. 千轮会话仅测「窗口 + API」,不要求一次渲染千条 DOM。
  6. 右侧导航可独立加载更早提问(不依赖窗口正文)。
  7. 移动端顶加载后内存有界;非最新时发送会先回到最新。

9. 修订记录

日期说明
2026-07-29初版:微信式窗口、双向分页、锚点、回收、around;turns 索引标 P1
2026-07-29完工:发送/truncate reset、/history/turns、Web 导航接入、移动端窗口、辅助面改最近页
2026-07-29首屏改 10 条、窗口上限 40、导航 12 条顶滚续载;修正短行文风估高导致的整屏空白
2026-07-29流式输出:用户上滑后停止追滚(不再每帧重钉贴底)