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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user