perf(webui): lazy conversation history with edge-anchored windows#272
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题(用户可感知)
WebUI 打开会话时,先绘制尾部 360 条,随后在空闲时一次性拉取全部历史;此后每轮对话结束的静默刷新也会退化为全量拉取(防截断守卫在渲染行数超过一页后必然触发)。对超长会话意味着:
方案:边缘锚定的懒加载窗口(纯 WebUI 前端,协议/Go/Rust 零改动)
新增
lib/chat/historyWindow.ts纯函数模块,为每个会话记录「已加载窗口的最旧消息边缘」:anchorTo:"end")保证翻页时视口稳定;alignEnrich的计数满窗分支截断已渲染区域顶端——检测到滑边即用响应的权威 total 修正跨度重试一次,再滑则放弃本轮(交给既有刷新循环收敛);不受影响的部分
已知取舍(行为变化)
测试
historyWindow17 个单测(计划/采纳/滑边重试/裁剪边界);tsc --noEmit、biome check(无新增告警)、vite build均通过。后续可选项(不在本 PR)
history.prefix真前缀分页可在此基础上优化"加载更早"的重复传输。