| ... | @@ -211,6 +211,12 @@ const BLANK_LINE_PATTERN = /^[ \t]*$/; | ... | @@ -211,6 +211,12 @@ const BLANK_LINE_PATTERN = /^[ \t]*$/; |
| 211 | // nested items count. The trailing space is required, so a partial `-` or | 211 | // nested items count. The trailing space is required, so a partial `-` or |
| 212 | // `1.` never matches before the model finishes the marker. | 212 | // `1.` never matches before the model finishes the marker. |
| 213 | const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; | 213 | const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; |
| | 214 | // A section title: an ATX heading, or a line of only bold text, which models |
| | 215 | // often use as a heading. |
| | 216 | const SECTION_TITLE_PATTERN = /^ {0,3}(?:#{1,6}(?:[ \t]|$)|\*\*(?:[^*]|\*(?!\*))+\*\*:?$)/; |
| | 217 | // An unindented ATX heading ends the paragraph or list above it, even with no |
| | 218 | // blank line between them. A bold line would continue the paragraph instead. |
| | 219 | const TOP_LEVEL_HEADING_PATTERN = /^#{1,6}(?:[ \t]|$)/; |
| 214 | | 220 | |
| 215 | /** | 221 | /** |
| 216 | * Splits buffered assistant text at the last blank line, closing code fence, | 222 | * Splits buffered assistant text at the last blank line, closing code fence, |
| ... | @@ -221,17 +227,26 @@ const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; | ... | @@ -221,17 +227,26 @@ const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; |
| 221 | * never leaks; a list item start is the one lookahead that may sit on the | 227 | * never leaks; a list item start is the one lookahead that may sit on the |
| 222 | * partial line, since tight lists have no blank lines between items and would | 228 | * partial line, since tight lists have no blank lines between items and would |
| 223 | * otherwise land all at once. | 229 | * otherwise land all at once. |
| | 230 | * |
| | 231 | * A section title holds the boundary until a content line follows it, so a |
| | 232 | * title never lands alone and waits above a block that is still streaming. |
| 224 | */ | 233 | */ |
| 225 | export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { | 234 | export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { |
| 226 | let openFence: { marker: string; indent: number } | null = null; | 235 | let openFence: { marker: string; indent: number } | null = null; |
| 227 | let boundary = -1; | 236 | let boundary = -1; |
| 228 | let lineStart = 0; | 237 | let lineStart = 0; |
| | 238 | let titleAwaitingContent = false; |
| 229 | for (;;) { | 239 | for (;;) { |
| 230 | const newline = text.indexOf("\n", lineStart); | 240 | const newline = text.indexOf("\n", lineStart); |
| 231 | const line = text | 241 | const line = text |
| 232 | .slice(lineStart, newline === -1 ? text.length : newline) | 242 | .slice(lineStart, newline === -1 ? text.length : newline) |
| 233 | .replace(/[ \t\r]+$/, ""); | 243 | .replace(/[ \t\r]+$/, ""); |
| 234 | if (openFence === null && lineStart > 0 && LIST_ITEM_START_PATTERN.test(line)) { | 244 | if ( |
| | 245 | openFence === null && |
| | 246 | lineStart > 0 && |
| | 247 | !titleAwaitingContent && |
| | 248 | LIST_ITEM_START_PATTERN.test(line) |
| | 249 | ) { |
| 235 | boundary = lineStart; | 250 | boundary = lineStart; |
| 236 | } | 251 | } |
| 237 | if (newline === -1) { | 252 | if (newline === -1) { |
| ... | @@ -243,6 +258,7 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: | ... | @@ -243,6 +258,7 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: |
| 243 | const marker = fenceMatch[2]!; | 258 | const marker = fenceMatch[2]!; |
| 244 | if (openFence === null) { | 259 | if (openFence === null) { |
| 245 | openFence = { marker, indent }; | 260 | openFence = { marker, indent }; |
| | 261 | titleAwaitingContent = false; |
| 246 | } else if ( | 262 | } else if ( |
| 247 | marker[0] === openFence.marker[0] && | 263 | marker[0] === openFence.marker[0] && |
| 248 | marker.length >= openFence.marker.length && | 264 | marker.length >= openFence.marker.length && |
| ... | @@ -254,7 +270,14 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: | ... | @@ -254,7 +270,14 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: |
| 254 | boundary = newline + 1; | 270 | boundary = newline + 1; |
| 255 | } | 271 | } |
| 256 | } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { | 272 | } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { |
| 257 | boundary = newline + 1; | 273 | if (!titleAwaitingContent) { |
| | 274 | boundary = newline + 1; |
| | 275 | } |
| | 276 | } else if (openFence === null) { |
| | 277 | if (lineStart > 0 && !titleAwaitingContent && TOP_LEVEL_HEADING_PATTERN.test(line)) { |
| | 278 | boundary = lineStart; |
| | 279 | } |
| | 280 | titleAwaitingContent = SECTION_TITLE_PATTERN.test(line); |
| 258 | } | 281 | } |
| 259 | lineStart = newline + 1; | 282 | lineStart = newline + 1; |
| 260 | } | 283 | } |