mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-19 10:02:16 +00:00
perf(desktop): keep oversized messages from freezing the chat
A multi-MB message (logged bundle, huge tool dump) froze the renderer before any paint: Streamdown runs `preprocess` + `marked` lex over the whole string synchronously in a useMemo, an uninterruptible long task that no try/catch or content-visibility can help (our JS runs before the browser ever skips layout). Tiered fix: - Message gate: past 200KB, bypass markdown entirely and render the raw text in `content-visibility:auto` line-chunks — synchronous work is bounded to a string split, the browser virtualizes layout natively, and every line stays in the DOM (selectable, find-in-page). - Code-block budget: past 3k lines / 150KB, skip Shiki (which emits a span per token) and render plain, chunked the same way. - Collapse/expand: a reusable ExpandableBlock clamps code blocks and the huge-text fallback to a 120px preview with a gradient + chevron, expanding to 300px. The inner element is always a scroll container so the content-visibility chunks stay lazily laid out in both states. No content is ever dropped; the copy button (card header) always yields the full block.
This commit is contained in:
parent
b82eca2beb
commit
0138282f97
7 changed files with 233 additions and 48 deletions
|
|
@ -322,28 +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 }) {
|
||||
// Both passes run text through regexes; on pathological input they can throw
|
||||
// (or overflow) and, since this renders inside a useMemo under the message,
|
||||
// bubble up to the root error boundary. Degrade gracefully to plain text.
|
||||
const { cleanedText, images } = useMemo(() => {
|
||||
try {
|
||||
return extractEmbeddedImages(text ?? '')
|
||||
} catch {
|
||||
return { cleanedText: text ?? '', images: [] }
|
||||
}
|
||||
}, [text])
|
||||
const segments = useMemo(() => {
|
||||
try {
|
||||
return hermesDirectiveFormatter.parse(cleanedText)
|
||||
} catch {
|
||||
return [{ kind: 'text', text: cleanedText }] as Unstable_DirectiveSegment[]
|
||||
}
|
||||
}, [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">
|
||||
|
|
|
|||
|
|
@ -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,11 +58,6 @@ 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 {
|
||||
// Streamdown runs `preprocess` inside its own useMemo, so anything thrown
|
||||
// here escapes to the ROOT error boundary and takes down the whole app — a
|
||||
// single adversarial message (e.g. content that overflows a regex/stack)
|
||||
// shouldn't be able to do that. Degrade to the raw text instead; it still
|
||||
// renders, just without our cosmetic normalization.
|
||||
try {
|
||||
return tailBoundedRemend(preprocessMarkdown(text))
|
||||
} catch {
|
||||
|
|
@ -462,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
|
||||
|
|
@ -542,6 +565,10 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
|||
[isStreaming]
|
||||
)
|
||||
|
||||
if (text.length > MAX_MARKDOWN_CHARS) {
|
||||
return <HugeTextFallback containerClassName={containerClassName} text={text} />
|
||||
}
|
||||
|
||||
return (
|
||||
<StreamdownTextPrimitive
|
||||
components={components}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
52
apps/desktop/src/components/chat/expandable-block.tsx
Normal file
52
apps/desktop/src/components/chat/expandable-block.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
apps/desktop/src/components/chat/shiki-highlighter.test.ts
Normal file
37
apps/desktop/src/components/chat/shiki-highlighter.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -151,10 +151,6 @@ function normalizeVisibleProse(text: string): string {
|
|||
.join('')
|
||||
}
|
||||
|
||||
// `out.push(...lines)` spreads every element as a separate call argument, so a
|
||||
// single fenced block with tens of thousands of lines (a logged minified
|
||||
// bundle, base64 blob, huge tool dump) overflows V8's argument-count limit and
|
||||
// throws `RangeError: Maximum call stack size exceeded`. Append iteratively.
|
||||
function extend(out: string[], lines: string[]) {
|
||||
for (const line of lines) {
|
||||
out.push(line)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue