-
{children}>;
- }
- if (sandboxLink.kind === 'artifact') {
- return renderArtifactChip(sandboxLink.artifact, children);
- }
- if (href?.startsWith('#cite-')) {
- const num = href.replace('#cite-', '');
- const sourceIdx = parseInt(num, 10) - 1;
- return (
-
- );
- }
- return (
-
- {children}
-
- );
- },
- img({ src, alt }) {
- // `` is how a model
- // announces a plot it produced. react-markdown runs
- // urlTransform over `src` too, so without this the invented
- // scheme survives and renders a broken-image box beside the
- // chip that opens the very same file.
- const source = typeof src === 'string' ? src : undefined;
- const sandboxLink = resolveSandboxLink(
- source,
- artifacts,
- turnArtifacts,
- );
- if (sandboxLink.kind === 'artifact') {
- const { artifact } = sandboxLink;
- return renderArtifactChip(
- artifact,
- alt || artifact.label || t('conversation.openFile'),
- );
- }
- if (sandboxLink.kind === 'plain') {
- return <>{alt ?? ''}>;
- }
- return (
-
- );
- },
- code(props) {
- const { children, className, node, ref, ...rest } = props;
- const match = /language-(\w+)/.exec(className || '');
- const language = match ? match[1] : '';
-
- return match ? (
-
-
-
- {language}
-
-
-
-
- {String(children).replace(/\n$/, '')}
-
-
- ) : (
-
- {children}
-
- );
- },
- ul({ children }) {
- return (
-
- );
- },
- ol({ children }) {
- return (
-
- {children}
-
- );
- },
- table({ children }) {
- return (
-
- );
- },
- thead({ children }) {
- return (
-
- {children}
-
- );
- },
- tr({ children }) {
- return (
-
- {children}
-
- );
- },
- th({ children }) {
- return {children} | ;
- },
- td({ children }) {
- return {children} | ;
- },
- }}
- >
- {segment.content}
-
+ {group.type === 'markdown' ? (
+
+ {group.blocks.map((block, blockIndex) => (
+
+ ))}
) : (
-
+
)}
diff --git a/frontend/src/conversation/markdown/answerMarkdown.ts b/frontend/src/conversation/markdown/answerMarkdown.ts
new file mode 100644
index 00000000..6994aec2
--- /dev/null
+++ b/frontend/src/conversation/markdown/answerMarkdown.ts
@@ -0,0 +1,241 @@
+/**
+ * The string-level steps of rendering an answer, around the markdown parser.
+ *
+ * Math, code and citations are told apart by the parser (see `texMath.ts`,
+ * `singleDollarMath.ts` and the remark plugins beside them). What is left
+ * here works on the raw string only where the parser cannot help:
+ *
+ * - `normalizeMathFences` moves formula text off a ``$$`` fence line, which
+ * remark-math would read as the fence's metadata;
+ * - `healStreamingMarkdown` closes what a half-streamed answer has left open;
+ * - `splitAnswerBlocks` cuts the answer into its top-level blocks, so a
+ * streaming answer re-renders only the block that is still growing.
+ */
+import type { Root, RootContent } from 'mdast';
+import remarkGfm from 'remark-gfm';
+import remarkMath from 'remark-math';
+import remarkParse from 'remark-parse';
+import remend from 'remend';
+import { type PluggableList, unified } from 'unified';
+
+import { remarkSingleDollarMath } from './singleDollarMath';
+import { remarkTexMath } from './texMath';
+
+/** Syntax plugins; every parse of an answer, split or render, uses them. */
+export const answerSyntaxPlugins: PluggableList = [
+ remarkGfm,
+ [remarkMath, { singleDollarTextMath: false }],
+ remarkTexMath,
+ remarkSingleDollarMath,
+];
+
+// A line's container prefix (blockquote markers, indentation) and the rest.
+const LINE = /^((?:[ \t]*>)*[ \t]*)([\s\S]*)$/;
+const FENCE_OPEN = /^(`{3,}|~{3,})/;
+const DOLLAR_FENCE = /^\$\$\s*$/;
+
+function splitLine(line: string): [prefix: string, rest: string] {
+ const [, prefix, rest] = LINE.exec(line) ?? ['', '', line];
+ return [prefix, rest];
+}
+
+function closesFence(rest: string, fence: string): boolean {
+ const run = rest.trim();
+ return (
+ run.length >= fence.length && run[0] === fence[0] && /^(?:`+|~+)$/.test(run)
+ );
+}
+
+/**
+ * Puts a ``$$`` fence's formula on its own line: ``$$\begin{aligned}`` opens a
+ * block whose first line remark-math reads as metadata and drops, and a
+ * closing ``\end{aligned}$$`` never closes it, so the block ran to the end of
+ * the answer. Code fences are left alone.
+ */
+export function normalizeMathFences(content: string): string {
+ if (!content.includes('$$')) return content;
+ const out: string[] = [];
+ let fence: string | null = null;
+ let inMath = false;
+
+ for (const line of content.split('\n')) {
+ const [prefix, rest] = splitLine(line);
+ if (fence) {
+ if (closesFence(rest, fence)) fence = null;
+ out.push(line);
+ continue;
+ }
+ if (inMath) {
+ const closing = /^(.*\S)[ \t]*\$\$\s*$/.exec(rest);
+ if (DOLLAR_FENCE.test(rest)) {
+ inMath = false;
+ } else if (closing) {
+ out.push(prefix + closing[1], `${prefix}$$`);
+ inMath = false;
+ continue;
+ }
+ out.push(line);
+ continue;
+ }
+ const codeFence = FENCE_OPEN.exec(rest);
+ const opening = /^\$\$(?!\$)(.*\S)\s*$/.exec(rest);
+ if (codeFence) {
+ fence = codeFence[1];
+ } else if (DOLLAR_FENCE.test(rest)) {
+ inMath = true;
+ } else if (opening && !opening[1].includes('$$')) {
+ out.push(`${prefix}$$`, prefix + opening[1].trimStart());
+ inMath = true;
+ continue;
+ }
+ out.push(line);
+ }
+ return out.join('\n');
+}
+
+// Inline code in a stretch of prose, open spans included, so a `$$` in it is
+// not counted as math.
+const INLINE_CODE = /(`+)[\s\S]*?(?:\1|$)/g;
+
+// Splits a half-streamed answer at the math it is in the middle of writing:
+// `head` is everything before that formula, `math` the formula closed. A ``$$``
+// or line-start ``\[`` block gets its closing fence, an inline ``$$`` its
+// closing ``$$``, and one with nothing after it yet is held back. Inside a code
+// fence nothing is touched: a shell's `$$` is not math.
+function splitOpenMath(content: string): { head: string; math: string } {
+ let fence: string | null = null;
+ let block: { close: string; prefix: string; start: number } | null = null;
+ let offset = 0;
+
+ for (const line of content.split('\n')) {
+ const [prefix, rest] = splitLine(line);
+ if (fence) {
+ if (closesFence(rest, fence)) fence = null;
+ } else if (block) {
+ const closed =
+ block.close === '$$' ? DOLLAR_FENCE.test(rest) : /\\\]\s*$/.test(rest);
+ if (closed) block = null;
+ } else if (FENCE_OPEN.test(rest)) {
+ fence = FENCE_OPEN.exec(rest)![1];
+ } else if (DOLLAR_FENCE.test(rest)) {
+ block = { close: '$$', prefix, start: offset };
+ } else if (rest.startsWith('\\[') && !rest.includes('\\]')) {
+ block = { close: '\\]', prefix, start: offset };
+ }
+ offset += line.length + 1;
+ }
+ if (fence) return { head: content, math: '' };
+ if (block) {
+ return {
+ head: content.slice(0, block.start),
+ math: `${content.slice(block.start)}\n${block.prefix}${block.close}`,
+ };
+ }
+
+ const paragraphStart = content.lastIndexOf('\n\n') + 1;
+ const prose = content
+ .slice(paragraphStart)
+ .replace(INLINE_CODE, '')
+ .replace(/\\\$/g, '');
+ const dollars = prose.match(/\$\$/g)?.length ?? 0;
+ if (dollars % 2 === 0) return { head: content, math: '' };
+ const opener = content.lastIndexOf('$$');
+ const formula = content.slice(opener);
+ return {
+ head: content.slice(0, opener),
+ math: formula.slice(2).trim() ? `${formula}$$` : '',
+ };
+}
+
+const REMEND_OPTIONS = {
+ // Math is closed above, code-aware; remend's own pass appends `$$` inside
+ // an open code fence.
+ katex: false,
+ inlineKatex: false,
+ // A half-typed `[1` or `\[` is not a link, and remend would strip its `[`.
+ links: false,
+ images: false,
+ // These two rewrite text that is already complete, so the answer would
+ // change once it stops streaming.
+ comparisonOperators: false,
+ singleTilde: false,
+};
+
+/**
+ * Closes what a half-streamed answer leaves open (``**bold``, `` `code``,
+ * ``$$`` and ``\[`` math), so it renders as it will once complete instead of
+ * flashing raw markdown. Only ever applied while the answer streams.
+ */
+export function healStreamingMarkdown(content: string): string {
+ // Emphasis is closed before the open formula, never after it: appended to
+ // the closing fence (``$$**``), it would stop the fence from closing.
+ const { head, math } = splitOpenMath(content);
+ if (!math) return remend(head, REMEND_OPTIONS);
+ const lineBreak = head.match(/\n*$/)![0];
+ return (
+ remend(head.slice(0, head.length - lineBreak.length), REMEND_OPTIONS) +
+ lineBreak +
+ math
+ );
+}
+
+export type AnswerBlock =
+ { type: 'markdown'; content: string } | { type: 'mermaid'; content: string };
+
+const parser = unified().use(remarkParse).use(answerSyntaxPlugins);
+
+// Where a block's first line starts, so an indented block keeps its indent.
+function lineStart(content: string, offset: number): number {
+ const start = content.lastIndexOf('\n', offset - 1) + 1;
+ return /^[ \t]*$/.test(content.slice(start, offset)) ? start : offset;
+}
+
+// A mermaid fence is drawn once it is closed; until then it streams as code.
+function isDiagram(node: RootContent, source: string): boolean {
+ if (node.type !== 'code' || node.lang !== 'mermaid') return false;
+ const lines = source.trimEnd().split('\n');
+ const opening = FENCE_OPEN.exec(lines[0].trim());
+ return (
+ lines.length > 1 &&
+ opening !== null &&
+ closesFence(lines[lines.length - 1], opening[1])
+ );
+}
+
+/**
+ * Cuts an answer into its top-level blocks, each rendered on its own, and its
+ * closed mermaid fences, drawn as diagrams. An answer with link or footnote
+ * definitions stays whole between diagrams, since a reference resolves only
+ * against definitions in the same parse.
+ */
+export function splitAnswerBlocks(content: string): AnswerBlock[] {
+ const tree: Root = parser.parse(content);
+ const keepWhole = tree.children.some(
+ (node) => node.type === 'definition' || node.type === 'footnoteDefinition',
+ );
+ const blocks: AnswerBlock[] = [];
+ let groupStart: number | null = null;
+
+ for (const node of tree.children) {
+ const start = lineStart(content, node.position?.start.offset ?? 0);
+ const end = node.position?.end.offset ?? content.length;
+ if (node.type === 'code' && isDiagram(node, content.slice(start, end))) {
+ if (groupStart !== null) {
+ blocks.push({
+ type: 'markdown',
+ content: content.slice(groupStart, start),
+ });
+ groupStart = null;
+ }
+ blocks.push({ type: 'mermaid', content: node.value.trim() });
+ } else if (keepWhole) {
+ groupStart ??= start;
+ } else {
+ blocks.push({ type: 'markdown', content: content.slice(start, end) });
+ }
+ }
+ if (groupStart !== null) {
+ blocks.push({ type: 'markdown', content: content.slice(groupStart) });
+ }
+ return blocks;
+}
diff --git a/frontend/src/conversation/markdown/micromarkExtension.ts b/frontend/src/conversation/markdown/micromarkExtension.ts
new file mode 100644
index 00000000..471039c2
--- /dev/null
+++ b/frontend/src/conversation/markdown/micromarkExtension.ts
@@ -0,0 +1,25 @@
+import type { Extension } from 'micromark-util-types';
+import type { Processor } from 'unified';
+
+// The tokens `remark-math`'s `mathFromMarkdown` turns into math nodes, which
+// the constructs here emit too.
+declare module 'micromark-util-types' {
+ interface TokenTypeMap {
+ mathFlow: 'mathFlow';
+ mathFlowFence: 'mathFlowFence';
+ mathFlowFenceSequence: 'mathFlowFenceSequence';
+ mathFlowValue: 'mathFlowValue';
+ mathText: 'mathText';
+ mathTextData: 'mathTextData';
+ mathTextSequence: 'mathTextSequence';
+ }
+}
+
+/** Registers a micromark syntax extension from inside a remark plugin. */
+export function addMicromarkExtension(
+ processor: Processor,
+ extension: Extension,
+): void {
+ const data = processor.data() as { micromarkExtensions?: Extension[] };
+ (data.micromarkExtensions ??= []).push(extension);
+}
diff --git a/frontend/src/conversation/markdown/remarkCitations.ts b/frontend/src/conversation/markdown/remarkCitations.ts
new file mode 100644
index 00000000..2c5e70e7
--- /dev/null
+++ b/frontend/src/conversation/markdown/remarkCitations.ts
@@ -0,0 +1,100 @@
+/**
+ * Links source citations, ``[N]``, in the prose of an answer.
+ *
+ * Runs on the syntax tree, so only plain text is ever linked: code, inline
+ * code and math are their own nodes, never `text`, and link text is skipped.
+ * An index like `row[0]` in code, or `a[1]` in a formula, stays what it is.
+ * A bare ``[1]`` with no matching definition is text to the parser; a
+ * reference link ``[docs][1]`` with a ``[1]: url`` definition stays a link,
+ * and brackets the answer escaped, ``arr\[1\]``, stay brackets.
+ *
+ * The link targets ``#cite-N``, which `MarkdownAnswer` renders as the "jump to
+ * source" pill.
+ */
+import type { PhrasingContent, Root, Text } from 'mdast';
+import { asciiPunctuation } from 'micromark-util-character';
+import { SKIP, visit } from 'unist-util-visit';
+
+export type CitationOptions = {
+ /**
+ * How many sources the answer has; only ``[1]`` to ``[sourceCount]`` are
+ * linked, since any other number has no source to jump to. Unset links
+ * every ``[N]``.
+ */
+ sourceCount?: number;
+};
+
+const CITATION = /\[(\d+)\]/g;
+
+// The offsets in `node.value` of characters the source wrote as a backslash
+// escape. The parser resolves escapes, so ``\[1\]`` and ``[1]`` are the same
+// text; only the source tells them apart. Anything else that makes the value
+// differ from the source (an entity, a quote marker) ends the walk, and the
+// rest of the node counts as unescaped.
+function escapedOffsets(node: Text, source: string): Set
{
+ const escaped = new Set();
+ const start = node.position?.start.offset;
+ const end = node.position?.end.offset;
+ if (start === undefined || end === undefined) return escaped;
+ const raw = source.slice(start, end);
+ if (!raw.includes('\\')) return escaped;
+
+ const { value } = node;
+ let i = 0;
+ for (let j = 0; j < value.length && i < raw.length; j++) {
+ // Indentation the parser dropped from a continuation line.
+ while (raw[i] !== value[j] && (raw[i] === ' ' || raw[i] === '\t')) i++;
+ if (
+ raw[i] === '\\' &&
+ raw[i + 1] === value[j] &&
+ asciiPunctuation(value.charCodeAt(j))
+ ) {
+ escaped.add(j);
+ i += 2;
+ } else if (raw[i] === value[j]) {
+ i++;
+ } else {
+ break;
+ }
+ }
+ return escaped;
+}
+
+export function remarkCitations({ sourceCount }: CitationOptions = {}) {
+ const citable = (n: number) =>
+ sourceCount === undefined || (n >= 1 && n <= sourceCount);
+
+ return (tree: Root, file: { value?: unknown }) => {
+ const source = String(file.value ?? '');
+
+ visit(tree, (node, index, parent) => {
+ if (node.type === 'link' || node.type === 'linkReference') return SKIP;
+ if (node.type !== 'text' || !parent || index === undefined) return;
+
+ const escaped = escapedOffsets(node, source);
+ const parts: PhrasingContent[] = [];
+ let last = 0;
+ for (const match of node.value.matchAll(CITATION)) {
+ if (!citable(Number(match[1])) || escaped.has(match.index)) continue;
+ if (match.index > last) {
+ parts.push({
+ type: 'text',
+ value: node.value.slice(last, match.index),
+ });
+ }
+ parts.push({
+ type: 'link',
+ url: `#cite-${match[1]}`,
+ children: [{ type: 'text', value: match[1] }],
+ });
+ last = match.index + match[0].length;
+ }
+ if (parts.length === 0) return;
+ if (last < node.value.length) {
+ parts.push({ type: 'text', value: node.value.slice(last) });
+ }
+ parent.children.splice(index, 1, ...parts);
+ return [SKIP, index + parts.length];
+ });
+ };
+}
diff --git a/frontend/src/conversation/markdown/remarkDisplayMath.ts b/frontend/src/conversation/markdown/remarkDisplayMath.ts
new file mode 100644
index 00000000..5de372af
--- /dev/null
+++ b/frontend/src/conversation/markdown/remarkDisplayMath.ts
@@ -0,0 +1,134 @@
+/**
+ * Decides display versus inline math on the syntax tree, where the parser has
+ * already told math, code and prose apart.
+ *
+ * An inline formula becomes display math when
+ * - it is written with a block delimiter (``$$`` or ``\[``) and sits on a line
+ * of its own: ``$$E=mc^2$$`` alone in a paragraph, or a ``\[ … \]`` line the
+ * flow construct did not take (a lazy line inside a quote, say); or
+ * - KaTeX can only typeset it in display mode (``\tag``, or an environment such
+ * as ``align``); inline, it would be a red error instead of an equation.
+ *
+ * A promoted formula splits its paragraph: the text before and after stays a
+ * paragraph of its own. Where no paragraph can be split (a table cell, a
+ * heading) a display-only formula is still typeset in display mode, in place.
+ */
+import type { Paragraph, PhrasingContent, Root, RootContent } from 'mdast';
+import { SKIP, visit } from 'unist-util-visit';
+
+type InlineMath = Extract;
+
+// `\tag` and the environments KaTeX refuses outside display mode.
+const DISPLAY_ONLY =
+ /\\tag\b|\\begin\{(?:equation|align|alignat|gather|split|CD)\*?\}/;
+
+const displayClasses = ['language-math', 'math-display'];
+
+function isInlineMath(node: PhrasingContent): node is InlineMath {
+ return node.type === 'inlineMath';
+}
+
+function blockDelimited(node: InlineMath, source: string): boolean {
+ const start = node.position?.start.offset;
+ if (start === undefined) return false;
+ const opener = source.slice(start, start + 2);
+ return opener === '$$' || opener === '\\[';
+}
+
+// Whether the siblings either side of `index` end and start a line.
+function aloneOnLine(children: PhrasingContent[], index: number): boolean {
+ const before = children[index - 1];
+ const after = children[index + 1];
+ const endsLine =
+ !before ||
+ before.type === 'break' ||
+ (before.type === 'text' && /(?:^|\n)[ \t]*$/.test(before.value));
+ const startsLine =
+ !after ||
+ after.type === 'break' ||
+ (after.type === 'text' && /^[ \t]*(?:\n|$)/.test(after.value));
+ return endsLine && startsLine;
+}
+
+function toDisplay(node: InlineMath): RootContent {
+ return {
+ type: 'math',
+ meta: null,
+ value: node.value,
+ position: node.position,
+ data: {
+ hName: 'pre',
+ hChildren: [
+ {
+ type: 'element',
+ tagName: 'code',
+ properties: { className: displayClasses },
+ children: [{ type: 'text', value: node.value }],
+ },
+ ],
+ },
+ };
+}
+
+// The paragraph around each promoted formula, cut at it, with the line breaks
+// that separated them dropped.
+function splitParagraph(
+ paragraph: Paragraph,
+ promote: (child: PhrasingContent, index: number) => boolean,
+): RootContent[] {
+ const blocks: RootContent[] = [];
+ let run: PhrasingContent[] = [];
+ let trimNext = false;
+
+ const flush = () => {
+ const last = run[run.length - 1];
+ if (last?.type === 'break') run.pop();
+ else if (last?.type === 'text') last.value = last.value.trimEnd();
+ run = run.filter((child) => child.type !== 'text' || child.value !== '');
+ if (run.length > 0) blocks.push({ type: 'paragraph', children: run });
+ run = [];
+ };
+
+ paragraph.children.forEach((child, index) => {
+ if (isInlineMath(child) && promote(child, index)) {
+ flush();
+ blocks.push(toDisplay(child));
+ trimNext = true;
+ return;
+ }
+ if (trimNext) {
+ trimNext = false;
+ if (child.type === 'break') return;
+ if (child.type === 'text') child.value = child.value.trimStart();
+ }
+ run.push(child);
+ });
+ flush();
+ return blocks;
+}
+
+export function remarkDisplayMath() {
+ return (tree: Root, file: { value?: unknown }) => {
+ const source = String(file.value);
+
+ visit(tree, 'paragraph', (node, index, parent) => {
+ if (!parent || index === undefined) return;
+ const promote = (child: PhrasingContent, at: number) =>
+ isInlineMath(child) &&
+ (DISPLAY_ONLY.test(child.value) ||
+ (blockDelimited(child, source) && aloneOnLine(node.children, at)));
+ if (!node.children.some(promote)) return;
+ const blocks = splitParagraph(node, promote);
+ // A paragraph's parent (the root, a list item, a quote) holds blocks.
+ (parent.children as RootContent[]).splice(index, 1, ...blocks);
+ return [SKIP, index + blocks.length];
+ });
+
+ // Nowhere to split (a table cell, a heading, inside bold): typeset a
+ // display-only formula in display mode where it stands.
+ visit(tree, 'inlineMath', (node) => {
+ if (!DISPLAY_ONLY.test(node.value)) return;
+ node.data = { ...node.data, hProperties: { className: displayClasses } };
+ });
+ };
+}
diff --git a/frontend/src/conversation/markdown/singleDollarMath.ts b/frontend/src/conversation/markdown/singleDollarMath.ts
new file mode 100644
index 00000000..7d2a96f4
--- /dev/null
+++ b/frontend/src/conversation/markdown/singleDollarMath.ts
@@ -0,0 +1,160 @@
+/**
+ * Single-dollar inline math, ``$x$``, that leaves prices alone.
+ *
+ * Ported from LibreChat's `client/src/utils/latex.ts`
+ * (https://github.com/danny-avila/LibreChat), MIT License,
+ * Copyright (c) 2026 LibreChat.
+ *
+ * `$...$` is ambiguous with prices ("from $2bn to at least $4bn"), and only
+ * the tokenizer can decide a span without rewriting the message: code spans,
+ * fences and autolinks are structurally excluded, and a rejected span stays
+ * byte-identical text. `remark-math` keeps running with
+ * `singleDollarTextMath: false`; this construct is the only single-dollar path.
+ *
+ * A `$...$` span becomes math only when all of Pandoc's boundary rules hold:
+ * - the opening `$` is immediately followed by a non-space character;
+ * - the closing `$` is immediately preceded by a non-space character and not
+ * immediately followed by a digit (rejects ranges like "$100-$200");
+ * - the span stays on one line, contains no backtick, treats `\`-pairs as
+ * opaque (`\$` stays inside the span), and closes with balanced braces.
+ *
+ * A failed close abandons the whole attempt instead of scanning further, so
+ * the next `$` in "Price is $50 and $100" can never silently extend a span;
+ * micromark then retries the construct at that `$` on its own merits. Pandoc
+ * has the same known false positive: `$HOME/$USER` in prose (not code) is math.
+ */
+import {
+ asciiDigit,
+ markdownLineEnding,
+ markdownSpace,
+} from 'micromark-util-character';
+import { codes, types } from 'micromark-util-symbol';
+import type {
+ Code,
+ Construct,
+ Effects,
+ Extension,
+ State,
+ TokenizeContext,
+} from 'micromark-util-types';
+import type { Processor } from 'unified';
+
+import { addMicromarkExtension } from './micromarkExtension';
+
+function tokenizeMathSpan(
+ this: TokenizeContext,
+ effects: Effects,
+ ok: State,
+ nok: State,
+): State {
+ let previousCode: Code = null;
+ let braceDepth = 0;
+
+ return start;
+
+ function start(code: Code): State | undefined {
+ effects.enter('mathText');
+ effects.enter('mathTextSequence');
+ effects.consume(code);
+ effects.exit('mathTextSequence');
+ return open;
+ }
+
+ function open(code: Code): State | undefined {
+ if (
+ code === codes.eof ||
+ code === codes.dollarSign ||
+ code === codes.graveAccent ||
+ markdownSpace(code) ||
+ markdownLineEnding(code)
+ ) {
+ return nok(code);
+ }
+ effects.enter('mathTextData');
+ return content(code);
+ }
+
+ function content(code: Code): State | undefined {
+ if (
+ code === codes.eof ||
+ code === codes.graveAccent ||
+ markdownLineEnding(code)
+ ) {
+ return nok(code);
+ }
+ if (code === codes.dollarSign) {
+ if (markdownSpace(previousCode) || braceDepth !== 0) {
+ return nok(code);
+ }
+ effects.exit('mathTextData');
+ effects.enter('mathTextSequence');
+ effects.consume(code);
+ return close;
+ }
+ if (code === codes.backslash) {
+ effects.consume(code);
+ return escape;
+ }
+ if (code === codes.leftCurlyBrace) {
+ braceDepth++;
+ }
+ if (code === codes.rightCurlyBrace) {
+ if (braceDepth === 0) {
+ return nok(code);
+ }
+ braceDepth--;
+ }
+ previousCode = code;
+ effects.consume(code);
+ return content;
+ }
+
+ function escape(code: Code): State | undefined {
+ if (code === codes.eof || markdownLineEnding(code)) {
+ return nok(code);
+ }
+ previousCode = code;
+ effects.consume(code);
+ return content;
+ }
+
+ function close(code: Code): State | undefined {
+ if (code === codes.dollarSign || asciiDigit(code)) {
+ return nok(code);
+ }
+ effects.exit('mathTextSequence');
+ effects.exit('mathText');
+ return ok(code);
+ }
+}
+
+/** Mirrors `micromark-extension-math`: a `$` opener is valid unless it follows an unescaped `$`. */
+function previous(this: TokenizeContext, code: Code): boolean {
+ return (
+ code !== codes.dollarSign ||
+ this.events[this.events.length - 1][1].type === types.characterEscape
+ );
+}
+
+const mathSpan: Construct = {
+ name: 'mathSpanSingleDollar',
+ tokenize: tokenizeMathSpan,
+ previous,
+};
+
+/**
+ * micromark syntax extension adding currency-safe single-dollar inline math.
+ * It emits the same `mathText`/`mathTextData` tokens as
+ * `micromark-extension-math`, so `remark-math`'s `mathFromMarkdown` handlers
+ * turn its spans into regular `inlineMath` nodes. Registration order relative
+ * to `remark-math` is immaterial: this construct rejects `$$` openers and
+ * `remark-math` (with `singleDollarTextMath: false`) rejects single `$` openers.
+ */
+export const singleDollarMath: Extension = {
+ text: { [codes.dollarSign]: mathSpan },
+};
+
+/** remark plugin for {@link singleDollarMath}; `remark-math` must run alongside it. */
+export function remarkSingleDollarMath(this: Processor) {
+ addMicromarkExtension(this, singleDollarMath);
+}
diff --git a/frontend/src/conversation/markdown/texMath.ts b/frontend/src/conversation/markdown/texMath.ts
new file mode 100644
index 00000000..6d27f0d8
--- /dev/null
+++ b/frontend/src/conversation/markdown/texMath.ts
@@ -0,0 +1,398 @@
+/**
+ * LaTeX's own math delimiters, ``\( … \)`` and ``\[ … \]``, as micromark
+ * constructs. Models write math this way far more often than with dollars, and
+ * remark-math only knows dollars.
+ *
+ * The constructs emit the same tokens as `micromark-extension-math`, so
+ * `remark-math`'s `mathFromMarkdown` turns them into ordinary `inlineMath` and
+ * `math` nodes and rehype-katex renders them. `remark-math` has to run too.
+ *
+ * - Text: ``\(x\)`` and ``\[x\]`` anywhere in a paragraph become inline math.
+ * The span may wrap lines but not cross a paragraph; ``\\`` pairs are opaque
+ * (a LaTeX line break), a nested ``\( … \)`` of the same kind is kept whole,
+ * and whitespace-only content is not math, so an escaped task box
+ * ``- \[ \] todo`` stays text. ``\[`` right after a letter or digit is an
+ * escaped index (``arr\[0\]``), not math.
+ * - Flow: a ``\[`` that starts a line opens display math, closed by a ``\]``
+ * that ends a line (the same line or a later one). Its lines are not parsed
+ * as markdown, so a lone ``=`` is not a setext underline. If a ``\]`` is
+ * followed by more text on its line, or the block is never closed, it is not
+ * a display block, and the text construct gets the ``\[`` instead: one line
+ * such as ``\[a\] and more`` must not swallow the rest of the answer.
+ *
+ * Written for this app rather than aliasing `micromark-extension-llm-math`
+ * over `micromark-extension-math`, whose flow construct does exactly that
+ * swallowing. Its structure follows that package and `micromark-extension-math`
+ * (both MIT).
+ */
+import {
+ asciiAlphanumeric,
+ markdownLineEnding,
+ markdownSpace,
+} from 'micromark-util-character';
+import { codes, types } from 'micromark-util-symbol';
+import type {
+ Code,
+ Construct,
+ Effects,
+ Extension,
+ State,
+ TokenizeContext,
+} from 'micromark-util-types';
+import type { Processor } from 'unified';
+
+import { addMicromarkExtension } from './micromarkExtension';
+
+function tokenizeTexText(
+ this: TokenizeContext,
+ effects: Effects,
+ ok: State,
+ nok: State,
+): State {
+ // What precedes the opening backslash, read before it is consumed.
+ const before = this.previous;
+ let open: Code = null;
+ let close: Code = null;
+ let depth = 0;
+ let hasContent = false;
+
+ const closing: Construct = { tokenize: tokenizeClosing, partial: true };
+
+ return start;
+
+ function start(code: Code): State | undefined {
+ effects.enter('mathText');
+ effects.enter('mathTextSequence');
+ effects.consume(code);
+ return sequenceOpen;
+ }
+
+ function sequenceOpen(code: Code): State | undefined {
+ if (code === codes.leftParenthesis) {
+ close = codes.rightParenthesis;
+ } else if (code === codes.leftSquareBracket && !asciiAlphanumeric(before)) {
+ close = codes.rightSquareBracket;
+ } else {
+ return nok(code);
+ }
+ open = code;
+ effects.consume(code);
+ effects.exit('mathTextSequence');
+ return between;
+ }
+
+ function between(code: Code): State | undefined {
+ if (code === codes.eof) return nok(code);
+ if (code === codes.backslash) {
+ return effects.check(closing, closeSequence, escape)(code);
+ }
+ // A line ending is kept as data: TeX reads it as a space, and dropping it
+ // would fuse ``\alpha`` with the next line's first letter.
+ if (markdownLineEnding(code)) {
+ effects.enter('mathTextData');
+ effects.consume(code);
+ effects.exit('mathTextData');
+ return between;
+ }
+ effects.enter('mathTextData');
+ return data(code);
+ }
+
+ function data(code: Code): State | undefined {
+ if (
+ code === codes.eof ||
+ code === codes.backslash ||
+ markdownLineEnding(code)
+ ) {
+ effects.exit('mathTextData');
+ return between(code);
+ }
+ if (!markdownSpace(code)) hasContent = true;
+ effects.consume(code);
+ return data;
+ }
+
+ function escape(code: Code): State | undefined {
+ effects.enter('mathTextData');
+ effects.consume(code);
+ hasContent = true;
+ return escaped;
+ }
+
+ function escaped(code: Code): State | undefined {
+ if (code === codes.eof || markdownLineEnding(code)) {
+ effects.exit('mathTextData');
+ return between(code);
+ }
+ if (code === open) depth++;
+ // Only reached for a nested close: the outermost one is `closing`.
+ if (code === close) depth--;
+ effects.consume(code);
+ return data;
+ }
+
+ function closeSequence(code: Code): State | undefined {
+ if (!hasContent) return nok(code);
+ effects.enter('mathTextSequence');
+ effects.consume(code);
+ return closeBracket;
+ }
+
+ function closeBracket(code: Code): State | undefined {
+ effects.consume(code);
+ return done;
+ }
+
+ function done(code: Code): State | undefined {
+ effects.exit('mathTextSequence');
+ effects.exit('mathText');
+ return ok(code);
+ }
+
+ function tokenizeClosing(effects: Effects, ok: State, nok: State): State {
+ return (code) => {
+ effects.enter('mathTextSequence');
+ effects.consume(code);
+ return (next) => {
+ if (next !== close || depth !== 0) return nok(next);
+ effects.exit('mathTextSequence');
+ return ok(next);
+ };
+ };
+ }
+}
+
+/** A ``\(`` or ``\[`` opens math unless its backslash is itself escaped. */
+function previousTex(this: TokenizeContext, code: Code): boolean {
+ return (
+ code !== codes.backslash ||
+ this.events[this.events.length - 1][1].type === types.characterEscape
+ );
+}
+
+const texText: Construct = {
+ name: 'mathTexText',
+ tokenize: tokenizeTexText,
+ previous: previousTex,
+};
+
+function tokenizeTexFlow(
+ this: TokenizeContext,
+ effects: Effects,
+ ok: State,
+ nok: State,
+): State {
+ // Set while micromark asks whether the block may interrupt a paragraph.
+ const interrupt = this.interrupt;
+ // Whether the line being entered is a lazy continuation.
+ const lazyLine = () => this.parser.lazy[this.now().line];
+ const tail = this.events[this.events.length - 1];
+ // The fence's own indent, stripped from the lines inside it too.
+ const initialSize =
+ tail && tail[1].type === types.linePrefix
+ ? tail[2].sliceSerialize(tail[1], true).length
+ : 0;
+
+ const closingFence: Construct = {
+ tokenize: tokenizeClosingFence,
+ partial: true,
+ };
+ const nonLazyLine: Construct = {
+ tokenize: tokenizeNonLazyLine,
+ partial: true,
+ };
+
+ return start;
+
+ function start(code: Code): State | undefined {
+ effects.enter('mathFlow');
+ effects.enter('mathFlowFence');
+ effects.enter('mathFlowFenceSequence');
+ effects.consume(code);
+ return sequenceOpen;
+ }
+
+ function sequenceOpen(code: Code): State | undefined {
+ if (code !== codes.leftSquareBracket) return nok(code);
+ effects.consume(code);
+ effects.exit('mathFlowFenceSequence');
+ return fenceWhitespace;
+ }
+
+ function fenceWhitespace(code: Code): State | undefined {
+ if (!markdownSpace(code)) return fenceEnd(code);
+ effects.enter(types.whitespace);
+ return fenceWhitespaceInside(code);
+ }
+
+ function fenceWhitespaceInside(code: Code): State | undefined {
+ if (markdownSpace(code)) {
+ effects.consume(code);
+ return fenceWhitespaceInside;
+ }
+ effects.exit(types.whitespace);
+ return fenceEnd(code);
+ }
+
+ function fenceEnd(code: Code): State | undefined {
+ effects.exit('mathFlowFence');
+ return lineContent(code);
+ }
+
+ // Anywhere in a line of the block, outside a value chunk.
+ function lineContent(code: Code): State | undefined {
+ if (code === codes.eof) return nok(code);
+ if (markdownLineEnding(code)) {
+ // To interrupt a paragraph only the opening line has to hold up.
+ if (interrupt) return ok(code);
+ return effects.attempt(nonLazyLine, lineStart, nok)(code);
+ }
+ if (code === codes.backslash) {
+ return effects.check(
+ { tokenize: tokenizeBracketClose, partial: true },
+ atClose,
+ valueEscape,
+ )(code);
+ }
+ effects.enter('mathFlowValue');
+ return value(code);
+ }
+
+ function lineStart(code: Code): State | undefined {
+ if (initialSize === 0 || !markdownSpace(code)) return lineContent(code);
+ effects.enter(types.linePrefix);
+ return linePrefix(code, 0);
+ }
+
+ function linePrefix(code: Code, size: number): State | undefined {
+ if (markdownSpace(code) && size < initialSize) {
+ effects.consume(code);
+ return (next: Code) => linePrefix(next, size + 1);
+ }
+ effects.exit(types.linePrefix);
+ return lineContent(code);
+ }
+
+ function value(code: Code): State | undefined {
+ if (
+ code === codes.eof ||
+ code === codes.backslash ||
+ markdownLineEnding(code)
+ ) {
+ effects.exit('mathFlowValue');
+ return lineContent(code);
+ }
+ effects.consume(code);
+ return value;
+ }
+
+ // A ``\]`` closes the block only when the rest of its line is blank; with
+ // anything after it, this is a line of prose, not a display block.
+ function atClose(code: Code): State | undefined {
+ return effects.attempt(closingFence, after, nok)(code);
+ }
+
+ function after(code: Code): State | undefined {
+ effects.exit('mathFlow');
+ return ok(code);
+ }
+
+ // ``\\`` and ``\x`` pairs are content, so ``\\]`` is not a close.
+ function valueEscape(code: Code): State | undefined {
+ effects.enter('mathFlowValue');
+ effects.consume(code);
+ return valueEscaped;
+ }
+
+ function valueEscaped(code: Code): State | undefined {
+ if (code === codes.eof || markdownLineEnding(code)) {
+ effects.exit('mathFlowValue');
+ return lineContent(code);
+ }
+ effects.consume(code);
+ return value;
+ }
+
+ function tokenizeBracketClose(
+ effects: Effects,
+ ok: State,
+ nok: State,
+ ): State {
+ return (code) => {
+ effects.enter('mathFlowFenceSequence');
+ effects.consume(code);
+ return (next) => {
+ if (next !== codes.rightSquareBracket) return nok(next);
+ effects.exit('mathFlowFenceSequence');
+ return ok(next);
+ };
+ };
+ }
+
+ function tokenizeClosingFence(
+ effects: Effects,
+ ok: State,
+ nok: State,
+ ): State {
+ return (code) => {
+ effects.enter('mathFlowFence');
+ effects.enter('mathFlowFenceSequence');
+ effects.consume(code);
+ return (bracket) => {
+ effects.consume(bracket);
+ effects.exit('mathFlowFenceSequence');
+ return afterSequence;
+ };
+ };
+
+ function afterSequence(code: Code): State | undefined {
+ if (markdownSpace(code)) {
+ effects.enter(types.whitespace);
+ return trailing(code);
+ }
+ return end(code);
+ }
+
+ function trailing(code: Code): State | undefined {
+ if (markdownSpace(code)) {
+ effects.consume(code);
+ return trailing;
+ }
+ effects.exit(types.whitespace);
+ return end(code);
+ }
+
+ function end(code: Code): State | undefined {
+ if (code !== codes.eof && !markdownLineEnding(code)) return nok(code);
+ effects.exit('mathFlowFence');
+ return ok(code);
+ }
+ }
+
+ // Past a line ending, unless the next line is a lazy continuation, which
+ // means the container around the block (a quote, a list item) has ended.
+ function tokenizeNonLazyLine(effects: Effects, ok: State, nok: State): State {
+ return (code) => {
+ effects.enter(types.lineEnding);
+ effects.consume(code);
+ effects.exit(types.lineEnding);
+ return (next) => (lazyLine() ? nok(next) : ok(next));
+ };
+ }
+}
+
+const texFlow: Construct = {
+ name: 'mathTexFlow',
+ tokenize: tokenizeTexFlow,
+ concrete: true,
+};
+
+export const texMath: Extension = {
+ flow: { [codes.backslash]: texFlow },
+ text: { [codes.backslash]: texText },
+};
+
+/** remark plugin for {@link texMath}; `remark-math` must run alongside it. */
+export function remarkTexMath(this: Processor) {
+ addMicromarkExtension(this, texMath);
+}