Spaces:
Runtime error
Runtime error
| import { useMemo, useRef, useState, useEffect } from 'react'; | |
| import { Box } from '@mui/material'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import remarkGfm from 'remark-gfm'; | |
| import type { SxProps, Theme } from '@mui/material/styles'; | |
| interface MarkdownContentProps { | |
| content: string; | |
| sx?: SxProps<Theme>; | |
| /** When true, shows a blinking cursor and throttles renders. */ | |
| isStreaming?: boolean; | |
| } | |
| /** Shared markdown styles β adapts to light/dark via CSS variables. */ | |
| const markdownSx: SxProps<Theme> = { | |
| fontSize: '0.925rem', | |
| lineHeight: 1.7, | |
| color: 'var(--text)', | |
| wordBreak: 'break-word', | |
| '& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } }, | |
| '& h1, & h2, & h3, & h4': { mt: 2.5, mb: 1, fontWeight: 600, lineHeight: 1.3 }, | |
| '& h1': { fontSize: '1.35rem' }, | |
| '& h2': { fontSize: '1.15rem' }, | |
| '& h3': { fontSize: '1.05rem' }, | |
| '& pre': { | |
| bgcolor: 'var(--code-bg)', | |
| p: 2, | |
| borderRadius: 2, | |
| overflow: 'auto', | |
| fontSize: '0.82rem', | |
| lineHeight: 1.6, | |
| border: '1px solid var(--tool-border)', | |
| my: 2, | |
| }, | |
| '& code': { | |
| bgcolor: 'var(--hover-bg)', | |
| px: 0.75, | |
| py: 0.25, | |
| borderRadius: 0.5, | |
| fontSize: '0.84rem', | |
| fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace', | |
| }, | |
| '& pre code': { bgcolor: 'transparent', p: 0 }, | |
| '& a': { | |
| color: 'var(--accent-yellow)', | |
| textDecoration: 'none', | |
| fontWeight: 500, | |
| '&:hover': { textDecoration: 'underline' }, | |
| }, | |
| '& ul, & ol': { pl: 3, my: 1 }, | |
| '& li': { mb: 0.5 }, | |
| '& li::marker': { color: 'var(--muted-text)' }, | |
| '& blockquote': { | |
| borderLeft: '3px solid var(--accent-yellow)', | |
| pl: 2, | |
| ml: 0, | |
| my: 1.5, | |
| color: 'var(--muted-text)', | |
| fontStyle: 'italic', | |
| }, | |
| '& table': { | |
| borderCollapse: 'collapse', | |
| width: '100%', | |
| my: 2, | |
| fontSize: '0.85rem', | |
| }, | |
| '& th': { | |
| borderBottom: '2px solid var(--border-hover)', | |
| textAlign: 'left', | |
| p: 1, | |
| fontWeight: 600, | |
| }, | |
| '& td': { | |
| borderBottom: '1px solid var(--tool-border)', | |
| p: 1, | |
| }, | |
| '& hr': { | |
| border: 'none', | |
| borderTop: '1px solid var(--border)', | |
| my: 2, | |
| }, | |
| '& img': { | |
| maxWidth: '100%', | |
| borderRadius: 2, | |
| }, | |
| }; | |
| /** | |
| * Throttled content for streaming: render the full markdown through | |
| * ReactMarkdown but only re-parse every ~80ms to avoid layout thrashing. | |
| * This is the Claude approach β always render as markdown, never split | |
| * into raw text. The parser handles incomplete tables gracefully. | |
| */ | |
| function useThrottledValue(value: string, isStreaming: boolean, intervalMs = 80): string { | |
| const [throttled, setThrottled] = useState(value); | |
| const lastUpdate = useRef(0); | |
| const pending = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| const latestValue = useRef(value); | |
| latestValue.current = value; | |
| useEffect(() => { | |
| if (!isStreaming) { | |
| // Not streaming β always use latest value immediately | |
| setThrottled(value); | |
| return; | |
| } | |
| const now = Date.now(); | |
| const elapsed = now - lastUpdate.current; | |
| if (elapsed >= intervalMs) { | |
| // Enough time passed β update immediately | |
| setThrottled(value); | |
| lastUpdate.current = now; | |
| } else { | |
| // Schedule an update for the remaining time | |
| if (pending.current) clearTimeout(pending.current); | |
| pending.current = setTimeout(() => { | |
| setThrottled(latestValue.current); | |
| lastUpdate.current = Date.now(); | |
| pending.current = null; | |
| }, intervalMs - elapsed); | |
| } | |
| return () => { | |
| if (pending.current) clearTimeout(pending.current); | |
| }; | |
| }, [value, isStreaming, intervalMs]); | |
| // When streaming ends, flush immediately | |
| useEffect(() => { | |
| if (!isStreaming) { | |
| setThrottled(latestValue.current); | |
| } | |
| }, [isStreaming]); | |
| return throttled; | |
| } | |
| export default function MarkdownContent({ content, sx, isStreaming = false }: MarkdownContentProps) { | |
| // Throttle re-parses during streaming to ~12fps (every 80ms) | |
| const displayContent = useThrottledValue(content, isStreaming); | |
| const remarkPlugins = useMemo(() => [remarkGfm], []); | |
| return ( | |
| <Box sx={[markdownSx, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}> | |
| <ReactMarkdown remarkPlugins={remarkPlugins}>{displayContent}</ReactMarkdown> | |
| </Box> | |
| ); | |
| } | |