Improve message stream caching and scroll performance
This commit is contained in:
@@ -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,59 +550,50 @@ 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
|
|
||||||
const behavior = immediate ? "auto" : "smooth"
|
|
||||||
setAutoScroll(false)
|
|
||||||
containerRef.scrollTo({ top: 0, behavior })
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!containerRef) return
|
|
||||||
updateScrollIndicators(containerRef)
|
|
||||||
persistScrollState()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistScrollState() {
|
if (!containerRef) return
|
||||||
if (!containerRef) return
|
const behavior = immediate ? "auto" : "smooth"
|
||||||
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
setAutoScroll(false)
|
||||||
}
|
containerRef.scrollTo({ top: 0, behavior })
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!containerRef) return
|
||||||
|
updateScrollIndicators(containerRef)
|
||||||
|
scheduleScrollPersist()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function handleScroll(event: Event) {
|
let pendingScrollPersist: number | null = null
|
||||||
if (!containerRef) return
|
function scheduleScrollPersist() {
|
||||||
updateScrollIndicators(containerRef)
|
if (pendingScrollPersist !== null) return
|
||||||
if (event.isTrusted) {
|
pendingScrollPersist = requestAnimationFrame(() => {
|
||||||
const atBottom = isNearBottom(containerRef)
|
pendingScrollPersist = null
|
||||||
if (!atBottom) {
|
if (!containerRef) return
|
||||||
setAutoScroll(false)
|
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
||||||
} else {
|
})
|
||||||
setAutoScroll(true)
|
}
|
||||||
}
|
|
||||||
}
|
function handleScroll(event: Event) {
|
||||||
persistScrollState()
|
if (!containerRef) return
|
||||||
}
|
updateScrollIndicators(containerRef)
|
||||||
|
if (event.isTrusted) {
|
||||||
|
const atBottom = isNearBottom(containerRef)
|
||||||
|
if (!atBottom) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
} else {
|
||||||
|
setAutoScroll(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleScrollPersist()
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousToken: string | undefined
|
||||||
|
|
||||||
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
|
|
||||||
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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user