Improve message stream caching and scroll performance

This commit is contained in:
Shantur Rathore
2025-11-27 16:51:05 +00:00
parent 18513939f7
commit 222a467a19

View File

@@ -20,9 +20,6 @@ const SCROLL_SCOPE = "session"
const TOOL_ICON = "🔧" const TOOL_ICON = "🔧"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const messageItemCache = new Map<string, ContentDisplayItem>()
const toolItemCache = new Map<string, ToolDisplayItem>()
const USER_BORDER_COLOR = "var(--message-user-border)" const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)" const TOOL_BORDER_COLOR = "var(--message-tool-border)"
@@ -88,28 +85,53 @@ function formatTokens(tokens: number): string {
return formatTokenTotal(tokens) return formatTokenTotal(tokens)
} }
function makeInstanceCacheKey(instanceId: string, id: string) { interface CachedBlockEntry {
return `${instanceId}:${id}` signature: string
block: MessageDisplayBlock
contentKeys: string[]
toolKeys: string[]
}
interface SessionRenderCache {
messageItems: Map<string, ContentDisplayItem>
toolItems: Map<string, ToolDisplayItem>
messageBlocks: Map<string, CachedBlockEntry>
}
const renderCaches = new Map<string, SessionRenderCache>()
function makeSessionCacheKey(instanceId: string, sessionId: string) {
return `${instanceId}:${sessionId}`
}
function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache {
const key = makeSessionCacheKey(instanceId, sessionId)
let cache = renderCaches.get(key)
if (!cache) {
cache = {
messageItems: new Map(),
toolItems: new Map(),
messageBlocks: new Map(),
}
renderCaches.set(key, cache)
}
return cache
} }
function clearInstanceCaches(instanceId: string) { function clearInstanceCaches(instanceId: string) {
clearRecordDisplayCacheForInstance(instanceId) clearRecordDisplayCacheForInstance(instanceId)
const prefix = `${instanceId}:` const prefix = `${instanceId}:`
for (const key of messageItemCache.keys()) { for (const key of renderCaches.keys()) {
if (key.startsWith(prefix)) { if (key.startsWith(prefix)) {
messageItemCache.delete(key) renderCaches.delete(key)
}
}
for (const key of toolItemCache.keys()) {
if (key.startsWith(prefix)) {
toolItemCache.delete(key)
} }
} }
} }
messageStoreBus.onInstanceDestroyed(clearInstanceCaches) messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
interface MessageStreamV2Props { interface MessageStreamV2Props {
instanceId: string instanceId: string
sessionId: string sessionId: string
@@ -270,14 +292,17 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
const thinkingDefaultExpanded = (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded" const thinkingDefaultExpanded = (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"
const revert = revertTarget() const revert = revertTarget()
const instanceId = props.instanceId const instanceId = props.instanceId
const sessionCache = getSessionRenderCache(instanceId, props.sessionId)
const blocks: MessageDisplayBlock[] = [] const blocks: MessageDisplayBlock[] = []
const usedMessageKeys = new Set<string>() const usedMessageKeys = new Set<string>()
const usedToolKeys = new Set<string>() const usedToolKeys = new Set<string>()
const activeMessageIds = new Set<string>()
const records = messageRecords() const records = messageRecords()
const assistantIndex = lastAssistantIndex() const assistantIndex = lastAssistantIndex()
const indexMap = messageIndexMap() const indexMap = messageIndexMap()
for (const record of records) { for (const record of records) {
if (revert?.messageID && record.id === revert.messageID) { if (revert?.messageID && record.id === revert.messageID) {
break break
} }
@@ -286,23 +311,51 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
const messageInfo = infoMap.get(record.id) const messageInfo = infoMap.get(record.id)
const recordIndex = indexMap.get(record.id) ?? 0 const recordIndex = indexMap.get(record.id) ?? 0
const isQueued = record.role === "user" && (assistantIndex === -1 || recordIndex > assistantIndex) const isQueued = record.role === "user" && (assistantIndex === -1 || recordIndex > assistantIndex)
const infoTime = (messageInfo?.time ?? {}) as { created?: number; updated?: number; completed?: number }
const infoTimestamp = typeof infoTime.completed === "number"
? infoTime.completed
: typeof infoTime.updated === "number"
? infoTime.updated
: infoTime.created ?? 0
const infoError = (messageInfo as { error?: { name?: string } } | undefined)?.error
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
const cacheSignature = [
record.revision,
isQueued ? 1 : 0,
showThinking ? 1 : 0,
thinkingDefaultExpanded ? 1 : 0,
showUsageMetrics ? 1 : 0,
infoTimestamp,
infoErrorName,
].join("|")
const cachedBlock = sessionCache.messageBlocks.get(record.id)
if (cachedBlock && cachedBlock.signature === cacheSignature) {
cachedBlock.contentKeys.forEach((key) => usedMessageKeys.add(key))
cachedBlock.toolKeys.forEach((key) => usedToolKeys.add(key))
blocks.push(cachedBlock.block)
activeMessageIds.add(record.id)
continue
}
const items: MessageBlockItem[] = [] const items: MessageBlockItem[] = []
const blockContentKeys: string[] = []
const blockToolKeys: string[] = []
let segmentIndex = 0 let segmentIndex = 0
let pendingParts: ClientPart[] = [] let pendingParts: ClientPart[] = []
let agentMetaAttached = record.role !== "assistant" let agentMetaAttached = record.role !== "assistant"
const defaultAccentColor = record.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR const defaultAccentColor = record.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
let lastAccentColor = defaultAccentColor let lastAccentColor = defaultAccentColor
const flushContent = () => { const flushContent = () => {
if (pendingParts.length === 0) return if (pendingParts.length === 0) return
const segmentKey = makeInstanceCacheKey(instanceId, `${record.id}:segment:${segmentIndex}`) const segmentKey = `${record.id}:segment:${segmentIndex}`
segmentIndex += 1 segmentIndex += 1
const shouldShowAgentMeta = const shouldShowAgentMeta =
record.role === "assistant" && record.role === "assistant" &&
!agentMetaAttached && !agentMetaAttached &&
pendingParts.some((part) => partHasRenderableText(part)) pendingParts.some((part) => partHasRenderableText(part))
let cached = messageItemCache.get(segmentKey) let cached = sessionCache.messageItems.get(segmentKey)
if (!cached) { if (!cached) {
cached = { cached = {
type: "content", type: "content",
@@ -313,7 +366,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
isQueued, isQueued,
showAgentMeta: shouldShowAgentMeta, showAgentMeta: shouldShowAgentMeta,
} }
messageItemCache.set(segmentKey, cached) sessionCache.messageItems.set(segmentKey, cached)
} else { } else {
cached.record = record cached.record = record
cached.parts = pendingParts.slice() cached.parts = pendingParts.slice()
@@ -326,6 +379,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
} }
items.push(cached) items.push(cached)
usedMessageKeys.add(segmentKey) usedMessageKeys.add(segmentKey)
blockContentKeys.push(segmentKey)
lastAccentColor = defaultAccentColor lastAccentColor = defaultAccentColor
pendingParts = [] pendingParts = []
} }
@@ -336,8 +390,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
const partVersion = typeof part.version === "number" ? part.version : 0 const partVersion = typeof part.version === "number" ? part.version : 0
const messageVersion = record.revision const messageVersion = record.revision
const key = `${record.id}:${part.id ?? partIndex}` const key = `${record.id}:${part.id ?? partIndex}`
const cacheKey = makeInstanceCacheKey(instanceId, key) let toolItem = sessionCache.toolItems.get(key)
let toolItem = toolItemCache.get(cacheKey)
if (!toolItem) { if (!toolItem) {
toolItem = { toolItem = {
type: "tool", type: "tool",
@@ -348,7 +401,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
messageVersion, messageVersion,
partVersion, partVersion,
} }
toolItemCache.set(cacheKey, toolItem) sessionCache.toolItems.set(key, toolItem)
} else { } else {
toolItem.key = key toolItem.key = key
toolItem.toolPart = part as ToolCallPart toolItem.toolPart = part as ToolCallPart
@@ -358,7 +411,8 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
toolItem.partVersion = partVersion toolItem.partVersion = partVersion
} }
items.push(toolItem) items.push(toolItem)
usedToolKeys.add(cacheKey) usedToolKeys.add(key)
blockToolKeys.push(key)
lastAccentColor = TOOL_BORDER_COLOR lastAccentColor = TOOL_BORDER_COLOR
return return
} }
@@ -371,7 +425,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
if (part.type === "step-finish") { if (part.type === "step-finish") {
flushContent() flushContent()
if (showUsageMetrics) { if (showUsageMetrics) {
const key = makeInstanceCacheKey(instanceId, `${record.id}:${part.id ?? partIndex}:${part.type}`) const key = `${record.id}:${part.id ?? partIndex}:${part.type}`
const accentColor = lastAccentColor || defaultAccentColor const accentColor = lastAccentColor || defaultAccentColor
items.push({ type: part.type, key, part, messageInfo, accentColor }) items.push({ type: part.type, key, part, messageInfo, accentColor })
lastAccentColor = accentColor lastAccentColor = accentColor
@@ -382,7 +436,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
if (part.type === "reasoning") { if (part.type === "reasoning") {
flushContent() flushContent()
if (showThinking && reasoningHasRenderableContent(part)) { if (showThinking && reasoningHasRenderableContent(part)) {
const key = makeInstanceCacheKey(instanceId, `${record.id}:${part.id ?? partIndex}:reasoning`) const key = `${record.id}:${part.id ?? partIndex}:reasoning`
const showAgentMeta = record.role === "assistant" && !agentMetaAttached const showAgentMeta = record.role === "assistant" && !agentMetaAttached
if (showAgentMeta) { if (showAgentMeta) {
agentMetaAttached = true agentMetaAttached = true
@@ -409,21 +463,35 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
continue continue
} }
blocks.push({ record, items }) const resultBlock: MessageDisplayBlock = { record, items }
blocks.push(resultBlock)
sessionCache.messageBlocks.set(record.id, {
signature: cacheSignature,
block: resultBlock,
contentKeys: blockContentKeys.slice(),
toolKeys: blockToolKeys.slice(),
})
activeMessageIds.add(record.id)
} }
for (const key of messageItemCache.keys()) { for (const [key] of sessionCache.messageItems) {
if (!usedMessageKeys.has(key)) { if (!usedMessageKeys.has(key)) {
messageItemCache.delete(key) sessionCache.messageItems.delete(key)
} }
} }
for (const key of toolItemCache.keys()) { for (const [key] of sessionCache.toolItems) {
if (!usedToolKeys.has(key)) { if (!usedToolKeys.has(key)) {
toolItemCache.delete(key) sessionCache.toolItems.delete(key)
}
}
for (const [messageId] of sessionCache.messageBlocks) {
if (!activeMessageIds.has(messageId)) {
sessionCache.messageBlocks.delete(messageId)
} }
} }
return blocks return blocks
}) })
const changeToken = createMemo(() => { const changeToken = createMemo(() => {
@@ -482,11 +550,12 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!containerRef) return if (!containerRef) return
updateScrollIndicators(containerRef) updateScrollIndicators(containerRef)
persistScrollState() scheduleScrollPersist()
}) })
} }
function scrollToTop(immediate = false) { function scrollToTop(immediate = false) {
if (!containerRef) return if (!containerRef) return
const behavior = immediate ? "auto" : "smooth" const behavior = immediate ? "auto" : "smooth"
setAutoScroll(false) setAutoScroll(false)
@@ -494,13 +563,18 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!containerRef) return if (!containerRef) return
updateScrollIndicators(containerRef) updateScrollIndicators(containerRef)
persistScrollState() scheduleScrollPersist()
}) })
} }
function persistScrollState() { let pendingScrollPersist: number | null = null
function scheduleScrollPersist() {
if (pendingScrollPersist !== null) return
pendingScrollPersist = requestAnimationFrame(() => {
pendingScrollPersist = null
if (!containerRef) return if (!containerRef) return
scrollCache.persist(containerRef, { atBottomOffset: 48 }) scrollCache.persist(containerRef, { atBottomOffset: 48 })
})
} }
function handleScroll(event: Event) { function handleScroll(event: Event) {
@@ -514,27 +588,12 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
setAutoScroll(true) setAutoScroll(true)
} }
} }
persistScrollState() scheduleScrollPersist()
} }
createEffect(() => {
const target = containerRef
if (!target) return
scrollCache.restore(target, {
fallback: () => scrollToBottom(true),
onApplied: (snapshot) => {
if (snapshot) {
setAutoScroll(snapshot.atBottom)
} else {
const atBottom = isNearBottom(target)
setAutoScroll(atBottom)
}
updateScrollIndicators(target)
},
})
})
let previousToken: string | undefined let previousToken: string | undefined
createEffect(() => { createEffect(() => {
const token = changeToken() const token = changeToken()
if (!token || token === previousToken) { if (!token || token === previousToken) {
@@ -555,7 +614,13 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
}) })
onCleanup(() => { onCleanup(() => {
persistScrollState() if (pendingScrollPersist !== null) {
cancelAnimationFrame(pendingScrollPersist)
pendingScrollPersist = null
}
if (containerRef) {
scrollCache.persist(containerRef, { atBottomOffset: 48 })
}
}) })
return ( return (