Merge pull request #47664 from NousResearch/bb/desktop-markdown-spread-overflow

fix(desktop): stop a single message from crashing or freezing the chat
This commit is contained in:
brooklyn! 2026-06-17 08:37:06 -05:00 committed by GitHub
commit f10f7114f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 259 additions and 31 deletions

View file

@ -322,13 +322,29 @@ function shortLabel(type: HermesRefType, id: string): string {
return tail || id
}
function safeEmbeddedImages(text: string) {
try {
return extractEmbeddedImages(text)
} catch {
return { cleanedText: text, images: [] as string[] }
}
}
function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] {
try {
return [...hermesDirectiveFormatter.parse(text)]
} catch {
return [{ kind: 'text', text }]
}
}
/**
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
* inline chips. Embedded MEDIA images render below as a thumbnail row.
*/
export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">

View file

@ -201,4 +201,13 @@ describe('preprocessMarkdown', () => {
expect(output).toContain('<https://example.com/a_b/c~d/page>')
})
it('handles a fenced block larger than V8 spread-argument limit', () => {
// A single huge code block (e.g. a logged minified bundle) used to throw
// `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`.
const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n')
const input = `\`\`\`js\n${body}\n\`\`\``
expect(() => preprocessMarkdown(input)).not.toThrow()
})
})

View file

@ -19,8 +19,9 @@ import {
useState
} from 'react'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
@ -57,7 +58,11 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
// module-scope so the prop identity is stable across renders.
function preprocessWithTailRepair(text: string): string {
return tailBoundedRemend(preprocessMarkdown(text))
try {
return tailBoundedRemend(preprocessMarkdown(text))
} catch {
return text
}
}
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
@ -453,8 +458,35 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn(
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
)
const MAX_MARKDOWN_CHARS = 200_000
function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) {
const chunks = useMemo(() => chunkByLines(text, 200), [text])
return (
<div
className={cn(
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-border font-mono text-[0.7rem] leading-relaxed text-foreground/90',
containerClassName
)}
>
<ExpandableBlock className="p-2">
{chunks.map((chunk, index) => (
<div
className="[content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * 16}px` }}
>
{chunk.text}
</div>
))}
</ExpandableBlock>
</div>
)
}
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
const { status } = useMessagePartText()
const { status, text } = useMessagePartText()
const isStreaming = status.type === 'running'
// Keep code parsing enabled while streaming so incomplete fenced blocks still
@ -551,6 +583,10 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
[isStreaming]
)
if (text.length > MAX_MARKDOWN_CHARS) {
return <HugeTextFallback containerClassName={containerClassName} text={text} />
}
return (
<StreamdownTextPrimitive
components={components}

View file

@ -66,7 +66,7 @@ function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
'font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
className
)}
data-slot="code-card-body"

View file

@ -0,0 +1,52 @@
'use client'
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface ExpandableBlockProps {
children: ReactNode
className?: string
}
export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
const innerRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
const [overflowing, setOverflowing] = useState(false)
useLayoutEffect(() => {
const el = innerRef.current
if (!el) {return}
const measure = () => setOverflowing(el.scrollHeight > 121)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [])
return (
<div className="relative">
<div
className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
ref={innerRef}
>
{children}
</div>
{overflowing && (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
)}
</div>
)
}

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { chunkByLines, exceedsHighlightBudget } from '@/components/chat/shiki-highlighter'
describe('exceedsHighlightBudget', () => {
it('highlights normal-sized blocks', () => {
expect(exceedsHighlightBudget('const x = 1\n'.repeat(100))).toBe(false)
})
it('skips highlighting past the line budget', () => {
expect(exceedsHighlightBudget('x\n'.repeat(5_000))).toBe(true)
})
it('skips highlighting past the char budget on few lines', () => {
expect(exceedsHighlightBudget('a'.repeat(200_000))).toBe(true)
})
it('short-circuits on char budget before line loop', () => {
expect(exceedsHighlightBudget('y\n'.repeat(250_000))).toBe(true)
})
})
describe('chunkByLines', () => {
it('keeps a small block as a single chunk', () => {
const code = 'a\nb\nc'
expect(chunkByLines(code, 200)).toEqual([{ text: code, lines: 3 }])
})
it('splits a large block and reconstructs it losslessly', () => {
const code = Array.from({ length: 1000 }, (_, i) => `line ${i}`).join('\n')
const chunks = chunkByLines(code, 200)
expect(chunks).toHaveLength(5)
expect(chunks.map(chunk => chunk.text).join('\n')).toBe(code)
expect(chunks.reduce((sum, chunk) => sum + chunk.lines, 0)).toBe(1000)
})
})

View file

@ -1,7 +1,7 @@
'use client'
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
import type { FC } from 'react'
import { type FC, useMemo } from 'react'
import ShikiHighlighter from 'react-shiki'
import {
@ -12,6 +12,7 @@ import {
CodeCardSubtitle,
CodeCardTitle
} from '@/components/chat/code-card'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
@ -43,6 +44,74 @@ const SHIKI_COLOR_REPLACEMENTS: Record<string, Record<string, string>> = {
'github-light-default': { '#6e7781': '#57606a' }
}
const MAX_HIGHLIGHT_CHARS = 150_000
const MAX_HIGHLIGHT_LINES = 3_000
const CHUNK_LINES = 200
const EST_LINE_PX = 16
export function exceedsHighlightBudget(code: string): boolean {
if (code.length > MAX_HIGHLIGHT_CHARS) {
return true
}
let lines = 1
let idx = code.indexOf('\n')
while (idx !== -1) {
if ((lines += 1) > MAX_HIGHLIGHT_LINES) {
return true
}
idx = code.indexOf('\n', idx + 1)
}
return false
}
interface CodeChunk {
text: string
lines: number
}
export function chunkByLines(code: string, perChunk: number): CodeChunk[] {
const lines = code.split('\n')
if (lines.length <= perChunk) {
return [{ text: code, lines: lines.length }]
}
const chunks: CodeChunk[] = []
for (let i = 0; i < lines.length; i += perChunk) {
const slice = lines.slice(i, i + perChunk)
chunks.push({ text: slice.join('\n'), lines: slice.length })
}
return chunks
}
const PlainCode: FC<{ code: string }> = ({ code }) => {
const chunks = useMemo(() => chunkByLines(code, CHUNK_LINES), [code])
if (chunks.length === 1) {
return <code className="block whitespace-pre">{code}</code>
}
return (
<>
{chunks.map((chunk, index) => (
<code
className="block whitespace-pre [content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * EST_LINE_PX}px` }}
>
{chunk.text}
</code>
))}
</>
)
}
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
components: { Pre },
language,
@ -64,6 +133,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
const plain = defer || exceedsHighlightBudget(trimmed)
return (
<CodeCard data-streaming={defer ? 'true' : undefined}>
@ -83,24 +153,26 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
/>
</CodeCardHeader>
<CodeCardBody>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
{defer ? (
<code className="block whitespace-pre">{trimmed}</code>
) : (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{trimmed}
</ShikiHighlighter>
)}
</Pre>
<ExpandableBlock>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
{plain ? (
<PlainCode code={trimmed} />
) : (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{trimmed}
</ShikiHighlighter>
)}
</Pre>
</ExpandableBlock>
</CodeCardBody>
</CodeCard>
)

View file

@ -151,12 +151,18 @@ function normalizeVisibleProse(text: string): string {
.join('')
}
function extend(out: string[], lines: string[]) {
for (const line of lines) {
out.push(line)
}
}
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
if (info) {
out.push(`${indent}${info}`.trimEnd())
}
out.push(...lines)
extend(out, lines)
}
function findClosingFence(lines: string[], start: number, marker: string): number {
@ -241,7 +247,7 @@ function normalizeFenceBlocks(text: string): string {
}
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
out.push(...bodyLines)
extend(out, bodyLines)
index = closeIndex + 1
continue
@ -264,10 +270,10 @@ function normalizeFenceBlocks(text: string): string {
// any literal `$$` characters in the body don't collide with
// an outer math wrapper. No close emitted yet — streaming.
out.push(`${indent}${marker}math`)
out.push(...bodyLines)
extend(out, bodyLines)
} else {
out.push(`${indent}${marker}${language}`)
out.push(...bodyLines)
extend(out, bodyLines)
}
break
@ -288,7 +294,7 @@ function normalizeFenceBlocks(text: string): string {
// colliding with our wrapper. Without this rewrite the block
// would render as a syntax-highlighted "latex" code listing.
out.push(`${indent}${marker}math`)
out.push(...bodyLines)
extend(out, bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
@ -296,7 +302,7 @@ function normalizeFenceBlocks(text: string): string {
}
out.push(`${indent}${marker}${language}`)
out.push(...bodyLines)
extend(out, bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
}