feat: markdown 增量解析
This commit is contained in:
@@ -1,155 +1,164 @@
|
||||
import { isValidElement, useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components, UrlTransform } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { CodeBlock } from './CodeBlock.tsx'
|
||||
import { remarkMathCompatibility } from './remarkMathCompatibility.ts'
|
||||
/**
|
||||
* Untrusted assistant-Markdown renderer over the direct mdast pipeline:
|
||||
* `parse.ts` grammars, the incremental streaming parser, and `render.tsx`.
|
||||
* While a message streams, all but the trailing two blocks freeze as cached
|
||||
* React elements and only the source tail behind them re-parses per chunk,
|
||||
* so per-chunk work tracks the tail size instead of the whole reply. Frozen
|
||||
* blocks keep their source-offset keys when they cross the freeze boundary,
|
||||
* so React reconciles instead of remounting. Known deviation while
|
||||
* streaming: a reference-style link or footnote whose definition sits on the
|
||||
* other side of the freeze boundary renders literally until the settled
|
||||
* full parse self-heals it.
|
||||
*/
|
||||
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { IncrementalMarkdownParser } from './incremental.ts'
|
||||
import { parseGfm, parseGfmWithMath } from './parse.ts'
|
||||
import {
|
||||
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
|
||||
wrapBlockChildren,
|
||||
} from './render.tsx'
|
||||
import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
const streamingRemarkPlugins = [remarkGfm]
|
||||
const settledRemarkPlugins = [
|
||||
remarkGfm,
|
||||
remarkMathCompatibility,
|
||||
remarkMath,
|
||||
]
|
||||
const settledRehypePlugins = [rehypeKatex]
|
||||
export type { MarkdownCodeLabels } from './render.tsx'
|
||||
|
||||
function sanitizeUrl(url: string): string {
|
||||
try {
|
||||
switch (new URL(url).protocol) {
|
||||
case 'http:':
|
||||
case 'https:':
|
||||
case 'mailto:':
|
||||
return url
|
||||
default:
|
||||
return ''
|
||||
/** One settled full render: parse with math, resolve references, append the footnote section. */
|
||||
function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] {
|
||||
const root = parseGfmWithMath(text)
|
||||
const targets = createReferenceTargets()
|
||||
collectReferenceTargets(root.children, targets)
|
||||
const context: MarkdownRenderContext = {
|
||||
streaming: false,
|
||||
codeLabels,
|
||||
targets,
|
||||
footnoteOrder: [],
|
||||
footnoteCounts: new Map(),
|
||||
}
|
||||
const blocks = wrapBlockChildren(
|
||||
renderBlocks(root.children.map((node, index) => ({ node, key: index })), context),
|
||||
false,
|
||||
)
|
||||
const section = renderFootnoteSection(context)
|
||||
return section === null ? blocks : [...blocks, '\n', section]
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming render state for one growing message: the incremental parser,
|
||||
* the frozen blocks' cached elements, and the reference/footnote state their
|
||||
* rendering consumed (footnote numbering assigned to frozen references is
|
||||
* final, so the tail continues from a copy of it each frame).
|
||||
*/
|
||||
class StreamingRenderer {
|
||||
private readonly parser = new IncrementalMarkdownParser(parseGfm)
|
||||
private generation = -1
|
||||
private frozenCount = 0
|
||||
private frozenElements: ReactNode[] = []
|
||||
private frozenTargets: ReferenceTargets = createReferenceTargets()
|
||||
private frozenFootnoteOrder: string[] = []
|
||||
private frozenFootnoteCounts = new Map<string, number>()
|
||||
private lastText: string | null = null
|
||||
private lastRendered: ReactNode[] = []
|
||||
|
||||
/** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */
|
||||
constructor(private readonly codeLabels: MarkdownCodeLabels | undefined) {}
|
||||
|
||||
/**
|
||||
* Render the current accumulated text. Idempotent per text value, so React
|
||||
* may re-execute the calling render freely.
|
||||
* @param text - The full accumulated markdown source.
|
||||
* @returns Frozen elements, re-rendered tail, and the footnote section.
|
||||
*/
|
||||
render(text: string): ReactNode[] {
|
||||
if (text === this.lastText) return this.lastRendered
|
||||
const { frozen, tail, generation } = this.parser.update(text)
|
||||
if (generation !== this.generation) {
|
||||
this.generation = generation
|
||||
this.frozenCount = 0
|
||||
this.frozenElements = []
|
||||
this.frozenTargets = createReferenceTargets()
|
||||
this.frozenFootnoteOrder = []
|
||||
this.frozenFootnoteCounts = new Map()
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
const newlyFrozen = frozen.slice(this.frozenCount)
|
||||
collectReferenceTargets(newlyFrozen.map(block => block.node), this.frozenTargets)
|
||||
// Targets visible this frame: everything frozen so far plus the current
|
||||
// tail parse — a newly frozen block's references resolved against the
|
||||
// same parse tree its definitions came from.
|
||||
const frameTargets: ReferenceTargets = {
|
||||
definitions: new Map(this.frozenTargets.definitions),
|
||||
footnotes: new Map(this.frozenTargets.footnotes),
|
||||
}
|
||||
collectReferenceTargets(tail.map(block => block.node), frameTargets)
|
||||
if (newlyFrozen.length > 0) {
|
||||
const frozenContext: MarkdownRenderContext = {
|
||||
streaming: true,
|
||||
codeLabels: this.codeLabels,
|
||||
targets: frameTargets,
|
||||
footnoteOrder: this.frozenFootnoteOrder,
|
||||
footnoteCounts: this.frozenFootnoteCounts,
|
||||
}
|
||||
// Separator newlines are cached alongside the elements so the
|
||||
// assembled children match the settled pipeline's block wrapping.
|
||||
const batch = [...this.frozenElements]
|
||||
for (const element of renderBlocks(newlyFrozen, frozenContext)) {
|
||||
if (batch.length > 0) batch.push('\n')
|
||||
batch.push(element)
|
||||
}
|
||||
this.frozenElements = batch
|
||||
this.frozenCount = frozen.length
|
||||
}
|
||||
const tailContext: MarkdownRenderContext = {
|
||||
streaming: true,
|
||||
codeLabels: this.codeLabels,
|
||||
targets: frameTargets,
|
||||
footnoteOrder: [...this.frozenFootnoteOrder],
|
||||
footnoteCounts: new Map(this.frozenFootnoteCounts),
|
||||
}
|
||||
const children = [...this.frozenElements]
|
||||
for (const element of renderBlocks(tail, tailContext)) {
|
||||
if (children.length > 0) children.push('\n')
|
||||
children.push(element)
|
||||
}
|
||||
const section = renderFootnoteSection(tailContext)
|
||||
if (section !== null) children.push('\n', section)
|
||||
this.lastText = text
|
||||
this.lastRendered = children
|
||||
return this.lastRendered
|
||||
}
|
||||
}
|
||||
|
||||
const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
|
||||
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
|
||||
export interface MarkdownCodeLabels {
|
||||
/** Copy-button idle label. */
|
||||
copyLabel?: string | undefined
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copiedLabel?: string | undefined
|
||||
}
|
||||
|
||||
function remoteImageUrl(url: string): string | undefined {
|
||||
try {
|
||||
const protocol = new URL(url).protocol
|
||||
return protocol === 'http:' || protocol === 'https:' ? url : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
|
||||
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
|
||||
return {
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
if (safeHref === '') return <>{children}</>
|
||||
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ alt = '', src = '' }) => {
|
||||
const imageSrc = remoteImageUrl(src)
|
||||
if (imageSrc === undefined) return <span className={css.imageAlt}>{alt}</span>
|
||||
return (
|
||||
<img
|
||||
className={css.image}
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className={css.tableScroll}>
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
// Fenced blocks route through the shared CodeBlock (shiki for registered
|
||||
// grammars, identical-geometry plain fallback for unknown/absent
|
||||
// languages); inline code keeps the default <code> path (the :not(pre)
|
||||
// rule styles it). While the message streams, the fence renders the
|
||||
// plain arm — retokenizing a growing fence on every chunk is quadratic
|
||||
// main-thread work; the finalize swap highlights it once.
|
||||
pre: ({ children }) => {
|
||||
// The markdown pipeline always hands `pre` its single `code` element;
|
||||
// the undefined arm guards a react-markdown representation change.
|
||||
/* v8 ignore next 2 */
|
||||
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
|
||||
const raw = child?.props.children
|
||||
// A fence whose content isn't one plain string (e.g. an empty fence)
|
||||
// keeps the stock <pre> rather than guessing.
|
||||
if (typeof raw !== 'string') return <pre>{children}</pre>
|
||||
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
|
||||
return (
|
||||
<CodeBlock
|
||||
code={raw}
|
||||
lang={streaming ? undefined : lang}
|
||||
copyLabel={codeLabels?.copyLabel}
|
||||
copiedLabel={codeLabels?.copiedLabel}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const staticComponents = buildComponents(false)
|
||||
const streamingComponents = buildComponents(true)
|
||||
|
||||
/**
|
||||
* Render untrusted assistant-authored Markdown as semantic React elements.
|
||||
* @param props - Markdown source text preserved by the session projection;
|
||||
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap);
|
||||
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
|
||||
* pass a reference-stable object (memoized per locale revision), because the
|
||||
* component table memoizes on its identity and a fresh literal per render
|
||||
* would rebuild it every streaming chunk.
|
||||
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on
|
||||
* the finalize swap) and parses incrementally across chunks; `codeLabels`
|
||||
* forwards localized copy-button labels to fence CodeBlocks — pass a
|
||||
* reference-stable object (memoized per locale revision), because a new
|
||||
* identity discards the streaming render cache mid-message.
|
||||
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
|
||||
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
|
||||
* images render directly.
|
||||
*/
|
||||
export function MarkdownText({ text, streaming = false, codeLabels }: {
|
||||
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: {
|
||||
text: string
|
||||
streaming?: boolean
|
||||
codeLabels?: MarkdownCodeLabels | undefined
|
||||
}) {
|
||||
// The label-free tables stay module-level singletons so the common case
|
||||
// keeps referential stability across renders without a hook.
|
||||
const components = useMemo(() => {
|
||||
if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents
|
||||
return buildComponents(streaming, codeLabels)
|
||||
}, [streaming, codeLabels])
|
||||
return (
|
||||
<div className={css.markdown}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={streaming ? streamingRemarkPlugins : settledRemarkPlugins}
|
||||
rehypePlugins={streaming ? undefined : settledRehypePlugins}
|
||||
components={components}
|
||||
urlTransform={safeUrl}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const streamRef = useRef<StreamingRenderer | null>(null)
|
||||
const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
|
||||
const children = useMemo(() => {
|
||||
if (!streaming) {
|
||||
streamRef.current = null
|
||||
return renderSettled(text, codeLabels)
|
||||
}
|
||||
if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
|
||||
streamRef.current = new StreamingRenderer(codeLabels)
|
||||
streamLabelsRef.current = codeLabels
|
||||
}
|
||||
return streamRef.current.render(text)
|
||||
}, [text, streaming, codeLabels])
|
||||
return <div className={css.markdown}>{children}</div>
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user