diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7b3a20e7..a1885d57 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,7 +28,9 @@ "clsx": "^2.1.1", "codemirror": "^6.0.2", "framer-motion": "^12.35.2", + "hast-util-to-jsx-runtime": "^2.3.6", "html-to-image": "^1.11.13", + "html-url-attributes": "^3.0.1", "leaflet": "^1.9.4", "lucide-react": "^1.17.0", "radix-ui": "^1.6.3", @@ -41,10 +43,14 @@ "react-syntax-highlighter": "^16.1.1", "recharts": "^2.15.4", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "shiki": "^3.23.0", "simple-icons": "^16.28.0", "supercluster": "^8.0.1", "tailwind-merge": "^3.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/frontend/package.json b/frontend/package.json index b9710730..08f0ac8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,9 @@ "clsx": "^2.1.1", "codemirror": "^6.0.2", "framer-motion": "^12.35.2", + "hast-util-to-jsx-runtime": "^2.3.6", "html-to-image": "^1.11.13", + "html-url-attributes": "^3.0.1", "leaflet": "^1.9.4", "lucide-react": "^1.17.0", "radix-ui": "^1.6.3", @@ -42,10 +44,14 @@ "react-syntax-highlighter": "^16.1.1", "recharts": "^2.15.4", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "shiki": "^3.23.0", "simple-icons": "^16.28.0", "supercluster": "^8.0.1", "tailwind-merge": "^3.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index 7a227dc8..c5bf8ad7 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -18,8 +18,7 @@ import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutl import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined'; import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; import LanguageIcon from '@mui/icons-material/Language'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; +import { renderMarkdownCached, renderMarkdownNow } from './markdownCache'; import WindowedMarkdown from './WindowedMarkdown'; import WindowedPlainText from './WindowedPlainText'; import { renderUserTextWithPills } from './renderUserTextWithPills'; @@ -933,16 +932,11 @@ const ChatMessageBubble: React.FC = ({ message, editing = false, onSaveEd return { text: rawText, start: 0, end: rawText.length, windowed: false }; }, [rawText, shouldRenderMarkdown]); - const renderedMarkdown = useMemo(() => ( - ( - {children} - ), - }} - >{markdownWindow.text} - ), [markdownWindow.text]); + // Streaming prefixes are unique per chunk, so they bypass the LRU; finished text hits it and survives remounts. + const renderedMarkdown = useMemo( + () => (isStreaming ? renderMarkdownNow(markdownWindow.text) : renderMarkdownCached(markdownWindow.text)), + [markdownWindow.text, isStreaming], + ); // Height to reserve for this message's off-screen placeholder before it has ever been measured. Estimated from the FULL text length (we render in full when in view) with the same model as AgentChat's spacer estimate, so the placeholder and the spacer reserve the same space. Once rendered, oversizedContentHeights wins over this. const placeholderFallbackHeight = useMemo( diff --git a/frontend/src/app/pages/AgentChat/bubbles/WindowedMarkdown.tsx b/frontend/src/app/pages/AgentChat/bubbles/WindowedMarkdown.tsx index 1fbc92ad..43fb8fd9 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/WindowedMarkdown.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/WindowedMarkdown.tsx @@ -1,7 +1,6 @@ import React, { useMemo, useRef, useState } from 'react'; import Box from '@mui/material/Box'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; +import { renderMarkdownCached } from './markdownCache'; import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './markdownMeasure'; // Intra-message virtualization for very long assistant messages. The text is split into FIXED blocks (each block always covers the same character range), so unlike the old growing-tail chunking nothing shifts as you scroll, and no scroll correction is needed. Only blocks within a screen of the viewport actually render their markdown; the rest are height-reserved placeholders, so an extremely long message never parses or mounts more than the on-screen portion plus a buffer. @@ -33,15 +32,8 @@ function splitMarkdownIntoBlocks(text: string, targetChars: number): string[] { return blocks.length ? blocks : [text]; } -const MD_COMPONENTS = { - a: ({ children, ...props }: any) => ( - {children} - ), -}; - -const renderBlock = (text: string) => ( - {text} -); +// Blocks only exist for finished oversized messages, so every block render goes through the LRU: scrolling a block out and back in reuses the parsed tree instead of re-paying the ~5-10ms parse. +const renderBlock = (text: string) => <>{renderMarkdownCached(text)}; const MarkdownBlock: React.FC<{ blockId: string; diff --git a/frontend/src/app/pages/AgentChat/bubbles/markdownCache.test.ts b/frontend/src/app/pages/AgentChat/bubbles/markdownCache.test.ts new file mode 100644 index 00000000..a970c2c0 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/bubbles/markdownCache.test.ts @@ -0,0 +1,48 @@ +// Run: node --test frontend/src/app/pages/AgentChat/bubbles/markdownCache.test.ts +// Byte-parity guard: the cached pipeline must render EXACTLY what renders, or a react-markdown upgrade has drifted the mirror. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { renderMarkdownCached, renderMarkdownNow, transcriptComponents } from './markdownCache.ts'; + +const fixtures: Record = { + prose: 'Hello **world**, some *emphasis* and `inline code`.', + link: 'A [link](https://example.com/a?b=c#d) and an autolink https://example.com/auto.', + unsafeLink: 'Bad [click me](javascript:alert(1)) link and [data](data:text/html;base64,x).', + image: '![alt text](https://example.com/img.png "title")', + table: '| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |', + codeFence: '```ts\nconst x: number = 42;\nfunction f(a: string): string { return a + x; }\n```', + taskList: '- [x] done thing\n- [ ] open thing', + strikethrough: 'This is ~~gone~~ kept.', + rawHtml: 'Before
inside
after, and too.', + headingsQuotes: '# Title\n\n## Sub\n\n> a quote\n\n---\n\n1. one\n2. two', + footnote: 'A claim.[^1]\n\n[^1]: The source.', + mixed: '## Report\n\n| metric | value |\n| --- | --- |\n| speed | **fast** |\n\n```py\nprint("hi")\n```\n\n- item with [ref](https://x.dev)\n', +}; + +const reference = (text: string): string => + renderToString( + createElement(ReactMarkdown, { remarkPlugins: [remarkGfm], components: transcriptComponents }, text), + ); + +for (const [name, text] of Object.entries(fixtures)) { + test(`parity: ${name}`, () => { + assert.equal(renderToString(createElement(() => renderMarkdownNow(text) as never)), reference(text)); + }); +} + +test('cache returns the identical element tree across calls', () => { + const a = renderMarkdownCached('Same **text** twice.'); + const b = renderMarkdownCached('Same **text** twice.'); + assert.equal(a, b); +}); + +test('cache is bounded', async () => { + for (let i = 0; i < 260; i++) renderMarkdownCached(`unique filler number ${i}`); + const early = renderMarkdownCached('unique filler number 1'); + const earlyAgain = renderMarkdownCached('unique filler number 1'); + assert.equal(early, earlyAgain); +}); diff --git a/frontend/src/app/pages/AgentChat/bubbles/markdownCache.ts b/frontend/src/app/pages/AgentChat/bubbles/markdownCache.ts new file mode 100644 index 00000000..7b100c37 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/bubbles/markdownCache.ts @@ -0,0 +1,76 @@ +import { createElement, type ReactNode } from 'react'; +import { Fragment, jsx, jsxs } from 'react/jsx-runtime'; +import { toJsxRuntime, type Components } from 'hast-util-to-jsx-runtime'; +import { urlAttributes } from 'html-url-attributes'; +import { defaultUrlTransform } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import { unified } from 'unified'; +import { visit, type BuildVisitor } from 'unist-util-visit'; +import type { Root } from 'hast'; + +// Mirrors react-markdown@10.1.0's parse -> run -> post pipeline so a finished message's parsed tree can live in a module-level LRU and survive remounts (session switch, scroll re-entry of windowed blocks). markdownCache.test.ts asserts byte-identical HTML against the real , so any upgrade that drifts the pipeline fails loudly instead of silently. +const processor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }); + +export const transcriptComponents: Partial = { + a: ({ children, node, ...props }) => { + void node; + return createElement('a', { ...props, style: { cursor: 'pointer' } }, children); + }, +}; + +const transform: BuildVisitor = (node, index, parent) => { + if (node.type === 'raw' && parent && typeof index === 'number') { + parent.children[index] = { type: 'text', value: node.value }; + return index; + } + if (node.type === 'element') { + let key: string; + for (key in urlAttributes) { + if (Object.hasOwn(urlAttributes, key) && Object.hasOwn(node.properties, key)) { + const value = node.properties[key]; + const test = urlAttributes[key]; + if (test === null || test.includes(node.tagName)) { + node.properties[key] = defaultUrlTransform(String(value || '')); + } + } + } + } + return undefined; +}; + +/** Parse + render markdown to a React tree right now, no caching. The streaming path uses this so per-chunk prefixes never churn the LRU. */ +export function renderMarkdownNow(text: string): ReactNode { + const mdast = processor.parse(text); + const hast = processor.runSync(mdast) as Root; + visit(hast, transform); + return toJsxRuntime(hast, { + Fragment, + components: transcriptComponents, + ignoreInvalidStyle: true, + jsx, + jsxs, + passKeys: true, + passNode: true, + }); +} + +const CACHE_MAX = 200; +const cache = new Map(); + +/** Cached render for FINISHED text only: same text in, same immutable element tree out, across remounts. */ +export function renderMarkdownCached(text: string): ReactNode { + const hit = cache.get(text); + if (hit !== undefined) { + cache.delete(text); + cache.set(text, hit); + return hit; + } + const el = renderMarkdownNow(text); + cache.set(text, el); + if (cache.size > CACHE_MAX) { + cache.delete(cache.keys().next().value as string); + } + return el; +}