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