Cache markdown render output per message part

This commit is contained in:
Shantur Rathore
2025-10-28 14:41:09 +00:00
parent 6597783e85
commit d18e44f721
6 changed files with 140 additions and 63 deletions

View File

@@ -1,72 +1,70 @@
import { createEffect, createSignal, Show } from "solid-js"
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { renderMarkdown } from "../lib/markdown"
import type { TextPart } from "../types/message"
interface MarkdownProps {
content: string
part: TextPart
isDark?: boolean
}
export function Markdown(props: MarkdownProps) {
const [html, setHtml] = createSignal("")
let containerRef: HTMLDivElement | undefined
let latestRequestedText = ""
createEffect(async () => {
const rendered = await renderMarkdown(props.content)
setHtml(rendered)
const part = props.part
const text = part.text || ""
if (part.renderCache && part.renderCache.text === text) {
setHtml(part.renderCache.html)
return
}
latestRequestedText = text
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
setHtml(rendered)
part.renderCache = { text, html: rendered }
}
} catch (error) {
console.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
setHtml(text)
}
}
})
createEffect(() => {
const currentHtml = html()
if (containerRef && currentHtml) {
setTimeout(() => {
const codeBlocks = containerRef?.querySelectorAll(".markdown-code-block")
onMount(() => {
const handleClick = async (e: Event) => {
const target = e.target as HTMLElement
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
codeBlocks?.forEach((block) => {
const existing = block.querySelector(".code-block-header")
if (existing) return
const lang = block.getAttribute("data-language")
const encodedCode = block.getAttribute("data-code")
const header = document.createElement("div")
header.className = "code-block-header"
const languageSpan = lang
? `<span class="code-block-language">${lang}</span>`
: '<span class="code-block-language"></span>'
header.innerHTML = `
${languageSpan}
<button class="code-block-copy" data-code="${encodedCode || ""}">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
`
block.insertBefore(header, block.firstChild)
const button = header.querySelector(".code-block-copy")
if (button) {
button.addEventListener("click", async () => {
const code = button.getAttribute("data-code")
if (code) {
const decodedCode = decodeURIComponent(code)
await navigator.clipboard.writeText(decodedCode)
const copyText = button.querySelector(".copy-text")
if (copyText) {
copyText.textContent = "Copied!"
setTimeout(() => {
copyText.textContent = "Copy"
}, 2000)
}
}
})
if (copyButton) {
e.preventDefault()
const code = copyButton.getAttribute("data-code")
if (code) {
const decodedCode = decodeURIComponent(code)
await navigator.clipboard.writeText(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (copyText) {
copyText.textContent = "Copied!"
setTimeout(() => {
copyText.textContent = "Copy"
}, 2000)
}
})
}, 0)
}
}
}
containerRef?.addEventListener("click", handleClick)
onCleanup(() => {
containerRef?.removeEventListener("click", handleClick)
})
})
return <div ref={containerRef} class="prose prose-sm dark:prose-invert max-w-none" innerHTML={html()} />

View File

@@ -25,7 +25,7 @@ export default function MessagePart(props: MessagePartProps) {
<Match when={partType() === "text"}>
<Show when={!props.part.synthetic && props.part.text}>
<div class="message-text">
<Markdown content={props.part.text} isDark={isDark()} />
<Markdown part={props.part} isDark={isDark()} />
</div>
</Show>
</Match>
@@ -48,7 +48,7 @@ export default function MessagePart(props: MessagePartProps) {
</div>
<Show when={isReasoningExpanded()}>
<div class="message-text mt-2">
<Markdown content={props.part.text || ""} isDark={isDark()} />
<Markdown part={props.part} isDark={isDark()} />
</div>
</Show>
</div>

View File

@@ -357,7 +357,7 @@ export default function ToolCall(props: ToolCallProps) {
return (
<div class="tool-call-bash">
<div class="message-text">
<Markdown content={fullOutput} isDark={isDark()} />
<Markdown part={{ type: "text", text: fullOutput }} isDark={isDark()} />
</div>
</div>
)
@@ -384,7 +384,7 @@ export default function ToolCall(props: ToolCallProps) {
if (hasMarkdownCodeBlocks(truncated)) {
return (
<div class="message-text">
<Markdown content={truncated} isDark={isDark()} />
<Markdown part={{ type: "text", text: truncated }} isDark={isDark()} />
</div>
)
}
@@ -479,7 +479,7 @@ export default function ToolCall(props: ToolCallProps) {
if (hasMarkdownCodeBlocks(truncated)) {
return (
<div class="message-text">
<Markdown content={truncated} isDark={isDark()} />
<Markdown part={{ type: "text", text: truncated }} isDark={isDark()} />
</div>
)
}

View File

@@ -1,6 +1,27 @@
import { marked } from "marked"
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
const CORE_LANGUAGES = [
"bash",
"shell",
"sh",
"javascript",
"typescript",
"tsx",
"jsx",
"json",
"yaml",
"yml",
"markdown",
"md",
"html",
"css",
"scss",
"python",
"go",
"rust",
]
let highlighter: Highlighter | null = null
let highlighterPromise: Promise<Highlighter> | null = null
let currentTheme: "light" | "dark" = "light"
@@ -15,9 +36,11 @@ async function getOrCreateHighlighter() {
return highlighterPromise
}
const filteredLangs = CORE_LANGUAGES.filter((lang) => lang in bundledLanguages)
highlighterPromise = createHighlighter({
themes: ["github-light", "github-dark"],
langs: Object.keys(bundledLanguages),
langs: filteredLangs,
})
highlighter = await highlighterPromise
@@ -41,8 +64,21 @@ function setupRenderer(isDark: boolean) {
const encodedCode = encodeURIComponent(code)
const escapedLang = lang ? escapeHtml(lang) : ""
const header = `
<div class="code-block-header">
<span class="code-block-language">${escapedLang || ""}</span>
<button class="code-block-copy" data-code="${encodedCode}">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
</div>
`
if (!lang || !highlighter) {
return `<div class="markdown-code-block" data-language="" data-code="${encodedCode}"><pre><code>${escapeHtml(code)}</code></pre></div>`
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code>${escapeHtml(code)}</code></pre></div>`
}
try {
@@ -50,9 +86,9 @@ function setupRenderer(isDark: boolean) {
lang,
theme: isDark ? "github-dark" : "github-light",
})
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${html}</div>`
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>`
} catch {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}"><pre><code class="language-${escapedLang}">${escapeHtml(code)}</code></pre></div>`
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(code)}</code></pre></div>`
}
}

View File

@@ -706,11 +706,19 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
messagesInfo.set(messageId, info)
// Clear render cache for all parts when loading messages
const parts = (apiMessage.parts || []).map((part: any) => {
if (part.type === "text") {
return { ...part, renderCache: undefined }
}
return part
})
const message: Message = {
id: messageId,
sessionId,
type: role === "user" ? "user" : "assistant",
parts: apiMessage.parts || [],
parts,
timestamp: info.time?.created || Date.now(),
status: "complete" as const,
version: 0,
@@ -855,12 +863,24 @@ function handleMessageUpdate(instanceId: string, event: any): void {
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
filteredSynthetics = true
// Clear render cache from remaining parts when synthetic parts are removed
message.parts.forEach((partItem: any) => {
if (partItem.type === "text") {
partItem.renderCache = undefined
}
})
}
let baseParts: any[]
if (replacedTemp) {
baseParts = message.parts.filter((partItem: any) => partItem.type !== "text")
message.parts = baseParts
// Clear render cache when replacing temp content
baseParts.forEach((partItem: any) => {
if (partItem.type === "text") {
partItem.renderCache = undefined
}
})
} else {
baseParts = message.parts
}
@@ -881,6 +901,10 @@ function handleMessageUpdate(instanceId: string, event: any): void {
partMap.set(part.id, baseParts.length - 1)
}
shouldIncrementVersion = true
// Clear render cache for new text parts
if (part.type === "text") {
part.renderCache = undefined
}
} else {
const previousPart = baseParts[partIndex]
const textUnchanged =
@@ -897,6 +921,10 @@ function handleMessageUpdate(instanceId: string, event: any): void {
baseParts[partIndex] = part
if (part.type !== "text" || !previousPart || previousPart.text !== part.text) {
shouldIncrementVersion = true
// Clear render cache when text changes
if (part.type === "text") {
part.renderCache = undefined
}
}
}
@@ -1147,6 +1175,7 @@ async function sendMessage(
type: "text" as const,
text: prompt,
synthetic: true,
renderCache: undefined,
},
]
@@ -1208,6 +1237,7 @@ async function sendMessage(
type: "text" as const,
text: source.value,
synthetic: true,
renderCache: undefined,
})
}
}

View File

@@ -1,3 +1,8 @@
export interface RenderCache {
text: string
html: string
}
export interface MessageDisplayParts {
text: any[]
tool: any[]
@@ -17,3 +22,11 @@ export interface Message {
version: number
displayParts?: MessageDisplayParts
}
export interface TextPart {
id?: string
type: "text"
text: string
synthetic?: boolean
renderCache?: RenderCache
}