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 { renderMarkdown } from "../lib/markdown"
|
||||||
|
import type { TextPart } from "../types/message"
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
content: string
|
part: TextPart
|
||||||
isDark?: boolean
|
isDark?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Markdown(props: MarkdownProps) {
|
export function Markdown(props: MarkdownProps) {
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
|
let latestRequestedText = ""
|
||||||
|
|
||||||
createEffect(async () => {
|
createEffect(async () => {
|
||||||
const rendered = await renderMarkdown(props.content)
|
const part = props.part
|
||||||
setHtml(rendered)
|
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(() => {
|
onMount(() => {
|
||||||
const currentHtml = html()
|
const handleClick = async (e: Event) => {
|
||||||
if (containerRef && currentHtml) {
|
const target = e.target as HTMLElement
|
||||||
setTimeout(() => {
|
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||||
const codeBlocks = containerRef?.querySelectorAll(".markdown-code-block")
|
|
||||||
|
|
||||||
codeBlocks?.forEach((block) => {
|
if (copyButton) {
|
||||||
const existing = block.querySelector(".code-block-header")
|
e.preventDefault()
|
||||||
if (existing) return
|
const code = copyButton.getAttribute("data-code")
|
||||||
|
if (code) {
|
||||||
const lang = block.getAttribute("data-language")
|
const decodedCode = decodeURIComponent(code)
|
||||||
const encodedCode = block.getAttribute("data-code")
|
await navigator.clipboard.writeText(decodedCode)
|
||||||
|
const copyText = copyButton.querySelector(".copy-text")
|
||||||
const header = document.createElement("div")
|
if (copyText) {
|
||||||
header.className = "code-block-header"
|
copyText.textContent = "Copied!"
|
||||||
|
setTimeout(() => {
|
||||||
const languageSpan = lang
|
copyText.textContent = "Copy"
|
||||||
? `<span class="code-block-language">${lang}</span>`
|
}, 2000)
|
||||||
: '<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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}, 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()} />
|
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"}>
|
<Match when={partType() === "text"}>
|
||||||
<Show when={!props.part.synthetic && props.part.text}>
|
<Show when={!props.part.synthetic && props.part.text}>
|
||||||
<div class="message-text">
|
<div class="message-text">
|
||||||
<Markdown content={props.part.text} isDark={isDark()} />
|
<Markdown part={props.part} isDark={isDark()} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -48,7 +48,7 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={isReasoningExpanded()}>
|
<Show when={isReasoningExpanded()}>
|
||||||
<div class="message-text mt-2">
|
<div class="message-text mt-2">
|
||||||
<Markdown content={props.part.text || ""} isDark={isDark()} />
|
<Markdown part={props.part} isDark={isDark()} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return (
|
return (
|
||||||
<div class="tool-call-bash">
|
<div class="tool-call-bash">
|
||||||
<div class="message-text">
|
<div class="message-text">
|
||||||
<Markdown content={fullOutput} isDark={isDark()} />
|
<Markdown part={{ type: "text", text: fullOutput }} isDark={isDark()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -384,7 +384,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (hasMarkdownCodeBlocks(truncated)) {
|
if (hasMarkdownCodeBlocks(truncated)) {
|
||||||
return (
|
return (
|
||||||
<div class="message-text">
|
<div class="message-text">
|
||||||
<Markdown content={truncated} isDark={isDark()} />
|
<Markdown part={{ type: "text", text: truncated }} isDark={isDark()} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -479,7 +479,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (hasMarkdownCodeBlocks(truncated)) {
|
if (hasMarkdownCodeBlocks(truncated)) {
|
||||||
return (
|
return (
|
||||||
<div class="message-text">
|
<div class="message-text">
|
||||||
<Markdown content={truncated} isDark={isDark()} />
|
<Markdown part={{ type: "text", text: truncated }} isDark={isDark()} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
|
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 highlighter: Highlighter | null = null
|
||||||
let highlighterPromise: Promise<Highlighter> | null = null
|
let highlighterPromise: Promise<Highlighter> | null = null
|
||||||
let currentTheme: "light" | "dark" = "light"
|
let currentTheme: "light" | "dark" = "light"
|
||||||
@@ -15,9 +36,11 @@ async function getOrCreateHighlighter() {
|
|||||||
return highlighterPromise
|
return highlighterPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredLangs = CORE_LANGUAGES.filter((lang) => lang in bundledLanguages)
|
||||||
|
|
||||||
highlighterPromise = createHighlighter({
|
highlighterPromise = createHighlighter({
|
||||||
themes: ["github-light", "github-dark"],
|
themes: ["github-light", "github-dark"],
|
||||||
langs: Object.keys(bundledLanguages),
|
langs: filteredLangs,
|
||||||
})
|
})
|
||||||
|
|
||||||
highlighter = await highlighterPromise
|
highlighter = await highlighterPromise
|
||||||
@@ -41,8 +64,21 @@ function setupRenderer(isDark: boolean) {
|
|||||||
const encodedCode = encodeURIComponent(code)
|
const encodedCode = encodeURIComponent(code)
|
||||||
const escapedLang = lang ? escapeHtml(lang) : ""
|
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) {
|
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 {
|
try {
|
||||||
@@ -50,9 +86,9 @@ function setupRenderer(isDark: boolean) {
|
|||||||
lang,
|
lang,
|
||||||
theme: isDark ? "github-dark" : "github-light",
|
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 {
|
} 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)
|
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 = {
|
const message: Message = {
|
||||||
id: messageId,
|
id: messageId,
|
||||||
sessionId,
|
sessionId,
|
||||||
type: role === "user" ? "user" : "assistant",
|
type: role === "user" ? "user" : "assistant",
|
||||||
parts: apiMessage.parts || [],
|
parts,
|
||||||
timestamp: info.time?.created || Date.now(),
|
timestamp: info.time?.created || Date.now(),
|
||||||
status: "complete" as const,
|
status: "complete" as const,
|
||||||
version: 0,
|
version: 0,
|
||||||
@@ -855,12 +863,24 @@ function handleMessageUpdate(instanceId: string, event: any): void {
|
|||||||
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
|
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
|
||||||
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
|
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
|
||||||
filteredSynthetics = 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[]
|
let baseParts: any[]
|
||||||
if (replacedTemp) {
|
if (replacedTemp) {
|
||||||
baseParts = message.parts.filter((partItem: any) => partItem.type !== "text")
|
baseParts = message.parts.filter((partItem: any) => partItem.type !== "text")
|
||||||
message.parts = baseParts
|
message.parts = baseParts
|
||||||
|
// Clear render cache when replacing temp content
|
||||||
|
baseParts.forEach((partItem: any) => {
|
||||||
|
if (partItem.type === "text") {
|
||||||
|
partItem.renderCache = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
baseParts = message.parts
|
baseParts = message.parts
|
||||||
}
|
}
|
||||||
@@ -881,6 +901,10 @@ function handleMessageUpdate(instanceId: string, event: any): void {
|
|||||||
partMap.set(part.id, baseParts.length - 1)
|
partMap.set(part.id, baseParts.length - 1)
|
||||||
}
|
}
|
||||||
shouldIncrementVersion = true
|
shouldIncrementVersion = true
|
||||||
|
// Clear render cache for new text parts
|
||||||
|
if (part.type === "text") {
|
||||||
|
part.renderCache = undefined
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const previousPart = baseParts[partIndex]
|
const previousPart = baseParts[partIndex]
|
||||||
const textUnchanged =
|
const textUnchanged =
|
||||||
@@ -897,6 +921,10 @@ function handleMessageUpdate(instanceId: string, event: any): void {
|
|||||||
baseParts[partIndex] = part
|
baseParts[partIndex] = part
|
||||||
if (part.type !== "text" || !previousPart || previousPart.text !== part.text) {
|
if (part.type !== "text" || !previousPart || previousPart.text !== part.text) {
|
||||||
shouldIncrementVersion = true
|
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,
|
type: "text" as const,
|
||||||
text: prompt,
|
text: prompt,
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
|
renderCache: undefined,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1208,6 +1237,7 @@ async function sendMessage(
|
|||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: source.value,
|
text: source.value,
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
|
renderCache: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export interface RenderCache {
|
||||||
|
text: string
|
||||||
|
html: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageDisplayParts {
|
export interface MessageDisplayParts {
|
||||||
text: any[]
|
text: any[]
|
||||||
tool: any[]
|
tool: any[]
|
||||||
@@ -17,3 +22,11 @@ export interface Message {
|
|||||||
version: number
|
version: number
|
||||||
displayParts?: MessageDisplayParts
|
displayParts?: MessageDisplayParts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TextPart {
|
||||||
|
id?: string
|
||||||
|
type: "text"
|
||||||
|
text: string
|
||||||
|
synthetic?: boolean
|
||||||
|
renderCache?: RenderCache
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user