Make UI global cache version-aware
Store one cached value per cacheId and overwrite when version changes to prevent unbounded growth from per-version keys.
This commit is contained in:
@@ -1,19 +1,32 @@
|
||||
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
const markdownRenderCache = new Map<string, RenderCache>()
|
||||
function hashText(value: string): string {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return (hash >>> 0).toString(16)
|
||||
}
|
||||
|
||||
function makeMarkdownCacheKey(partId: string, themeKey: string, highlightEnabled: boolean, versionKey: string) {
|
||||
const versionSegment = versionKey.length > 0 ? versionKey : "noversion"
|
||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}:${versionSegment}`
|
||||
function resolvePartVersion(part: TextPart, text: string): string {
|
||||
if (typeof part.version === "number") {
|
||||
return String(part.version)
|
||||
}
|
||||
return `text-${hashText(text)}`
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
part: TextPart
|
||||
instanceId?: string
|
||||
sessionId?: string
|
||||
isDark?: boolean
|
||||
size?: "base" | "sm" | "tight"
|
||||
disableHighlight?: boolean
|
||||
@@ -29,38 +42,49 @@ export function Markdown(props: MarkdownProps) {
|
||||
Promise.resolve().then(() => props.onRendered?.())
|
||||
}
|
||||
|
||||
createEffect(async () => {
|
||||
const resolved = createMemo(() => {
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const dark = Boolean(props.isDark)
|
||||
const themeKey = dark ? "dark" : "light"
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
const highlightEnabled = !props.disableHighlight
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||
if (!partId) {
|
||||
throw new Error("Markdown rendering requires a part id")
|
||||
}
|
||||
const versionKey = typeof part.version === "number" ? String(part.version) : ""
|
||||
const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled, versionKey)
|
||||
const version = resolvePartVersion(part, text)
|
||||
return { part, text, themeKey, highlightEnabled, partId, version }
|
||||
})
|
||||
|
||||
const cacheHandle = useGlobalCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: "markdown",
|
||||
cacheId: () => {
|
||||
const { partId, themeKey, highlightEnabled } = resolved()
|
||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
},
|
||||
version: () => resolved().version,
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||
|
||||
latestRequestedText = text
|
||||
|
||||
const localCache = part.renderCache
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
if (!cache) return false
|
||||
if (versionKey.length > 0) {
|
||||
return cache.mode === versionKey && cache.theme === themeKey
|
||||
}
|
||||
return cache.text === text && cache.theme === themeKey
|
||||
return cache.theme === themeKey && cache.mode === version
|
||||
}
|
||||
|
||||
const localCache = part.renderCache
|
||||
if (localCache && cacheMatches(localCache)) {
|
||||
setHtml(localCache.html)
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
const globalCache = markdownRenderCache.get(cacheKey)
|
||||
const globalCache = cacheHandle.get<RenderCache>()
|
||||
if (globalCache && cacheMatches(globalCache)) {
|
||||
setHtml(globalCache.html)
|
||||
part.renderCache = globalCache
|
||||
@@ -68,6 +92,14 @@ export function Markdown(props: MarkdownProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const commitCacheEntry = (renderedHtml: string) => {
|
||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||
setHtml(renderedHtml)
|
||||
part.renderCache = cacheEntry
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
|
||||
if (!highlightEnabled) {
|
||||
part.renderCache = undefined
|
||||
|
||||
@@ -75,20 +107,12 @@ export function Markdown(props: MarkdownProps) {
|
||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: versionKey || undefined }
|
||||
setHtml(rendered)
|
||||
part.renderCache = cacheEntry
|
||||
markdownRenderCache.set(cacheKey, cacheEntry)
|
||||
notifyRendered()
|
||||
commitCacheEntry(rendered)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
const cacheEntry: RenderCache = { text, html: text, theme: themeKey, mode: versionKey || undefined }
|
||||
setHtml(text)
|
||||
part.renderCache = cacheEntry
|
||||
markdownRenderCache.set(cacheKey, cacheEntry)
|
||||
notifyRendered()
|
||||
commitCacheEntry(text)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -96,22 +120,13 @@ export function Markdown(props: MarkdownProps) {
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: versionKey || undefined }
|
||||
setHtml(rendered)
|
||||
part.renderCache = cacheEntry
|
||||
markdownRenderCache.set(cacheKey, cacheEntry)
|
||||
notifyRendered()
|
||||
commitCacheEntry(rendered)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
const cacheEntry: RenderCache = { text, html: text, theme: themeKey, mode: versionKey || undefined }
|
||||
setHtml(text)
|
||||
part.renderCache = cacheEntry
|
||||
markdownRenderCache.set(cacheKey, cacheEntry)
|
||||
notifyRendered()
|
||||
commitCacheEntry(text)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -147,15 +162,12 @@ export function Markdown(props: MarkdownProps) {
|
||||
|
||||
containerRef?.addEventListener("click", handleClick)
|
||||
|
||||
// Register listener for language loading completion
|
||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||
if (props.disableHighlight) {
|
||||
return
|
||||
}
|
||||
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const { part, text, themeKey, version } = resolved()
|
||||
|
||||
if (latestRequestedText !== text) {
|
||||
return
|
||||
@@ -164,9 +176,10 @@ export function Markdown(props: MarkdownProps) {
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
if (latestRequestedText === text) {
|
||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
||||
setHtml(rendered)
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
part.renderCache = { text, html: rendered, theme: themeKey }
|
||||
part.renderCache = cacheEntry
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -102,6 +102,8 @@ interface MessagePartProps {
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
|
||||
@@ -240,29 +240,33 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
|
||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
|
||||
const createVariantCache = (variant: string | (() => string)) =>
|
||||
const cacheVersion = createMemo(() => {
|
||||
if (typeof props.partVersion === "number") {
|
||||
return String(props.partVersion)
|
||||
}
|
||||
if (typeof props.messageVersion === "number") {
|
||||
return String(props.messageVersion)
|
||||
}
|
||||
return "noversion"
|
||||
})
|
||||
|
||||
const createVariantCache = (variant: string | (() => string), version?: () => string) =>
|
||||
useGlobalCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: TOOL_CALL_CACHE_SCOPE,
|
||||
key: () => {
|
||||
cacheId: () => {
|
||||
const context = cacheContext()
|
||||
const resolvedVariant = typeof variant === "function" ? variant() : variant
|
||||
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, resolvedVariant)
|
||||
},
|
||||
version: () => (version ? version() : cacheVersion()),
|
||||
})
|
||||
|
||||
const diffCache = createVariantCache("diff")
|
||||
const permissionDiffCache = createVariantCache("permission-diff")
|
||||
const markdownCache = createVariantCache("markdown")
|
||||
const ansiRunningCache = createVariantCache(() => {
|
||||
const versionKey = typeof props.partVersion === "number" ? String(props.partVersion) : "noversion"
|
||||
return `ansi-running:${versionKey}`
|
||||
})
|
||||
const ansiFinalCache = createVariantCache(() => {
|
||||
const versionKey = typeof props.partVersion === "number" ? String(props.partVersion) : "noversion"
|
||||
return `ansi-final:${versionKey}`
|
||||
})
|
||||
const ansiRunningCache = createVariantCache("ansi-running", () => "running")
|
||||
const ansiFinalCache = createVariantCache("ansi-final")
|
||||
const runningAnsiRenderer = createAnsiStreamRenderer()
|
||||
let runningAnsiSource = ""
|
||||
|
||||
@@ -736,13 +740,8 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
throw new Error("Tool call markdown requires a part id")
|
||||
}
|
||||
const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion }
|
||||
const cached = markdownCache.get<RenderCache>()
|
||||
if (cached) {
|
||||
markdownPart.renderCache = cached
|
||||
}
|
||||
|
||||
const handleMarkdownRendered = () => {
|
||||
markdownCache.set(markdownPart.renderCache)
|
||||
handleScrollRendered()
|
||||
props.onContentRendered?.()
|
||||
}
|
||||
@@ -751,6 +750,8 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
disableHighlight={disableHighlight}
|
||||
onRendered={handleMarkdownRendered}
|
||||
|
||||
@@ -5,10 +5,16 @@ export interface CacheEntryBaseParams {
|
||||
}
|
||||
|
||||
export interface CacheEntryParams extends CacheEntryBaseParams {
|
||||
key: string
|
||||
cacheId: string
|
||||
version: string
|
||||
}
|
||||
|
||||
type CacheValueMap = Map<string, unknown>
|
||||
type VersionedCacheEntry = {
|
||||
version: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
type CacheValueMap = Map<string, VersionedCacheEntry>
|
||||
type CacheScopeMap = Map<string, CacheValueMap>
|
||||
type CacheSessionMap = Map<string, CacheScopeMap>
|
||||
|
||||
@@ -83,18 +89,22 @@ export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined)
|
||||
|
||||
if (value === undefined) {
|
||||
const existingMap = getScopeValueMap(params, false)
|
||||
existingMap?.delete(params.key)
|
||||
existingMap?.delete(params.cacheId)
|
||||
cleanupHierarchy(instanceKey, sessionKey, params.scope)
|
||||
return
|
||||
}
|
||||
|
||||
const scopeEntries = getScopeValueMap(params, true)
|
||||
scopeEntries?.set(params.key, value)
|
||||
scopeEntries?.set(params.cacheId, { version: params.version, value })
|
||||
}
|
||||
|
||||
export function getCacheEntry<T>(params: CacheEntryParams): T | undefined {
|
||||
const scopeEntries = getScopeValueMap(params, false)
|
||||
return scopeEntries?.get(params.key) as T | undefined
|
||||
const entry = scopeEntries?.get(params.cacheId)
|
||||
if (!entry || entry.version !== params.version) {
|
||||
return undefined
|
||||
}
|
||||
return entry.value as T
|
||||
}
|
||||
|
||||
export function clearCacheScope(params: CacheEntryBaseParams): void {
|
||||
|
||||
@@ -18,8 +18,9 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle
|
||||
const instanceId = normalizeId(resolveValue(params.instanceId))
|
||||
const sessionId = normalizeId(resolveValue(params.sessionId))
|
||||
const scope = resolveValue(params.scope)
|
||||
const key = resolveValue(params.key)
|
||||
return { instanceId, sessionId, scope, key }
|
||||
const cacheId = resolveValue(params.cacheId)
|
||||
const version = String(resolveValue(params.version))
|
||||
return { instanceId, sessionId, scope, cacheId, version }
|
||||
})
|
||||
|
||||
const scopeParams = createMemo(() => {
|
||||
@@ -73,7 +74,8 @@ interface UseGlobalCacheParams {
|
||||
instanceId?: MaybeAccessor<string | undefined>
|
||||
sessionId?: MaybeAccessor<string | undefined>
|
||||
scope: MaybeAccessor<string>
|
||||
key: MaybeAccessor<string>
|
||||
cacheId: MaybeAccessor<string>
|
||||
version: MaybeAccessor<string | number>
|
||||
}
|
||||
|
||||
interface GlobalCacheHandle {
|
||||
|
||||
Reference in New Issue
Block a user