updated_mongodb_p / frontend /src /utils /streamRender.js
ape-dev
Improve streaming markdown rendering
e0198de
Raw
History Blame Contribute Delete
9.12 kB
/**
* Progressive Markdown display for a reply that is still streaming.
*
* The UI commits COMPLETE LINES instead of waiting for complete
* blank-line-delimited blocks, while buffering or sanitizing incomplete
* Markdown structures so raw syntax is never exposed to the reader.
*
* Two ideas do the work together — the line-level split is not sufficient on
* its own:
*
* 1. Split at the last newline, so every finished line can render and only
* the partially-typed final line waits. (Splitting at the last blank line
* instead would freeze a whole table — which has no blank lines — as raw
* text for the entire stream.)
*
* 2. Buffer the multi-line structures that a prefix of complete lines can
* still leave half-open:
* - an unterminated ``` code fence (`withoutOpenCodeFence`)
* - a table with its header row but no `|---|` separator yet
* (`withoutNascentTable`)
* and sanitize the single in-progress line (`cleanStreamTail`).
*
* This module is TEXT-LEVEL and renderer-agnostic: it decides WHAT markdown is
* safe to parse right now, not how to turn it into HTML. The actual rendering
* is react-markdown (see Message.jsx), so the split survives any renderer
* upgrade. Pure (no React) so it unit-tests directly — see
* streamRender.test.mjs.
*/
/**
* Split the stream buffer into what may render now vs. what must wait.
*
* Returns:
* { thinking: true } — buffer is still empty
* { thinking: false, committed, tail } — `committed` is markdown that is
* safe to hand to the parser; `tail` is the sanitized plain-text remnant
* of the line still being typed ("" when there is nothing showable).
*/
export function splitStreaming(content) {
const text = content || "";
if (!text.trim()) {
return { thinking: true, committed: "", tail: "", liveCode: null };
}
const cut = text.lastIndexOf("\n");
const committedRaw = cut === -1 ? "" : text.slice(0, cut);
const tailRaw = cut === -1 ? text : text.slice(cut + 1);
const liveCode = extractOpenCodeFence(committedRaw, tailRaw);
const committedSource = liveCode ? liveCode.before : committedRaw;
// Among the complete lines, hold back any structure that is not yet in a
// renderable state, so the parser never receives half-open syntax.
const committed = commitStableMarkdown(committedSource);
// If the buffer ends inside an open code fence, the in-progress line is code,
// not prose — hide it along with the fence instead of showing it as text.
const startsFence = /^\s*```/.test(tailRaw);
const tail = liveCode || startsFence ? "" : cleanStreamTail(tailRaw);
return {
thinking: false,
committed,
tail,
liveCode: liveCode ? {
language: liveCode.language,
code: liveCode.code,
} : null,
};
}
/**
* Trim the committed prefix down to the largest span that renders cleanly:
* no open code fence, no header-only table. Order matters — remove an open
* fence first, since fenced content can contain `|` lines that would
* otherwise look like a nascent table.
*/
export function commitStableMarkdown(committed) {
return withoutNascentTable(
repairMarkdownTables(
withoutOpenMathBlock(
withoutOpenCodeFence(committed),
),
),
);
}
export function extractOpenCodeFence(committed, tail = "") {
if (!committed || committed.indexOf("```") === -1) return null;
const lines = committed.split("\n");
let open = false;
let lastOpenIdx = -1;
let language = "";
for (let i = 0; i < lines.length; i++) {
const fence = lines[i].match(/^\s*```(.*)$/);
if (!fence) continue;
if (!open) {
open = true;
lastOpenIdx = i;
language = (fence[1] || "").trim().split(/\s+/)[0] || "";
} else {
open = false;
lastOpenIdx = -1;
language = "";
}
}
if (!open || lastOpenIdx < 0) return null;
const codeLines = lines.slice(lastOpenIdx + 1);
const code = tail ? [...codeLines, tail].join("\n") : codeLines.join("\n");
return {
before: lines.slice(0, lastOpenIdx).join("\n"),
language,
code,
};
}
/**
* Hold back an unterminated ``` code fence.
*
* A fence renders as a code block only once BOTH its opening and closing ```
* lines are present. While the fence is still open, the committed prefix ends
* with `` ```lang `` and code lines that would render as loose text. Cut the
* prefix at the last unmatched opening fence so the code block stays hidden
* until it closes, then renders whole.
*/
export function withoutOpenCodeFence(committed) {
const open = extractOpenCodeFence(committed);
return open ? open.before : committed;
}
export function withoutOpenMathBlock(committed) {
if (!committed || committed.indexOf("$$") === -1) return committed;
const lines = committed.split("\n");
let open = false;
let lastOpenIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*\$\$\s*$/.test(lines[i])) {
if (!open) { open = true; lastOpenIdx = i; }
else { open = false; }
}
}
return open ? lines.slice(0, lastOpenIdx).join("\n") : committed;
}
export function repairMarkdownTables(markdown) {
if (!markdown || markdown.indexOf("|") === -1) return markdown;
const lines = markdown.split("\n");
const out = [];
let i = 0;
let inFence = false;
while (i < lines.length) {
if (/^\s*```/.test(lines[i])) {
inFence = !inFence;
out.push(lines[i++]);
continue;
}
if (inFence || !isPipeTableLine(lines[i])) {
out.push(lines[i++]);
continue;
}
const start = i;
while (i < lines.length && isPipeTableLine(lines[i])) i++;
const block = lines.slice(start, i);
const hasSeparator = block.some(isTableSeparatorLine);
if (!hasSeparator && canRepairTableBlock(block)) {
out.push(block[0], separatorForTableLine(block[0]), ...block.slice(1));
} else {
out.push(...block);
}
}
return out.join("\n");
}
export function isPipeTableLine(line) {
const s = (line || "").trim();
return s.startsWith("|") && s.endsWith("|") && splitTableCells(s).length >= 2;
}
function canRepairTableBlock(block) {
if (block.length < 2) return false;
const count = splitTableCells(block[0]).length;
return count >= 2 && block.every((line) => splitTableCells(line).length === count);
}
function separatorForTableLine(line) {
const count = splitTableCells(line).length;
return `|${Array.from({ length: count }, () => "---").join("|")}|`;
}
function splitTableCells(line) {
const s = (line || "").trim().replace(/^\|/, "").replace(/\|$/, "");
return s.split("|").map((cell) => cell.trim());
}
/**
* Hold back a table that has its header row but not its `|---|` separator yet.
*
* A markdown table needs both a header row and a separator row to render.
* While streaming, the header line completes one frame before the separator,
* so for that window the committed text ends in table rows with no separator —
* which would show as raw pipes. Find the trailing run of `|`-lines and, if
* none of them is a separator, hide the whole run until the separator arrives.
* Once it lands, the block renders as a real table.
*/
export function withoutNascentTable(committed) {
if (!committed || committed.indexOf("|") === -1) return committed;
const lines = committed.split("\n");
let i = lines.length;
while (i > 0 && isPipeTableLine(lines[i - 1])) i--;
const trailing = lines.slice(i);
if (trailing.length === 0) return committed; // no trailing table
if (trailing.some(isTableSeparatorLine)) return committed; // already a table
return lines.slice(0, i).join("\n"); // header-only → hide
}
export function isTableSeparatorLine(line) {
const s = (line || "").trim();
if (!(s.startsWith("|") && s.endsWith("|"))) return false;
return /-/.test(s) && /^\|[\s:|-]+\|$/.test(s);
}
/**
* Sanitize the single in-progress line before showing it.
*
* That line is raw markdown, so printing it verbatim leaks syntax: a half-typed
* separator shows as `|---|`, an unclosed bold shows its `**`. A table row
* mid-build is pure scaffolding, so it is hidden outright; ordinary prose keeps
* typing through smoothly with only its (possibly unclosed) inline markers
* dropped.
*/
export function cleanStreamTail(tail) {
const t = tail || "";
if (!t.trim()) return "";
if (/^\s*\|/.test(t)) return ""; // table row / separator — hide
return t
.replace(/^\s*#{1,6}\s*/, "") // heading marker being typed
.replace(/^\s*>\s?/, "") // blockquote marker being typed
.replace(/\$\$/g, "") // display math delimiter
.replace(/(^|[^\\])\$(?=\S)(?!\d)/g, "$1") // inline math opener, not money
.replace(/\*\*/g, "") // unclosed bold
.replace(/(^|[^*])\*(?!\*)/g, "$1") // unclosed single-star italic
.replace(/~~/g, "") // unclosed strikethrough
.replace(/`/g, ""); // unclosed code span / fence
}