1import type { ResponseStreamingMode } from "@t3tools/contracts";
2import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
3
4// An opening fence may sit at any indentation, since fences inside list
5// items are indented past the marker. A closing fence may be indented at most
6// three spaces more than its opener. Deeper lines are content in the block.
7const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/;
8// CommonMark blank lines hold only spaces and tabs. Other whitespace, such as
9// a no-break space, is paragraph content.
10const BLANK_LINE_PATTERN = /^[ \t]*$/;
11// A bullet or ordered marker followed by whitespace, at any indentation so
12// nested items count. The trailing space is required, so a partial `-` or
13// `1.` never matches before the model finishes the marker.
14const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/;
15// A section title: an ATX heading, or a line of only bold text, which models
16// often use as a heading.
17const SECTION_TITLE_PATTERN = /^ {0,3}(?:#{1,6}(?:[ \t]|$)|\*\*(?:[^*]|\*(?!\*))+\*\*:?$)/;
18// An unindented ATX heading ends the paragraph or list above it, even with no
19// blank line between them. A bold line would continue the paragraph instead.
20const TOP_LEVEL_HEADING_PATTERN = /^#{1,6}(?:[ \t]|$)/;
21
22/**
23 * Splits buffered assistant text at the last blank line, closing code fence,
24 * or list item start that is not inside an open fenced code block. `ready` is
25 * safe to deliver now because the markdown before it will not change shape as
26 * more text arrives. `rest` stays buffered until the next boundary or
27 * completion. Only fully terminated lines count, so a trailing partial line
28 * never leaks; a list item start is the one lookahead that may sit on the
29 * partial line, since tight lists have no blank lines between items and would
30 * otherwise land all at once.
31 *
32 * A section title holds the boundary until a content line follows it, so a
33 * title never lands alone and waits above a block that is still streaming.
34 */
35export function splitBufferedAssistantText(text: string): { ready: string; rest: string } {
36 let openFence: { marker: string; indent: number } | null = null;
37 let boundary = -1;
38 let lineStart = 0;
39 let titleAwaitingContent = false;
40 for (;;) {
41 const newline = text.indexOf("\n", lineStart);
42 const line = text
43 .slice(lineStart, newline === -1 ? text.length : newline)
44 .replace(/[ \t\r]+$/, "");
45 if (
46 openFence === null &&
47 lineStart > 0 &&
48 !titleAwaitingContent &&
49 LIST_ITEM_START_PATTERN.test(line)
50 ) {
51 boundary = lineStart;
52 }
53 if (newline === -1) {
54 break;
55 }
56 const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line);
57 if (fenceMatch) {
58 const indent = fenceMatch[1]!.length;
59 const marker = fenceMatch[2]!;
60 if (openFence === null) {
61 openFence = { marker, indent };
62 titleAwaitingContent = false;
63 } else if (
64 marker[0] === openFence.marker[0] &&
65 marker.length >= openFence.marker.length &&
66 indent <= openFence.indent + 3 &&
67 line.length === indent + marker.length
68 ) {
69 // CommonMark: a closing fence carries no info string.
70 openFence = null;
71 boundary = newline + 1;
72 }
73 } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) {
74 if (!titleAwaitingContent) {
75 boundary = newline + 1;
76 }
77 } else if (openFence === null) {
78 if (lineStart > 0 && !titleAwaitingContent && TOP_LEVEL_HEADING_PATTERN.test(line)) {
79 boundary = lineStart;
80 }
81 titleAwaitingContent = SECTION_TITLE_PATTERN.test(line);
82 }
83 lineStart = newline + 1;
84 }
85 if (boundary === -1) {
86 return { ready: "", rest: text };
87 }
88 return { ready: text.slice(0, boundary), rest: text.slice(boundary) };
89}
90
91/** Buffer assistant messages and reasoning alike, keeping unfinished markdown out of the read model. */
92export function makeAssistantStreamingFilter(mode: ResponseStreamingMode) {
93 const delivered = new Map<string, { text: string; at: number }>();
94 return (
95 event: ProviderAdapter.ProviderAdapterV2Event,
96 now: number,
97 ): ProviderAdapter.ProviderAdapterV2Event | null => {
98 if (
99 event.type === "node.updated" &&
100 event.node.kind === "assistant_message" &&
101 event.node.status === "running"
102 )
103 return null;
104 const message =
105 event.type === "message.updated" && event.message.role === "assistant"
106 ? event.message
107 : event.type === "turn_item.updated" &&
108 (event.turnItem.type === "assistant_message" || event.turnItem.type === "reasoning")
109 ? event.turnItem
110 : null;
111 if (!message) return event;
112 const key = `${event.type}:${message.id}`;
113 if (!message.streaming) {
114 delivered.delete(key);
115 return event;
116 }
117 if (mode === "turn") return null;
118 const previous = delivered.get(key);
119 if (previous && now - previous.at < 400) return null;
120 const { ready } = splitBufferedAssistantText(message.text);
121 if (!ready || ready === previous?.text) return null;
122 delivered.set(key, { text: ready, at: now });
123 if (event.type === "message.updated")
124 return { ...event, message: { ...event.message, text: ready } };
125 if (
126 event.type === "turn_item.updated" &&
127 (event.turnItem.type === "assistant_message" || event.turnItem.type === "reasoning")
128 )
129 return { ...event, turnItem: { ...event.turnItem, text: ready } };
130 return event;
131 };
132}