mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
Merge pull request #67236 from NousResearch/bb/tui-incremental-markdown
perf(tui): render streamed markdown incrementally per block
This commit is contained in:
commit
4f4a4f0d43
3 changed files with 637 additions and 143 deletions
302
ui-tui/scripts/bench-streaming-md.tsx
Normal file
302
ui-tui/scripts/bench-streaming-md.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
// Benchmark: streamed-markdown render strategies for the TUI.
|
||||
//
|
||||
// Replays a newline-terminated, block-heavy synthetic stream at width 80
|
||||
// through a real Ink render (renderSync + rerender per update) and compares:
|
||||
//
|
||||
// naive — <Md text={full}/> per update (re-tokenizes everything)
|
||||
// monolithic — the previous StreamingMd: one memoized stable-prefix <Md>
|
||||
// plus a tail <Md>. O(total) fence scan per update and a
|
||||
// full prefix re-parse every time the boundary advances.
|
||||
// per-block — current StreamingMd: append-only settled block array +
|
||||
// incremental scanner. Each block parses exactly once.
|
||||
//
|
||||
// Each strategy/size pair runs in its OWN child process (orchestrator mode)
|
||||
// so the parse-tree LRU in markdown.tsx and GC pressure from one strategy
|
||||
// can't distort another's numbers. Each run also gets a unique text salt and
|
||||
// a fresh theme object (its own WeakMap cache bucket).
|
||||
//
|
||||
// Run: npx tsx scripts/bench-streaming-md.tsx
|
||||
// Single case: npx tsx scripts/bench-streaming-md.tsx <naive|monolithic|per-block> <blocks>
|
||||
|
||||
import { execFileSync } from 'child_process'
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { Box, renderSync } from '@hermes/ink'
|
||||
import React, { memo, useRef } from 'react'
|
||||
|
||||
import { Md } from '../src/components/markdown.js'
|
||||
import { StreamingMd } from '../src/components/streamingMarkdown.js'
|
||||
import { DEFAULT_THEME } from '../src/theme.js'
|
||||
|
||||
// ---- previous implementation (monolithic stable prefix), for comparison ----
|
||||
|
||||
const fenceOpenAt = (s: string, end: number) => {
|
||||
let codeOpen = false
|
||||
let mathOpen = false
|
||||
let mathOpener: '$$' | '\\[' | null = null
|
||||
let i = 0
|
||||
|
||||
while (i < end) {
|
||||
const nl = s.indexOf('\n', i)
|
||||
const lineEnd = nl < 0 || nl > end ? end : nl
|
||||
const line = s.slice(i, lineEnd).trim()
|
||||
|
||||
if (/^(?:`{3,}|~{3,})/.test(line)) {
|
||||
codeOpen = !codeOpen
|
||||
} else if (!codeOpen) {
|
||||
if (!mathOpen && /^\$\$/.test(line)) {
|
||||
if (!(line.length >= 4 && /\$\$$/.test(line))) {
|
||||
mathOpen = true
|
||||
mathOpener = '$$'
|
||||
}
|
||||
} else if (!mathOpen && /^\\\[/.test(line)) {
|
||||
if (!/\\\]$/.test(line)) {
|
||||
mathOpen = true
|
||||
mathOpener = '\\['
|
||||
}
|
||||
} else if (mathOpen && mathOpener === '$$' && /\$\$$/.test(line)) {
|
||||
mathOpen = false
|
||||
mathOpener = null
|
||||
} else if (mathOpen && mathOpener === '\\[' && /\\\]$/.test(line)) {
|
||||
mathOpen = false
|
||||
mathOpener = null
|
||||
}
|
||||
}
|
||||
|
||||
if (nl < 0 || nl >= end) {
|
||||
break
|
||||
}
|
||||
|
||||
i = nl + 1
|
||||
}
|
||||
|
||||
return codeOpen || mathOpen
|
||||
}
|
||||
|
||||
const findStableBoundaryOld = (text: string) => {
|
||||
let idx = text.length
|
||||
|
||||
while (idx > 0) {
|
||||
const boundary = text.lastIndexOf('\n\n', idx - 1)
|
||||
|
||||
if (boundary < 0) {
|
||||
return -1
|
||||
}
|
||||
|
||||
const splitAt = boundary + 2
|
||||
|
||||
if (!fenceOpenAt(text, splitAt)) {
|
||||
return splitAt
|
||||
}
|
||||
|
||||
idx = boundary
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
const MonolithicStreamingMd = memo(function MonolithicStreamingMd({
|
||||
t,
|
||||
text
|
||||
}: {
|
||||
t: typeof DEFAULT_THEME
|
||||
text: string
|
||||
}) {
|
||||
const stablePrefixRef = useRef('')
|
||||
|
||||
if (!text.startsWith(stablePrefixRef.current)) {
|
||||
stablePrefixRef.current = ''
|
||||
}
|
||||
|
||||
const boundary = findStableBoundaryOld(text)
|
||||
|
||||
if (boundary > stablePrefixRef.current.length) {
|
||||
stablePrefixRef.current = text.slice(0, boundary)
|
||||
}
|
||||
|
||||
const stablePrefix = stablePrefixRef.current
|
||||
const unstableSuffix = text.slice(stablePrefix.length)
|
||||
|
||||
if (!stablePrefix) {
|
||||
return <Md t={t} text={unstableSuffix} />
|
||||
}
|
||||
|
||||
if (!unstableSuffix) {
|
||||
return <Md t={t} text={stablePrefix} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Md t={t} text={stablePrefix} />
|
||||
<Md t={t} text={unstableSuffix} />
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
// ---- synthetic stream ----
|
||||
|
||||
const makeBlocks = (count: number, salt: string) => {
|
||||
const blocks: string[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
switch (i % 4) {
|
||||
case 0:
|
||||
blocks.push(`Paragraph ${salt}-${i} explaining step ${i} with **bold** and \`code\` inline.\n`)
|
||||
|
||||
break
|
||||
|
||||
case 1:
|
||||
blocks.push(`- item one ${salt}-${i}\n- item two with _emphasis_\n- item three\n`)
|
||||
|
||||
break
|
||||
|
||||
case 2:
|
||||
blocks.push(`\`\`\`ts\nconst v${i} = compute${salt}(${i})\nif (v${i} > 0) {\n emit(v${i})\n}\n\`\`\`\n`)
|
||||
|
||||
break
|
||||
|
||||
default:
|
||||
blocks.push(`### Heading ${salt}-${i}\n\nSome follow-up prose for section ${i}.\n`)
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
// Newline-terminated updates: the stream grows one line per rerender.
|
||||
const makeUpdates = (blocks: string[]) => {
|
||||
const full = blocks.join('\n')
|
||||
const updates: string[] = []
|
||||
|
||||
let pos = 0
|
||||
|
||||
while (pos < full.length) {
|
||||
const nl = full.indexOf('\n', pos)
|
||||
|
||||
pos = nl < 0 ? full.length : nl + 1
|
||||
updates.push(full.slice(0, pos))
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
const nullStream = () => {
|
||||
const s = new PassThrough()
|
||||
|
||||
Object.assign(s, { columns: 80, isTTY: false, rows: 24 })
|
||||
s.on('data', () => {})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
const bench = (
|
||||
label: string,
|
||||
updates: string[],
|
||||
node: (t: typeof DEFAULT_THEME, text: string) => React.ReactNode,
|
||||
captureSeries = false
|
||||
) => {
|
||||
// Fresh theme per run → fresh (collectable) mdCache bucket.
|
||||
const runTheme = { ...DEFAULT_THEME }
|
||||
|
||||
const instance = renderSync(node(runTheme, ''), {
|
||||
patchConsole: false,
|
||||
stderr: nullStream() as unknown as NodeJS.WriteStream,
|
||||
stdin: nullStream() as unknown as NodeJS.ReadStream,
|
||||
stdout: nullStream() as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
const times: number[] = []
|
||||
const start = performance.now()
|
||||
|
||||
let n = 0
|
||||
|
||||
for (const text of updates) {
|
||||
const t0 = captureSeries ? performance.now() : 0
|
||||
|
||||
instance.rerender(node(runTheme, text))
|
||||
|
||||
if (captureSeries) {
|
||||
times.push(performance.now() - t0)
|
||||
}
|
||||
|
||||
// Something in the render path emits performance measures; unbounded,
|
||||
// the entry buffer itself becomes a memory leak over thousands of
|
||||
// rerenders and skews long runs.
|
||||
if (++n % 64 === 0) {
|
||||
performance.clearMeasures()
|
||||
performance.clearMarks()
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - start
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
return { elapsed, label, times }
|
||||
}
|
||||
|
||||
const STRATEGIES = {
|
||||
monolithic: (t: typeof DEFAULT_THEME, text: string) => <MonolithicStreamingMd t={t} text={text} />,
|
||||
naive: (t: typeof DEFAULT_THEME, text: string) => <Md t={t} text={text} />,
|
||||
'per-block': (t: typeof DEFAULT_THEME, text: string) => <StreamingMd t={t} text={text} />
|
||||
} as const
|
||||
|
||||
const [strategyArg, sizeArg] = process.argv.slice(2)
|
||||
|
||||
if (strategyArg === 'series') {
|
||||
// Series mode: per-append render times for one strategy/size, as JSON.
|
||||
// Usage: npx tsx scripts/bench-streaming-md.tsx series <strategy> <blocks>
|
||||
const [, seriesStrategy, seriesSize] = process.argv.slice(2)
|
||||
const size = Number(seriesSize)
|
||||
const updates = makeUpdates(makeBlocks(size, `${seriesStrategy}${size}`))
|
||||
const { elapsed, times } = bench(
|
||||
seriesStrategy!,
|
||||
updates,
|
||||
STRATEGIES[seriesStrategy as keyof typeof STRATEGIES],
|
||||
true
|
||||
)
|
||||
|
||||
console.log(JSON.stringify({ appends: updates.length, elapsed, times }))
|
||||
} else if (strategyArg) {
|
||||
// Child mode: run one strategy/size and print elapsed ms as JSON.
|
||||
const size = Number(sizeArg)
|
||||
const updates = makeUpdates(makeBlocks(size, `${strategyArg}${size}`))
|
||||
const { elapsed } = bench(strategyArg, updates, STRATEGIES[strategyArg as keyof typeof STRATEGIES])
|
||||
|
||||
console.log(JSON.stringify({ appends: updates.length, elapsed }))
|
||||
} else {
|
||||
// Orchestrator mode: one child process per strategy/size.
|
||||
const sizes = [32, 128, 512]
|
||||
|
||||
const run = (strategy: string, size: number): { appends: number; elapsed: number } => {
|
||||
const out = execFileSync('npx', ['tsx', 'scripts/bench-streaming-md.tsx', strategy, String(size)], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=8192' },
|
||||
timeout: 3_600_000
|
||||
})
|
||||
|
||||
return JSON.parse(out.trim().split('\n').at(-1)!)
|
||||
}
|
||||
|
||||
const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : `${ms.toFixed(1)} ms`)
|
||||
|
||||
console.log(
|
||||
'| Blocks | Append calls | naive (full Md) | monolithic prefix | per-block (new) | new vs naive | new vs monolithic |'
|
||||
)
|
||||
console.log(
|
||||
'|--------|--------------|-----------------|-------------------|-----------------|--------------|-------------------|'
|
||||
)
|
||||
|
||||
for (const size of sizes) {
|
||||
const naive = run('naive', size)
|
||||
const mono = run('monolithic', size)
|
||||
const perBlock = run('per-block', size)
|
||||
|
||||
console.log(
|
||||
`| ${size} | ${perBlock.appends} | ${fmt(naive.elapsed)} | ${fmt(mono.elapsed)} | ${fmt(perBlock.elapsed)} | ${(
|
||||
naive.elapsed / perBlock.elapsed
|
||||
).toFixed(1)}x | ${(mono.elapsed / perBlock.elapsed).toFixed(1)}x |`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,48 @@
|
|||
import { PassThrough } from 'stream'
|
||||
|
||||
import { Box, renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { findStableBoundary } from '../components/streamingMarkdown.js'
|
||||
// We test the pure boundary logic by rendering the component's ref
|
||||
// behaviour through repeated calls. Since React isn't being rendered here,
|
||||
// we reach into the module to test findStableBoundary via its exported
|
||||
// behaviour — but the pure helper isn't exported. So test the component's
|
||||
// observable output: pass sequential text values and verify the stable
|
||||
// prefix never retreats.
|
||||
//
|
||||
// Strategy: mount StreamingMd in isolation and observe which <Md>
|
||||
// instances it renders (by text prop). Without a DOM renderer that's
|
||||
// heavy, so we validate the helper behaviour by directly invoking the
|
||||
// fence/boundary logic via a re-exported surface.
|
||||
import { Md } from '../components/markdown.js'
|
||||
import { advanceScan, createScanState, findStableBoundary } from '../components/streamingMarkdown.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const BEL = String.fromCharCode(7)
|
||||
const ESC = String.fromCharCode(27)
|
||||
const CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
|
||||
const OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
|
||||
|
||||
const renderPlain = (node: React.ReactNode) => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(node, {
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
return output
|
||||
.replace(OSC_RE, '')
|
||||
.split('\n')
|
||||
.map(line => stripAnsi(line).replace(CSI_RE, '').trimEnd())
|
||||
}
|
||||
|
||||
describe('findStableBoundary', () => {
|
||||
it('returns -1 when no blank line exists yet', () => {
|
||||
expect(findStableBoundary('partial line with no newline yet')).toBe(-1)
|
||||
|
|
@ -111,11 +140,182 @@ describe('findStableBoundary', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('streaming theme assumption', () => {
|
||||
it('theme is exportable (component import sanity check)', () => {
|
||||
// Sanity that the theme we pass doesn't change shape. Component import
|
||||
// already happens above — this is a smoke test that the module graph
|
||||
// for streamingMarkdown wires up without cycles.
|
||||
expect(DEFAULT_THEME.color.accent).toBeTruthy()
|
||||
// A corpus exercising every construct the boundary scanner must respect:
|
||||
// paragraphs, fenced code (with blank lines and $$ bait inside), display
|
||||
// math ($$ and \[), setext headings, tables, lists, quotes, headings.
|
||||
const CORPUS = [
|
||||
'Intro paragraph explaining the plan in some detail.\n',
|
||||
'\nSection Title\n=============\n',
|
||||
'\nA paragraph before code.\n',
|
||||
'\n```ts\nconst a = 1\n\nconst b = 2\n// $$ not math $$\n```\n',
|
||||
'\nBetween-blocks narration.\n',
|
||||
'\n$$\nE = mc^2\n\n\\sum_i x_i\n$$\n',
|
||||
'\n- item one\n- item two\n\n1. first\n2. second\n',
|
||||
'\n| a | b |\n|---|---|\n| 1 | 2 |\n',
|
||||
'\n> quoted wisdom\n> second line\n',
|
||||
'\n\\[\nx^2 + y^2 = z^2\n\\]\n',
|
||||
'\n## Closing heading\n',
|
||||
'\nFinal paragraph without a trailing newline'
|
||||
].join('')
|
||||
|
||||
const mulberry32 = (seed: number) => () => {
|
||||
seed |= 0
|
||||
seed = (seed + 0x6d2b79f5) | 0
|
||||
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
||||
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
describe('advanceScan (incremental scanner)', () => {
|
||||
it('reconstructs the input exactly: blocks + tail === text', () => {
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan(CORPUS, state)
|
||||
|
||||
expect(state.blocks.join('')).toHaveLength(state.settledLen)
|
||||
expect(state.blocks.join('') + CORPUS.slice(state.settledLen)).toBe(CORPUS)
|
||||
expect(state.blocks.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it('produces identical blocks fed incrementally at arbitrary cut points', () => {
|
||||
const oneShot = createScanState()
|
||||
|
||||
advanceScan(CORPUS, oneShot)
|
||||
|
||||
for (let seed = 1; seed <= 8; seed++) {
|
||||
const rand = mulberry32(seed)
|
||||
const state = createScanState()
|
||||
|
||||
let pos = 0
|
||||
|
||||
while (pos < CORPUS.length) {
|
||||
pos = Math.min(CORPUS.length, pos + 1 + Math.floor(rand() * 7))
|
||||
|
||||
const prevBlocks = state.blocks.length
|
||||
|
||||
advanceScan(CORPUS.slice(0, pos), state)
|
||||
|
||||
// Append-only: previously committed blocks never change.
|
||||
expect(state.blocks.length).toBeGreaterThanOrEqual(prevBlocks)
|
||||
}
|
||||
|
||||
expect(state.blocks).toEqual(oneShot.blocks)
|
||||
expect(state.settledLen).toBe(oneShot.settledLen)
|
||||
}
|
||||
})
|
||||
|
||||
it('is idempotent when called again with the same text', () => {
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan(CORPUS, state)
|
||||
|
||||
const blocks = [...state.blocks]
|
||||
|
||||
advanceScan(CORPUS, state)
|
||||
|
||||
expect(state.blocks).toEqual(blocks)
|
||||
})
|
||||
|
||||
it('holds a partial trailing line in the tail even if it looks fence-like', () => {
|
||||
// "``" could grow into "```ts" — the scanner must not judge the line
|
||||
// until its newline arrives.
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan('para\n\n``', state)
|
||||
|
||||
expect(state.blocks).toEqual(['para\n\n'])
|
||||
expect(state.codeOpen).toBe(false)
|
||||
|
||||
advanceScan('para\n\n```ts\ncode\n\nstill code', state)
|
||||
|
||||
// The blank line inside the now-open fence must not commit a block.
|
||||
expect(state.blocks).toEqual(['para\n\n'])
|
||||
expect(state.codeOpen).toBe(true)
|
||||
|
||||
advanceScan('para\n\n```ts\ncode\n\nstill code\n```\n\nafter\n\n', state)
|
||||
|
||||
expect(state.blocks).toEqual(['para\n\n', '```ts\ncode\n\nstill code\n```\n\n', 'after\n\n'])
|
||||
expect(state.codeOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('does not commit whitespace-only blocks on 3+ newline runs', () => {
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan('alpha\n\n\n\nbeta\n\n', state)
|
||||
|
||||
expect(state.blocks).toEqual(['alpha\n\n', '\n\nbeta\n\n'])
|
||||
expect(state.blocks.join('')).toBe('alpha\n\n\n\nbeta\n\n')
|
||||
})
|
||||
|
||||
it('keeps a setext heading contiguous with its paragraph', () => {
|
||||
// A setext underline attaches to the line above; the only committed
|
||||
// boundary is the blank line, so the pair can never be torn apart or
|
||||
// retroactively merged (the desktop splitter needed a fix for this,
|
||||
// #67176 — blank-line boundaries are immune by construction).
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan('Title\n', state)
|
||||
advanceScan('Title\n====\n', state)
|
||||
advanceScan('Title\n====\n\nbody\n\n', state)
|
||||
|
||||
expect(state.blocks).toEqual(['Title\n====\n\n', 'body\n\n'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('StreamingMd rendering equivalence', () => {
|
||||
it('settled blocks + tail render identically to one combined Md', () => {
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan(CORPUS, state)
|
||||
|
||||
const tail = CORPUS.slice(state.settledLen)
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
const split = renderPlain(
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
...state.blocks.map((block, i) => React.createElement(Md, { key: i, t, text: block })),
|
||||
tail ? React.createElement(Md, { key: 'tail', t, text: tail }) : null
|
||||
)
|
||||
)
|
||||
|
||||
const combined = renderPlain(React.createElement(Md, { t, text: CORPUS }))
|
||||
|
||||
expect(split).toEqual(combined)
|
||||
})
|
||||
|
||||
it('renders split/combined identically at every streamed step', () => {
|
||||
const rand = mulberry32(42)
|
||||
const state = createScanState()
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
let pos = 0
|
||||
|
||||
while (pos < CORPUS.length) {
|
||||
pos = Math.min(CORPUS.length, pos + 24 + Math.floor(rand() * 200))
|
||||
|
||||
const text = CORPUS.slice(0, pos)
|
||||
|
||||
advanceScan(text, state)
|
||||
|
||||
const tail = text.slice(state.settledLen)
|
||||
|
||||
const split = renderPlain(
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
...state.blocks.map((block, i) => React.createElement(Md, { key: i, t, text: block })),
|
||||
tail ? React.createElement(Md, { key: 'tail', t, text: tail }) : null
|
||||
)
|
||||
)
|
||||
|
||||
const combined = renderPlain(React.createElement(Md, { t, text }))
|
||||
|
||||
expect(split).toEqual(combined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
// StreamingMd — incremental markdown renderer for in-flight assistant text.
|
||||
//
|
||||
// Naive approach (render <Md text={full}/>) re-tokenizes the entire message
|
||||
// on every stream delta. At 20-char batches over a 3 KB response that's 150
|
||||
// full re-parses.
|
||||
// Rendering <Md text={full}/> per delta re-tokenizes the whole message every
|
||||
// time (O(total) × deltas). The prior stable-prefix split fixed the per-delta
|
||||
// cost but not the per-block cliff: each advanced boundary re-tokenized the
|
||||
// entire prefix from scratch — O(blocks²) — plus an O(total) fence rescan.
|
||||
//
|
||||
// This splits `text` at the last stable top-level block boundary (blank
|
||||
// line outside a fenced code span) into:
|
||||
// stablePrefix — passed to an inner <Md>, memoized on its exact text
|
||||
// value. During the turn, the prefix only grows monotonically,
|
||||
// so its memo key matches the previous render and React
|
||||
// reuses the cached subtree — zero re-tokenization.
|
||||
// unstableSuffix — the in-flight block(s). A separate <Md> re-parses just
|
||||
// this tail on every delta (O(unstable length) vs.
|
||||
// O(total length)).
|
||||
// This is fully incremental. A forward scanner keeps fence/math state + scan
|
||||
// position in a ref across deltas, so each delta touches only newly-arrived
|
||||
// complete lines. Settled top-level blocks (split on "\n\n" outside a fence)
|
||||
// are frozen into an append-only array, each its own <Md> memoized on text
|
||||
// that never changes — every block tokenizes exactly once. Only the in-flight
|
||||
// tail re-parses per delta (O(tail)).
|
||||
//
|
||||
// The boundary is stored in a ref so it only advances — idempotent under
|
||||
// StrictMode double-render. Component unmounts between turns (isStreaming
|
||||
// flips off → message moves to history and renders via <Md> directly), so
|
||||
// the ref resets naturally.
|
||||
// Invariants that keep it correct:
|
||||
// · Only newline-terminated lines are scanned; a partial trailing line may
|
||||
// yet become a fence opener, so it stays in the tail.
|
||||
// · Blank-line boundaries can't be retroactively merged (a setext underline
|
||||
// only binds the contiguous line above it, never across a committed "\n\n").
|
||||
// · An unmatched `$$` / `\[` opener is treated as open forever — more
|
||||
// conservative than markdown.tsx's full-text fallback, because a committed
|
||||
// block is frozen and can't be un-decided once the closer streams in.
|
||||
// · State only advances (idempotent under StrictMode). The component
|
||||
// unmounts between turns; if `text` stops extending `scanned` (turn reuse,
|
||||
// or boundedLiveRenderText front-trimming a huge reply) the scanner resets
|
||||
// and <Md>'s LRU absorbs the re-parse.
|
||||
//
|
||||
// Layout: the two <Md> subtrees MUST render stacked (column). The parent
|
||||
// container in messageLine.tsx is a default `flexDirection: 'row'` Box
|
||||
// (Ink's default), so returning a bare Fragment of two <Md> siblings
|
||||
// laid them out side-by-side — producing the "two jumbled columns while
|
||||
// streaming" rendering bug. Wrapping in a flexDirection="column" Box
|
||||
// here localizes the fix to the streaming path; the non-streaming <Md>
|
||||
// already returns its own column Box, so its single-child case was never
|
||||
// affected.
|
||||
// Layout: the <Md> subtrees MUST stack in a column — the messageLine.tsx
|
||||
// parent is a default row Box, so bare siblings render side-by-side.
|
||||
|
||||
import { Box } from '@hermes/ink'
|
||||
import { memo, useRef } from 'react'
|
||||
|
|
@ -35,133 +35,125 @@ import type { Theme } from '../theme.js'
|
|||
|
||||
import { Md } from './markdown.js'
|
||||
|
||||
// Count ``` / ~~~ AND `$$` / `\[…\]` fence toggles in `s` up to `end`. Odd
|
||||
// = currently inside a fenced block; splitting the prefix there would
|
||||
// orphan the fence and let the unstable suffix re-render as broken
|
||||
// markdown. Math fences only toggle when the code fence is closed so
|
||||
// snippets like ` ```\n$$x$$\n``` ` (math example inside a code block)
|
||||
// don't double-count. A `$$x$$` line that opens AND closes on its own
|
||||
// produces zero net toggles; that's `len >= 4` plus `endsDollar`.
|
||||
//
|
||||
// NB: this is INTENTIONALLY more conservative than `markdown.tsx`'s
|
||||
// parser, which falls back to paragraph rendering when an `$$` opener
|
||||
// has no matching closer. The renderer can do that safely because it
|
||||
// always sees the full text on every call. The streaming chunker
|
||||
// cannot — once a chunk is committed to the monotonic stable prefix it
|
||||
// is frozen, so prematurely deciding "this `$$` is just prose" would
|
||||
// permanently commit a paragraph rendering that becomes wrong the
|
||||
// instant the closer streams in. Treating any unmatched `$$` opener
|
||||
// as still-open keeps the boundary parked behind it until the closer
|
||||
// arrives (or the stream ends and the non-streaming `<Md>` takes over,
|
||||
// at which point the renderer's fallback kicks in correctly).
|
||||
const fenceOpenAt = (s: string, end: number) => {
|
||||
let codeOpen = false
|
||||
let mathOpen = false
|
||||
let mathOpener: '$$' | '\\[' | null = null
|
||||
let i = 0
|
||||
export interface StreamScanState {
|
||||
/** Settled top-level block strings, in order. Append-only. */
|
||||
blocks: string[]
|
||||
/** Inside an unclosed ``` / ~~~ fence at the scan position. */
|
||||
codeOpen: boolean
|
||||
/** Non-null inside an unclosed display-math block at the scan position. */
|
||||
mathOpener: '$$' | '\\[' | null
|
||||
/** Prefix whose complete lines have been scanned (kept as text so the reset
|
||||
* guard can confirm the scanned region — and thus fence state — still holds). */
|
||||
scanned: string
|
||||
/** Length of the committed prefix (blocks.join('').length). */
|
||||
settledLen: number
|
||||
}
|
||||
|
||||
while (i < end) {
|
||||
const nl = s.indexOf('\n', i)
|
||||
const lineEnd = nl < 0 || nl > end ? end : nl
|
||||
const line = s.slice(i, lineEnd).trim()
|
||||
export const createScanState = (): StreamScanState => ({
|
||||
blocks: [],
|
||||
codeOpen: false,
|
||||
mathOpener: null,
|
||||
scanned: '',
|
||||
settledLen: 0
|
||||
})
|
||||
|
||||
if (/^(?:`{3,}|~{3,})/.test(line)) {
|
||||
codeOpen = !codeOpen
|
||||
} else if (!codeOpen) {
|
||||
if (!mathOpen && /^\$\$/.test(line)) {
|
||||
const isSingleLine = line.length >= 4 && /\$\$$/.test(line)
|
||||
// Fold one complete line into the fence/math state: ``` / ~~~ toggle the code
|
||||
// fence; `$$` / `\[` open display math unless the line also closes it; closers
|
||||
// count only against a pending opener; math inside an open code fence is inert.
|
||||
const applyLine = (state: StreamScanState, line: string) => {
|
||||
if (/^(?:`{3,}|~{3,})/.test(line)) {
|
||||
state.codeOpen = !state.codeOpen
|
||||
|
||||
if (!isSingleLine) {
|
||||
mathOpen = true
|
||||
mathOpener = '$$'
|
||||
}
|
||||
} else if (!mathOpen && /^\\\[/.test(line)) {
|
||||
const isSingleLine = /\\\]$/.test(line)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSingleLine) {
|
||||
mathOpen = true
|
||||
mathOpener = '\\['
|
||||
}
|
||||
} else if (mathOpen && mathOpener === '$$' && /\$\$$/.test(line)) {
|
||||
mathOpen = false
|
||||
mathOpener = null
|
||||
} else if (mathOpen && mathOpener === '\\[' && /\\\]$/.test(line)) {
|
||||
mathOpen = false
|
||||
mathOpener = null
|
||||
}
|
||||
if (state.codeOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!state.mathOpener) {
|
||||
if (/^\$\$/.test(line) && !(line.length >= 4 && /\$\$$/.test(line))) {
|
||||
state.mathOpener = '$$'
|
||||
} else if (/^\\\[/.test(line) && !/\\\]$/.test(line)) {
|
||||
state.mathOpener = '\\['
|
||||
}
|
||||
} else if (state.mathOpener === '$$' && /\$\$$/.test(line)) {
|
||||
state.mathOpener = null
|
||||
} else if (state.mathOpener === '\\[' && /\\\]$/.test(line)) {
|
||||
state.mathOpener = null
|
||||
}
|
||||
}
|
||||
|
||||
// Consume newly-arrived complete lines, committing a settled block at every
|
||||
// "\n\n" outside a fence. Whitespace-only runs stay with the next block, never
|
||||
// committed as empty <Md>s. Mutates `state`; re-calling with the same text is
|
||||
// a no-op (idempotent).
|
||||
export const advanceScan = (text: string, state: StreamScanState) => {
|
||||
const start = state.scanned.length
|
||||
|
||||
let i = start
|
||||
|
||||
while (i < text.length) {
|
||||
const nl = text.indexOf('\n', i)
|
||||
|
||||
if (nl < 0) {
|
||||
break // partial trailing line — could still open a fence; keep in tail
|
||||
}
|
||||
|
||||
if (nl < 0 || nl >= end) {
|
||||
break
|
||||
if (nl === i) {
|
||||
// Second half of a "\n\n" outside any fence → prior text is a block.
|
||||
if (i > 0 && !state.codeOpen && !state.mathOpener) {
|
||||
const block = text.slice(state.settledLen, nl + 1)
|
||||
|
||||
if (/\S/.test(block)) {
|
||||
state.blocks.push(block)
|
||||
state.settledLen = nl + 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
applyLine(state, text.slice(i, nl).trim())
|
||||
}
|
||||
|
||||
i = nl + 1
|
||||
}
|
||||
|
||||
return codeOpen || mathOpen
|
||||
if (i > start) {
|
||||
state.scanned += text.slice(start, i)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the last "\n\n" boundary before `end` that is OUTSIDE a fenced code
|
||||
// block. Returns the index AFTER the second newline (start of the next
|
||||
// block), or -1 if no safe boundary exists yet.
|
||||
// Index just past the last committed boundary, or -1 if nothing has settled.
|
||||
// Thin wrapper over the scanner for boundary-semantics tests.
|
||||
export const findStableBoundary = (text: string) => {
|
||||
let idx = text.length
|
||||
const state = createScanState()
|
||||
|
||||
while (idx > 0) {
|
||||
const boundary = text.lastIndexOf('\n\n', idx - 1)
|
||||
advanceScan(text, state)
|
||||
|
||||
if (boundary < 0) {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Boundary candidate: end of stable prefix is boundary + 2 (start of
|
||||
// next block). Check fence balance up to that point.
|
||||
const splitAt = boundary + 2
|
||||
|
||||
if (!fenceOpenAt(text, splitAt)) {
|
||||
return splitAt
|
||||
}
|
||||
|
||||
idx = boundary
|
||||
}
|
||||
|
||||
return -1
|
||||
return state.settledLen > 0 ? state.settledLen : -1
|
||||
}
|
||||
|
||||
export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }: StreamingMdProps) {
|
||||
const stablePrefixRef = useRef('')
|
||||
const scanRef = useRef<StreamScanState>(createScanState())
|
||||
|
||||
// Reset if the text no longer starts with our recorded prefix (defensive;
|
||||
// normally the component unmounts between turns so this shouldn't trigger).
|
||||
if (!text.startsWith(stablePrefixRef.current)) {
|
||||
stablePrefixRef.current = ''
|
||||
let state = scanRef.current
|
||||
|
||||
// Reset if `text` no longer extends the scanned prefix (turn reuse, or a
|
||||
// front-trim), which would invalidate the persisted fence state.
|
||||
if (!text.startsWith(state.scanned)) {
|
||||
state = scanRef.current = createScanState()
|
||||
}
|
||||
|
||||
const boundary = findStableBoundary(text)
|
||||
advanceScan(text, state)
|
||||
|
||||
// Only advance the prefix — never retreat. The boundary math looks at the
|
||||
// FULL text each call; if it returns a larger index than before, we grow
|
||||
// the cached prefix. Monotonic growth makes the memo key stable across
|
||||
// deltas (identical string → same <Md> subtree → no re-render).
|
||||
if (boundary > stablePrefixRef.current.length) {
|
||||
stablePrefixRef.current = text.slice(0, boundary)
|
||||
}
|
||||
|
||||
const stablePrefix = stablePrefixRef.current
|
||||
const unstableSuffix = text.slice(stablePrefix.length)
|
||||
|
||||
if (!stablePrefix) {
|
||||
return <Md cols={cols} compact={compact} t={t} text={unstableSuffix} />
|
||||
}
|
||||
|
||||
if (!unstableSuffix) {
|
||||
return <Md cols={cols} compact={compact} t={t} text={stablePrefix} />
|
||||
}
|
||||
const tail = text.slice(state.settledLen)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Md cols={cols} compact={compact} t={t} text={stablePrefix} />
|
||||
<Md cols={cols} compact={compact} t={t} text={unstableSuffix} />
|
||||
{state.blocks.map((block, i) => (
|
||||
<Md cols={cols} compact={compact} key={i} t={t} text={block} />
|
||||
))}
|
||||
|
||||
{tail ? <Md cols={cols} compact={compact} t={t} text={tail} /> : null}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue