@ai-sdk/workflow 0.0.0-bf6e4b15-20260402200305

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,359 @@
1
+ import {
2
+ type ChatRequestOptions,
3
+ type ChatTransport,
4
+ type PrepareReconnectToStreamRequest,
5
+ type PrepareSendMessagesRequest,
6
+ parseJsonEventStream,
7
+ type UIMessage,
8
+ type UIMessageChunk,
9
+ uiMessageChunkSchema,
10
+ } from 'ai';
11
+ import { iteratorToStream, streamToIterator } from './stream-iterator.js';
12
+
13
+ export interface SendMessagesOptions<UI_MESSAGE extends UIMessage> {
14
+ trigger: 'submit-message' | 'regenerate-message';
15
+ chatId: string;
16
+ messageId?: string;
17
+ messages: UI_MESSAGE[];
18
+ abortSignal?: AbortSignal;
19
+ }
20
+
21
+ export interface ReconnectToStreamOptions {
22
+ chatId: string;
23
+ abortSignal?: AbortSignal;
24
+ }
25
+
26
+ type OnChatSendMessage<UI_MESSAGE extends UIMessage> = (
27
+ response: Response,
28
+ options: SendMessagesOptions<UI_MESSAGE>,
29
+ ) => void | Promise<void>;
30
+
31
+ type OnChatEnd = ({
32
+ chatId,
33
+ chunkIndex,
34
+ }: {
35
+ chatId: string;
36
+ chunkIndex: number;
37
+ }) => void | Promise<void>;
38
+
39
+ /**
40
+ * Configuration options for the WorkflowChatTransport.
41
+ *
42
+ * @template UI_MESSAGE - The type of UI messages being sent and received,
43
+ * must extend the UIMessage interface from the AI SDK.
44
+ */
45
+ export interface WorkflowChatTransportOptions<UI_MESSAGE extends UIMessage> {
46
+ /**
47
+ * API endpoint for chat requests
48
+ * Defaults to /api/chat if not provided
49
+ */
50
+ api?: string;
51
+
52
+ /**
53
+ * Custom fetch implementation to use for HTTP requests.
54
+ * Defaults to the global fetch function if not provided.
55
+ */
56
+ fetch?: typeof fetch;
57
+
58
+ /**
59
+ * Callback invoked after successfully sending messages to the chat endpoint.
60
+ * Useful for tracking chat history and inspecting response headers.
61
+ *
62
+ * @param response - The HTTP response object from the chat endpoint
63
+ * @param options - The original options passed to sendMessages
64
+ */
65
+ onChatSendMessage?: OnChatSendMessage<UI_MESSAGE>;
66
+
67
+ /**
68
+ * Callback invoked when a chat stream ends (receives a "finish" chunk).
69
+ * Useful for cleanup operations or state updates.
70
+ *
71
+ * @param chatId - The ID of the chat that ended
72
+ * @param chunkIndex - The total number of chunks received
73
+ */
74
+ onChatEnd?: OnChatEnd;
75
+
76
+ /**
77
+ * Maximum number of consecutive errors allowed during reconnection attempts.
78
+ * Defaults to 3 if not provided.
79
+ */
80
+ maxConsecutiveErrors?: number;
81
+
82
+ /**
83
+ * Function to prepare the request for sending messages.
84
+ * Allows customizing the API endpoint, headers, credentials, and body.
85
+ */
86
+ prepareSendMessagesRequest?: PrepareSendMessagesRequest<UI_MESSAGE>;
87
+
88
+ /**
89
+ * Function to prepare the request for reconnecting to a stream.
90
+ * Allows customizing the API endpoint, headers, and credentials.
91
+ */
92
+ prepareReconnectToStreamRequest?: PrepareReconnectToStreamRequest;
93
+ }
94
+
95
+ /**
96
+ * A transport implementation for managing chat workflows with support for
97
+ * streaming responses and automatic reconnection to interrupted streams.
98
+ *
99
+ * This class implements the ChatTransport interface from the AI SDK and provides
100
+ * reliable message streaming with automatic recovery from network interruptions
101
+ * or function timeouts.
102
+ *
103
+ * @template UI_MESSAGE - The type of UI messages being sent and received,
104
+ * must extend the UIMessage interface from the AI SDK.
105
+ *
106
+ * @implements {ChatTransport<UI_MESSAGE>}
107
+ */
108
+ export class WorkflowChatTransport<
109
+ UI_MESSAGE extends UIMessage,
110
+ > implements ChatTransport<UI_MESSAGE> {
111
+ private readonly api: string;
112
+ private readonly fetch: typeof fetch;
113
+ private readonly onChatSendMessage?: OnChatSendMessage<UI_MESSAGE>;
114
+ private readonly onChatEnd?: OnChatEnd;
115
+ private readonly maxConsecutiveErrors: number;
116
+ private readonly prepareSendMessagesRequest?: PrepareSendMessagesRequest<UI_MESSAGE>;
117
+ private readonly prepareReconnectToStreamRequest?: PrepareReconnectToStreamRequest;
118
+
119
+ /**
120
+ * Creates a new WorkflowChatTransport instance.
121
+ *
122
+ * @param options - Configuration options for the transport
123
+ * @param options.api - API endpoint for chat requests (defaults to '/api/chat')
124
+ * @param options.fetch - Custom fetch implementation (defaults to global fetch)
125
+ * @param options.onChatSendMessage - Callback after sending messages
126
+ * @param options.onChatEnd - Callback when chat stream ends
127
+ * @param options.maxConsecutiveErrors - Maximum consecutive errors for reconnection
128
+ * @param options.prepareSendMessagesRequest - Function to prepare send messages request
129
+ * @param options.prepareReconnectToStreamRequest - Function to prepare reconnect request
130
+ */
131
+ constructor(options: WorkflowChatTransportOptions<UI_MESSAGE> = {}) {
132
+ this.api = options.api ?? '/api/chat';
133
+ this.fetch = options.fetch ?? fetch.bind(globalThis);
134
+ this.onChatSendMessage = options.onChatSendMessage;
135
+ this.onChatEnd = options.onChatEnd;
136
+ this.maxConsecutiveErrors = options.maxConsecutiveErrors ?? 3;
137
+ this.prepareSendMessagesRequest = options.prepareSendMessagesRequest;
138
+ this.prepareReconnectToStreamRequest =
139
+ options.prepareReconnectToStreamRequest;
140
+ }
141
+
142
+ /**
143
+ * Sends messages to the chat endpoint and returns a stream of response chunks.
144
+ *
145
+ * This method handles the entire chat lifecycle including:
146
+ * - Sending messages to the /api/chat endpoint
147
+ * - Streaming response chunks
148
+ * - Automatic reconnection if the stream is interrupted
149
+ *
150
+ * @param options - Options for sending messages
151
+ * @param options.trigger - The type of message submission ('submit-message' or 'regenerate-message')
152
+ * @param options.chatId - Unique identifier for this chat session
153
+ * @param options.messageId - Optional message ID for tracking specific messages
154
+ * @param options.messages - Array of UI messages to send
155
+ * @param options.abortSignal - Optional AbortSignal to cancel the request
156
+ *
157
+ * @returns A ReadableStream of UIMessageChunk objects containing the response
158
+ * @throws Error if the fetch request fails or returns a non-OK status
159
+ */
160
+ async sendMessages(
161
+ options: SendMessagesOptions<UI_MESSAGE> & ChatRequestOptions,
162
+ ): Promise<ReadableStream<UIMessageChunk>> {
163
+ return iteratorToStream(this.sendMessagesIterator(options), {
164
+ signal: options.abortSignal,
165
+ });
166
+ }
167
+
168
+ private async *sendMessagesIterator(
169
+ options: SendMessagesOptions<UI_MESSAGE> & ChatRequestOptions,
170
+ ): AsyncGenerator<UIMessageChunk> {
171
+ const { chatId, messages, abortSignal, trigger, messageId } = options;
172
+
173
+ // We keep track of if the "finish" chunk is received to determine
174
+ // if we need to reconnect, and keep track of the chunk index to resume from.
175
+ let gotFinish = false;
176
+ let chunkIndex = 0;
177
+
178
+ // Prepare the request using the configurator if provided
179
+ const requestConfig = this.prepareSendMessagesRequest
180
+ ? await this.prepareSendMessagesRequest({
181
+ id: chatId,
182
+ messages,
183
+ requestMetadata: options.metadata,
184
+ body: options.body,
185
+ credentials: undefined,
186
+ headers: options.headers,
187
+ api: this.api,
188
+ trigger,
189
+ messageId,
190
+ })
191
+ : undefined;
192
+
193
+ const url = requestConfig?.api ?? this.api;
194
+ const res = await this.fetch(url, {
195
+ method: 'POST',
196
+ body: JSON.stringify(
197
+ requestConfig?.body ?? { messages, ...options.body },
198
+ ),
199
+ headers: requestConfig?.headers,
200
+ credentials: requestConfig?.credentials,
201
+ signal: abortSignal,
202
+ });
203
+
204
+ if (!res.ok || !res.body) {
205
+ throw new Error(
206
+ `Failed to fetch chat: ${res.status} ${await res.text()}`,
207
+ );
208
+ }
209
+
210
+ const workflowRunId = res.headers.get('x-workflow-run-id');
211
+ if (!workflowRunId) {
212
+ throw new Error(
213
+ 'Workflow run ID not found in "x-workflow-run-id" response header',
214
+ );
215
+ }
216
+
217
+ // Notify the caller that the chat POST request was sent.
218
+ // This is useful for tracking the chat history on the client
219
+ // side and allows for inspecting response headers.
220
+ await this.onChatSendMessage?.(res, options);
221
+
222
+ // Flush the initial stream until the end or an error occurs
223
+ try {
224
+ const chunkStream = parseJsonEventStream({
225
+ stream: res.body,
226
+ schema: uiMessageChunkSchema,
227
+ });
228
+ for await (const chunk of streamToIterator(chunkStream)) {
229
+ if (!chunk.success) {
230
+ throw chunk.error;
231
+ }
232
+
233
+ chunkIndex++;
234
+
235
+ yield chunk.value;
236
+
237
+ if (chunk.value.type === 'finish') {
238
+ gotFinish = true;
239
+ }
240
+ }
241
+ } catch (error) {
242
+ console.error('Error in chat POST stream', error);
243
+ }
244
+
245
+ if (gotFinish) {
246
+ await this.onFinish(gotFinish, { chatId, chunkIndex });
247
+ } else {
248
+ // If the initial POST request did not include the "finish" chunk,
249
+ // we need to reconnect to the stream. This could indicate that a
250
+ // network error occurred or the Vercel Function timed out.
251
+ yield* this.reconnectToStreamIterator(options, workflowRunId, chunkIndex);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Reconnects to an existing chat stream that was previously interrupted.
257
+ *
258
+ * This method is useful for resuming a chat session after network issues,
259
+ * page refreshes, or Vercel Function timeouts.
260
+ *
261
+ * @param options - Options for reconnecting to the stream
262
+ * @param options.chatId - The chat ID to reconnect to
263
+ *
264
+ * @returns A ReadableStream of UIMessageChunk objects
265
+ * @throws Error if the reconnection request fails or returns a non-OK status
266
+ */
267
+ async reconnectToStream(
268
+ options: ReconnectToStreamOptions & ChatRequestOptions,
269
+ ): Promise<ReadableStream<UIMessageChunk> | null> {
270
+ const it = this.reconnectToStreamIterator(options);
271
+ return iteratorToStream(it, { signal: options.abortSignal });
272
+ }
273
+
274
+ private async *reconnectToStreamIterator(
275
+ options: ReconnectToStreamOptions & ChatRequestOptions,
276
+ workflowRunId?: string,
277
+ initialChunkIndex = 0,
278
+ ): AsyncGenerator<UIMessageChunk> {
279
+ let chunkIndex = initialChunkIndex;
280
+
281
+ const defaultApi = `${this.api}/${encodeURIComponent(workflowRunId ?? options.chatId)}/stream`;
282
+
283
+ // Prepare the request using the configurator if provided
284
+ const requestConfig = this.prepareReconnectToStreamRequest
285
+ ? await this.prepareReconnectToStreamRequest({
286
+ id: options.chatId,
287
+ requestMetadata: options.metadata,
288
+ body: undefined,
289
+ credentials: undefined,
290
+ headers: undefined,
291
+ api: defaultApi,
292
+ })
293
+ : undefined;
294
+
295
+ const baseUrl = requestConfig?.api ?? defaultApi;
296
+
297
+ let gotFinish = false;
298
+ let consecutiveErrors = 0;
299
+
300
+ while (!gotFinish) {
301
+ const url = `${baseUrl}?startIndex=${chunkIndex}`;
302
+ const res = await this.fetch(url, {
303
+ headers: requestConfig?.headers,
304
+ credentials: requestConfig?.credentials,
305
+ signal: options.abortSignal,
306
+ });
307
+
308
+ if (!res.ok || !res.body) {
309
+ throw new Error(
310
+ `Failed to fetch chat: ${res.status} ${await res.text()}`,
311
+ );
312
+ }
313
+
314
+ try {
315
+ const chunkStream = parseJsonEventStream({
316
+ stream: res.body,
317
+ schema: uiMessageChunkSchema,
318
+ });
319
+ for await (const chunk of streamToIterator(chunkStream)) {
320
+ if (!chunk.success) {
321
+ throw chunk.error;
322
+ }
323
+
324
+ chunkIndex++;
325
+
326
+ yield chunk.value;
327
+
328
+ if (chunk.value.type === 'finish') {
329
+ gotFinish = true;
330
+ }
331
+ }
332
+ // Reset consecutive error count only after successful stream parsing
333
+ consecutiveErrors = 0;
334
+ } catch (error) {
335
+ console.error('Error in chat GET reconnectToStream', error);
336
+ consecutiveErrors++;
337
+
338
+ if (consecutiveErrors >= this.maxConsecutiveErrors) {
339
+ throw new Error(
340
+ `Failed to reconnect after ${this.maxConsecutiveErrors} consecutive errors. Last error: ${error instanceof Error ? error.message : String(error)}`,
341
+ );
342
+ }
343
+ }
344
+ }
345
+
346
+ await this.onFinish(gotFinish, { chatId: options.chatId, chunkIndex });
347
+ }
348
+
349
+ private async onFinish(
350
+ gotFinish: boolean,
351
+ { chatId, chunkIndex }: { chatId: string; chunkIndex: number },
352
+ ) {
353
+ if (gotFinish) {
354
+ await this.onChatEnd?.({ chatId, chunkIndex });
355
+ } else {
356
+ throw new Error('No finish chunk received');
357
+ }
358
+ }
359
+ }