// Minimal SSE + UIMessageChunk parser. The backend writes // data: ${JSON.stringify(chunk)}\n\n // for each AI SDK UIMessageChunk, plus a final `data: [DONE]\n\n`. // We reconstruct events by buffering stream text and splitting on blank lines. export type UIMessageChunk = | { type: 'start'; messageId?: string } | { type: 'start-step' } | { type: 'finish-step' } | { type: 'finish' } | { type: 'error'; errorText: string } | { type: 'text-start'; id: string } | { type: 'text-delta'; id: string; delta: string } | { type: 'text-end'; id: string } | { type: 'tool-input-start'; toolCallId: string; toolName: string } | { type: 'tool-input-delta'; toolCallId: string; inputTextDelta: string } | { type: 'tool-input-available'; toolCallId: string; toolName: string; input: any } | { type: 'tool-output-available'; toolCallId: string; output: any } | { type: 'tool-output-error'; toolCallId: string; errorText: string } | { type: string; [key: string]: any }; // Reads the SSE body byte stream and yields UIMessageChunk objects. export async function* readChatStream( body: ReadableStream, ): AsyncGenerator { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ''; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Each SSE event is terminated by a blank line. Split off complete // events and keep the trailing partial in buffer. let sep: number; while ((sep = buffer.indexOf('\n\n')) !== -1) { const rawEvent = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); // Grab lines starting with "data:" (there may be comments or // event: lines too — we ignore them). const lines = rawEvent.split('\n'); for (const line of lines) { if (!line.startsWith('data:')) continue; const payload = line.slice(5).trimStart(); if (!payload || payload === '[DONE]') continue; try { yield JSON.parse(payload) as UIMessageChunk; } catch { // Bad JSON — skip this event rather than crash the stream. } } } } } finally { reader.releaseLock(); } }