Spaces:
Sleeping
Sleeping
File size: 9,122 Bytes
ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 e0198de ef99e21 3c5f882 e0198de ef99e21 3c5f882 e0198de ef99e21 e0198de ef99e21 e0198de ef99e21 e0198de ef99e21 3c5f882 ef99e21 e0198de ef99e21 e0198de ef99e21 e0198de ef99e21 e0198de ef99e21 3c5f882 ef99e21 e0198de ef99e21 3c5f882 ef99e21 3c5f882 e0198de ef99e21 3c5f882 ef99e21 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 | /**
* 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
}
|