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
}