From d18e44f72150b7136531876a83a95c2c2aa3a034 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 28 Oct 2025 14:41:09 +0000 Subject: [PATCH] Cache markdown render output per message part --- src/components/markdown.tsx | 104 ++++++++++++++++---------------- src/components/message-part.tsx | 4 +- src/components/tool-call.tsx | 6 +- src/lib/markdown.ts | 44 ++++++++++++-- src/stores/sessions.ts | 32 +++++++++- src/types/message.ts | 13 ++++ 6 files changed, 140 insertions(+), 63 deletions(-) diff --git a/src/components/markdown.tsx b/src/components/markdown.tsx index a0db2f58..fccfe718 100644 --- a/src/components/markdown.tsx +++ b/src/components/markdown.tsx @@ -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 - ? `${lang}` - : '' - - header.innerHTML = ` - ${languageSpan} - - ` - 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
diff --git a/src/components/message-part.tsx b/src/components/message-part.tsx index 521ab99b..fe4535e3 100644 --- a/src/components/message-part.tsx +++ b/src/components/message-part.tsx @@ -25,7 +25,7 @@ export default function MessagePart(props: MessagePartProps) {
- +
@@ -48,7 +48,7 @@ export default function MessagePart(props: MessagePartProps) {
- +
diff --git a/src/components/tool-call.tsx b/src/components/tool-call.tsx index cd6e2d29..8a5a0109 100644 --- a/src/components/tool-call.tsx +++ b/src/components/tool-call.tsx @@ -357,7 +357,7 @@ export default function ToolCall(props: ToolCallProps) { return (
- +
) @@ -384,7 +384,7 @@ export default function ToolCall(props: ToolCallProps) { if (hasMarkdownCodeBlocks(truncated)) { return (
- +
) } @@ -479,7 +479,7 @@ export default function ToolCall(props: ToolCallProps) { if (hasMarkdownCodeBlocks(truncated)) { return (
- +
) } diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 2899720c..191056a1 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -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 | 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 = ` +
+ ${escapedLang || ""} + +
+ ` + if (!lang || !highlighter) { - return `
${escapeHtml(code)}
` + return `
${header}
${escapeHtml(code)}
` } try { @@ -50,9 +86,9 @@ function setupRenderer(isDark: boolean) { lang, theme: isDark ? "github-dark" : "github-light", }) - return `
${html}
` + return `
${header}${html}
` } catch { - return `
${escapeHtml(code)}
` + return `
${header}
${escapeHtml(code)}
` } } diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 1fddfae0..6a50ef80 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -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, }) } } diff --git a/src/types/message.ts b/src/types/message.ts index c2a54ec8..51b47914 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -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 +}