File size: 5,304 Bytes
fae625a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import {
	MessageUpdateStatus,
	MessageUpdateType,
	type MessageStatusUpdate,
	type MessageUpdate,
} from "$lib/types/MessageUpdate";
import type { Message } from "$lib/types/Message";
import type { StreamingMode } from "$lib/types/Settings";
import { mergeFinalAnswerContent } from "./mergeFinalAnswer";

export interface ConsumeContext {
	streamingMode: StreamingMode;
	/** In raw mode, how long content may buffer before a flush (ms). */
	maxUpdateTime: number;
	isAborted: () => boolean;
	onAbort: () => void;
	/** Fired on every stream token; the caller uses it to clear its `pending` flag once. */
	onStreamStart: () => void;
	onTitle: (title: string) => void;
	onError: (update: MessageStatusUpdate) => void;
}

/**
 * Apply a stream of MessageUpdates to the reactive $state `message` the UI renders.
 * Shared by the two ways updates arrive — the POST that starts a run and the reattach
 * stream that resumes one — so both render identically from one copy of the rules.
 */
export async function consumeMessageUpdates(
	iterator: AsyncGenerator<MessageUpdate>,
	message: Message,
	ctx: ConsumeContext
): Promise<void> {
	let buffer = "";
	let lastUpdateTime = new Date();
	let frameFlushScheduled = false;

	// Local authoritative copy of message.updates during streaming. Assigning the
	// reactive field on every chunk re-triggers the full markdown derivation; buffer
	// here and flush on the same cadence as content. Readers below must use the
	// buffer, not message.updates, or they see stale data between flushes.
	let updatesBuffer: MessageUpdate[] = message.updates ?? [];
	let updatesDirty = false;

	const flushBuffer = (currentTime: Date) => {
		if (buffer.length === 0 && !updatesDirty) return;
		if (buffer.length > 0) {
			message.content += buffer;
			buffer = "";
			lastUpdateTime = currentTime;
		}
		if (updatesDirty) {
			message.updates = updatesBuffer;
			updatesDirty = false;
		}
	};

	const scheduleFrameFlush = () => {
		if (frameFlushScheduled) return;
		frameFlushScheduled = true;
		const flush = () => {
			frameFlushScheduled = false;
			flushBuffer(new Date());
		};
		if (typeof requestAnimationFrame === "function") {
			requestAnimationFrame(flush);
		} else {
			setTimeout(flush, 0);
		}
	};

	for await (const update of iterator) {
		if (ctx.isAborted()) {
			// Commit anything still buffered: the navigation-abort path skips the
			// post-stream refresh, so a dropped buffer would be lost from the UI.
			flushBuffer(new Date());
			ctx.onAbort();
			return;
		}

		// Remove null padding added server-side (see the keylogging note there).
		if (update.type === MessageUpdateType.Stream) {
			update.token = update.token.replaceAll("\0", "");
		}

		const isKeepAlive =
			update.type === MessageUpdateType.Status && update.status === MessageUpdateStatus.KeepAlive;

		if (!isKeepAlive) {
			if (update.type === MessageUpdateType.Stream) {
				const lastUpdate = updatesBuffer.at(-1);
				if (lastUpdate?.type === MessageUpdateType.Stream) {
					// Persisted streams use empty-token length markers whose spans are
					// read from message.content. Keep that representation when live text
					// resumes, or replacing the marker with a token makes the renderer
					// drop the marker's original span at the reattach boundary.
					const merged =
						lastUpdate.token === "" && lastUpdate.len !== undefined
							? {
									...lastUpdate,
									len: lastUpdate.len + update.token.length,
								}
							: {
									...lastUpdate,
									token: lastUpdate.token + update.token,
								};
					// Fresh objects/arrays so the UI reacts to merged tokens or lengths.
					updatesBuffer = [...updatesBuffer.slice(0, -1), merged];
				} else {
					updatesBuffer = [...updatesBuffer, update];
				}
			} else {
				updatesBuffer = [...updatesBuffer, update];
			}
			updatesDirty = true;
		}

		const currentTime = new Date();

		// A non-stream update flushes buffered tokens so the UI doesn't appear to cut
		// mid-sentence while tools run or the final answer arrives.
		if (update.type !== MessageUpdateType.Stream) {
			flushBuffer(currentTime);
		}

		if (update.type === MessageUpdateType.Stream) {
			buffer += update.token;
			if (ctx.streamingMode === "smooth") {
				scheduleFrameFlush();
			} else if (currentTime.getTime() - lastUpdateTime.getTime() > ctx.maxUpdateTime) {
				flushBuffer(currentTime);
			}
			ctx.onStreamStart();
		} else if (update.type === MessageUpdateType.FinalAnswer) {
			message.content = mergeFinalAnswerContent({
				existing: message.content,
				finalText: update.text ?? "",
				hadTools: updatesBuffer.some((u) => u.type === MessageUpdateType.Tool),
				isInterrupted: update.interrupted === true,
			});
		} else if (
			update.type === MessageUpdateType.Status &&
			update.status === MessageUpdateStatus.Error
		) {
			ctx.onError(update);
		} else if (update.type === MessageUpdateType.Title) {
			ctx.onTitle(update.title);
		} else if (update.type === MessageUpdateType.File) {
			message.files = [
				...(message.files ?? []),
				{ type: "hash", value: update.sha, mime: update.mime, name: update.name },
			];
		} else if (update.type === MessageUpdateType.RouterMetadata) {
			message.routerMetadata = { route: update.route, model: update.model };
		}
	}

	flushBuffer(new Date());
}