mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The chatFileMentions service (provided by ui-deliverables beside its turn-tail entry, reached via ctx.get) resolves inline-code tokens in the closing message against the turn's produced locations: exact path or unique basename links, ambiguity and unknowns stay inert. MarkdownText gains the optional fileMentions seam — settled renders only, never inside anchors.
177 lines
7.5 KiB
TypeScript
177 lines
7.5 KiB
TypeScript
/**
|
|
* 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, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
|
|
import 'katex/dist/katex.min.css'
|
|
import css from './MarkdownText.module.css'
|
|
|
|
export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx'
|
|
|
|
/** One settled full render: parse with math, resolve references, append the footnote section. */
|
|
function renderSettled(
|
|
text: string,
|
|
codeLabels: MarkdownCodeLabels | undefined,
|
|
fileMentions: MarkdownFileMentions | undefined,
|
|
): ReactNode[] {
|
|
const root = parseGfmWithMath(text)
|
|
const targets = createReferenceTargets()
|
|
collectReferenceTargets(root.children, targets)
|
|
const context: MarkdownRenderContext = {
|
|
streaming: false,
|
|
codeLabels,
|
|
fileMentions,
|
|
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()
|
|
}
|
|
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,
|
|
fileMentions: undefined,
|
|
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,
|
|
fileMentions: undefined,
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) 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. `fileMentions`
|
|
* links inline-code tokens its resolver recognizes as real files; this is
|
|
* the single streaming gate — it applies to settled renders only, because a
|
|
* streaming message's vocabulary is not final and frozen cached elements
|
|
* must not bake in handlers that could go stale.
|
|
* @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 const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: {
|
|
text: string
|
|
streaming?: boolean
|
|
codeLabels?: MarkdownCodeLabels | undefined
|
|
fileMentions?: MarkdownFileMentions | undefined
|
|
}) {
|
|
const streamRef = useRef<StreamingRenderer | null>(null)
|
|
const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
|
|
const children = useMemo(() => {
|
|
if (!streaming) {
|
|
streamRef.current = null
|
|
return renderSettled(text, codeLabels, fileMentions)
|
|
}
|
|
if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
|
|
streamRef.current = new StreamingRenderer(codeLabels)
|
|
streamLabelsRef.current = codeLabels
|
|
}
|
|
return streamRef.current.render(text)
|
|
}, [text, streaming, codeLabels, fileMentions])
|
|
return <div className={css.markdown}>{children}</div>
|
|
})
|