Skip to content

perf(webui): lazy conversation history with edge-anchored windows#272

Merged
su-fen merged 4 commits into
mainfrom
feat/webui-conversation-lazy-loading
Jul 25, 2026
Merged

perf(webui): lazy conversation history with edge-anchored windows#272
su-fen merged 4 commits into
mainfrom
feat/webui-conversation-lazy-loading

Conversation

@coder-hhx

Copy link
Copy Markdown
Collaborator

问题(用户可感知)

WebUI 打开会话时,先绘制尾部 360 条,随后在空闲时一次性拉取全部历史;此后每轮对话结束的静默刷新也会退化为全量拉取(防截断守卫在渲染行数超过一页后必然触发)。对超长会话意味着:

  • 打开会话后台传输/解析整个会话(可达数 MB 的 JSON);
  • 每聊一轮都重新传输/解析一遍全量历史;
  • 内存中始终持有全量转写。

方案:边缘锚定的懒加载窗口(纯 WebUI 前端,协议/Go/Rust 零改动)

新增 lib/chat/historyWindow.ts 纯函数模块,为每个会话记录「已加载窗口的最旧消息边缘」:

  • 打开会话:只拉尾部 360 条(回访会话恢复到用户已翻到的范围);取消空闲全量水合(阶段 2 变为 no-op,openController 镜像文件未改动);
  • 静默刷新:按"边缘→尾部"跨度请求,每轮刷新成本 ∝ 已加载窗口,与会话总长无关;
  • "加载更早历史"按钮:从一次拉全量改为每次向上扩一页(360 条),复用 id 保持的 enrich 合并通路,虚拟列表的 keyed anchoring(anchorTo:"end")保证翻页时视口稳定;
  • 滑边保护:请求在途时有并发写入会使返回窗口顶边下滑,套用此类窗口可能经 alignEnrich 的计数满窗分支截断已渲染区域顶端——检测到滑边即用响应的权威 total 修正跨度重试一次,再滑则放弃本轮(交给既有刷新循环收敛);
  • turn 边界对齐:has_more 窗口裁掉顶部切半的 headless 条目(保留 checkpoint 摘要),使所有解析 id 跨扩页稳定,行不重挂载、滚动锚定不失效;
  • 簿记生命周期:draft 绑定时重键,删除/登出时清理。

不受影响的部分

  • LLM 上下文:由桌面端从 SQLite 组装,前端转写只是展示层副本,加载多少不影响发给模型的内容;
  • 编辑重发/分支/重试(基于已加载行的 messageRef)、会话分享页(独立全量接口)、侧栏 FTS 搜索(后端)、无计数旧版桌面端(优雅回退到全量行为)。

已知取舍(行为变化)

  • 「加载更早历史」按钮对长会话常驻(原来空闲全量水合后消失),每次点击加载一页;
  • 楼层导航/变更文件卡片只覆盖已加载窗口(随翻页补全);
  • 会话结束后的静默刷新在有新增消息时会多一次修正请求(两次窗口请求,仍远小于原全量)。

测试

  • historyWindow 17 个单测(计划/采纳/滑边重试/裁剪边界);
  • 全量 webui 套件 433 通过;tsc --noEmitbiome check(无新增告警)、vite build 均通过。

后续可选项(不在本 PR)

  • 桌面端 GUI 仍为空闲全量水合(本地 SQLite,痛点小),可另行对齐;
  • history.prefix 真前缀分页可在此基础上优化"加载更早"的重复传输。

coder-hhx and others added 4 commits July 25, 2026 18:12
Opening a conversation used to paint a 360-message tail and then quietly
hydrate the FULL history at idle; every quiet refresh after that refetched
the full conversation too (the anti-truncation guard fell back to an
uncapped fetch whenever the rendered transcript outgrew one page), so
transfer/parse/memory cost for long conversations grew with their lifetime
size on every turn.

Replace full hydration with per-conversation lazy windows:

- New lib/chat/historyWindow.ts tracks the oldest loaded persisted-message
  edge per conversation. Quiet refreshes plan edge-anchored spans (edge →
  tail), so per-turn refresh cost is proportional to the loaded window,
  not the conversation size. Responses whose top edge slipped below the
  established one (concurrent append mid-flight) are refetched once with
  the corrected span and dropped if they slipped again — a slipped window
  could otherwise truncate the rendered region through alignEnrich's
  count-based full-window branch.
- Phase-2 idle full hydration is now a no-op; "load earlier history"
  grows the window by one page (360 messages) instead of fetching the
  whole conversation, through the same id-preserving enrich path.
- Windows with more history above are trimmed to start at a turn
  boundary (leading checkpoints kept): headless "ht:^>N" parse ids are
  window-relative and would re-key rows on the next expansion, breaking
  the virtualizer's keyed scroll anchoring.
- Window bookkeeping follows the conversation lifecycle: re-keyed with
  draft binding, dropped on delete/removal/logout.

LLM context assembly is unaffected (desktop composes it from SQLite);
the transcript is a display-layer copy.

Tests: history-window unit tests (17) plus the full webui suite (433).
The post-turn quiet refresh planned its edge→tail span from the previous
fetch's total, so it always came up short by exactly the message count the
desktop had just flushed: the slipped-edge retry fired on every turn and
the steady state was two full window fetches per refresh instead of one.

The desktop persists a run's messages before reporting completion, and the
history upsert it publishes at that flush carries the conversation's new
total_message_count. Fold that total into the window bookkeeping
(noteHistoryWindowTotal — forward-only, stale/invalid counts are identity)
for every tracked conversation before the refresh plans its span. The slip
retry remains as the safety net for dropped upserts (they are best-effort
broadcasts under backpressure), not the steady state.
One click per 360-message page made walking a long conversation back
tedious (a 5k-message conversation took 13 clicks to reach its top).
Scrolling within one viewport of the top now requests the previous page
through the same handler as the "load earlier history" button, which stays
as the visible affordance and loading indicator.

Only scroll events trigger the fetch — opening a conversation lands at the
bottom and never auto-fetches — and after a page lands the keyed anchoring
parks the viewport about a page below the top, so each further upward
scroll pages one request at a time: readers load exactly as far as they
walk, servers transfer only the pages actually walked to, and a failed
fetch retries only on the next user scroll (no hammering).
The lazy-window rewrite left the web end faking its way through the open
controller's two-phase contract: openInitial resolved "painted", the
controller dutifully entered "hydrating" and scheduled an idle task, and a
no-op hydrateConversationFull resolved it — a phase that could never do
anything, kept alive only to satisfy the dep shape.

Teach the shared controller (byte-mirrored, both copies updated) a third
phase-1 result: "painted-complete" means the fetched slice already covers
everything this end intends to load, so the controller goes straight to
"ready" without scheduling hydration. hydrateFull becomes optional and is
only consulted after a plain "painted". The web end resolves
"painted-complete" and drops its phase-2 stub entirely; the GUI keeps its
real two-phase hydration unchanged.
@su-fen
su-fen merged commit e6342e7 into main Jul 25, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants