Cache markdown render output per message part
This commit is contained in:
@@ -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()} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user