Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
File size: 4,641 Bytes
79b2fcc 70d2074 79b2fcc 70d2074 79b2fcc 70d2074 79b2fcc 70d2074 79b2fcc 70d2074 79b2fcc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | 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',
display: 'block',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
},
'& thead': {
position: 'sticky',
top: 0,
},
'& th': {
borderBottom: '2px solid var(--border-hover)',
bgcolor: 'var(--hover-bg)',
textAlign: 'left',
px: 1.5,
py: 0.75,
fontWeight: 600,
whiteSpace: 'nowrap',
},
'& td': {
borderBottom: '1px solid var(--tool-border)',
px: 1.5,
py: 0.75,
},
'& tr:nth-of-type(even) td': {
bgcolor: 'color-mix(in srgb, var(--hover-bg) 50%, transparent)',
},
'& 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>
);
}
|