@tanstack/ai-client 0.0.1

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,344 @@
1
+ import { convertMessagesToModelMessages } from '@tanstack/ai'
2
+ import type { ModelMessage, StreamChunk, UIMessage } from '@tanstack/ai'
3
+
4
+ /**
5
+ * Merge custom headers into request headers
6
+ */
7
+ function mergeHeaders(
8
+ customHeaders?: Record<string, string> | Headers,
9
+ ): Record<string, string> {
10
+ if (!customHeaders) {
11
+ return {}
12
+ }
13
+ if (customHeaders instanceof Headers) {
14
+ const result: Record<string, string> = {}
15
+ customHeaders.forEach((value, key) => {
16
+ result[key] = value
17
+ })
18
+ return result
19
+ }
20
+ return customHeaders
21
+ }
22
+
23
+ /**
24
+ * Read lines from a stream (newline-delimited)
25
+ */
26
+ async function* readStreamLines(
27
+ reader: ReadableStreamDefaultReader<Uint8Array>,
28
+ abortSignal?: AbortSignal,
29
+ ): AsyncGenerator<string> {
30
+ try {
31
+ const decoder = new TextDecoder()
32
+ let buffer = ''
33
+
34
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
35
+ while (true) {
36
+ // Check if aborted before reading
37
+ if (abortSignal?.aborted) {
38
+ break
39
+ }
40
+
41
+ const { done, value } = await reader.read()
42
+ if (done) break
43
+
44
+ buffer += decoder.decode(value, { stream: true })
45
+ const lines = buffer.split('\n')
46
+
47
+ // Keep the last incomplete line in the buffer
48
+ buffer = lines.pop() || ''
49
+
50
+ for (const line of lines) {
51
+ if (line.trim()) {
52
+ yield line
53
+ }
54
+ }
55
+ }
56
+
57
+ // Process any remaining data in the buffer
58
+ if (buffer.trim()) {
59
+ yield buffer
60
+ }
61
+ } finally {
62
+ reader.releaseLock()
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Connection adapter interface - converts a connection into a stream of chunks
68
+ */
69
+ export interface ConnectionAdapter {
70
+ /**
71
+ * Connect and return an async iterable of StreamChunks
72
+ * @param messages - The messages to send (UIMessages or ModelMessages)
73
+ * @param data - Additional data to send
74
+ * @param abortSignal - Optional abort signal for request cancellation
75
+ */
76
+ connect: (
77
+ messages: Array<UIMessage> | Array<ModelMessage>,
78
+ data?: Record<string, any>,
79
+ abortSignal?: AbortSignal,
80
+ ) => AsyncIterable<StreamChunk>
81
+ }
82
+
83
+ /**
84
+ * Options for fetch-based connection adapters
85
+ */
86
+ export interface FetchConnectionOptions {
87
+ headers?: Record<string, string> | Headers
88
+ credentials?: RequestCredentials
89
+ signal?: AbortSignal
90
+ body?: Record<string, any>
91
+ }
92
+
93
+ /**
94
+ * Create a Server-Sent Events connection adapter
95
+ *
96
+ * @param url - The API endpoint URL (or a function that returns the URL)
97
+ * @param options - Fetch options (headers, credentials, body, etc.) or a function that returns options (can be async)
98
+ * @returns A connection adapter for SSE streams
99
+ *
100
+ * @example
101
+ * ```typescript
102
+ * // Static URL
103
+ * const connection = fetchServerSentEvents('/api/chat');
104
+ *
105
+ * // Dynamic URL
106
+ * const connection = fetchServerSentEvents(() => `/api/chat?user=${userId}`);
107
+ *
108
+ * // With options
109
+ * const connection = fetchServerSentEvents('/api/chat', {
110
+ * headers: { 'Authorization': 'Bearer token' }
111
+ * });
112
+ *
113
+ * // With dynamic options
114
+ * const connection = fetchServerSentEvents('/api/chat', () => ({
115
+ * headers: { 'Authorization': `Bearer ${getToken()}` }
116
+ * }));
117
+ *
118
+ * // With additional body data
119
+ * const connection = fetchServerSentEvents('/api/chat', async () => ({
120
+ * body: {
121
+ * provider: 'openai',
122
+ * model: 'gpt-4o',
123
+ * }
124
+ * }));
125
+ * ```
126
+ */
127
+ export function fetchServerSentEvents(
128
+ url: string | (() => string),
129
+ options:
130
+ | FetchConnectionOptions
131
+ | (() => FetchConnectionOptions | Promise<FetchConnectionOptions>) = {},
132
+ ): ConnectionAdapter {
133
+ return {
134
+ async *connect(messages, data, abortSignal) {
135
+ // Resolve URL and options if they are functions
136
+ const resolvedUrl = typeof url === 'function' ? url() : url
137
+ const resolvedOptions =
138
+ typeof options === 'function' ? await options() : options
139
+
140
+ const modelMessages = convertMessagesToModelMessages(messages)
141
+
142
+ const requestHeaders: Record<string, string> = {
143
+ 'Content-Type': 'application/json',
144
+ ...mergeHeaders(resolvedOptions.headers),
145
+ }
146
+
147
+ // Merge body from options with messages and data
148
+ const requestBody = {
149
+ messages: modelMessages,
150
+ data,
151
+ ...resolvedOptions.body,
152
+ }
153
+
154
+ const response = await fetch(resolvedUrl, {
155
+ method: 'POST',
156
+ headers: requestHeaders,
157
+ body: JSON.stringify(requestBody),
158
+ credentials: resolvedOptions.credentials || 'same-origin',
159
+ signal: abortSignal || resolvedOptions.signal,
160
+ })
161
+
162
+ if (!response.ok) {
163
+ throw new Error(
164
+ `HTTP error! status: ${response.status} ${response.statusText}`,
165
+ )
166
+ }
167
+
168
+ // Parse Server-Sent Events format
169
+ const reader = response.body?.getReader()
170
+ if (!reader) {
171
+ throw new Error('Response body is not readable')
172
+ }
173
+
174
+ for await (const line of readStreamLines(reader, abortSignal)) {
175
+ // Handle Server-Sent Events format
176
+ const data = line.startsWith('data: ') ? line.slice(6) : line
177
+
178
+ if (data === '[DONE]') continue
179
+
180
+ try {
181
+ const parsed: StreamChunk = JSON.parse(data)
182
+ yield parsed
183
+ } catch (parseError) {
184
+ // Skip non-JSON lines or malformed chunks
185
+ console.warn('Failed to parse SSE chunk:', data)
186
+ }
187
+ }
188
+ },
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Create an HTTP streaming connection adapter (for raw streaming without SSE format)
194
+ *
195
+ * @param url - The API endpoint URL (or a function that returns the URL)
196
+ * @param options - Fetch options (headers, credentials, body, etc.) or a function that returns options (can be async)
197
+ * @returns A connection adapter for HTTP streams
198
+ *
199
+ * @example
200
+ * ```typescript
201
+ * // Static URL
202
+ * const connection = fetchHttpStream('/api/chat');
203
+ *
204
+ * // Dynamic URL
205
+ * const connection = fetchHttpStream(() => `/api/chat?user=${userId}`);
206
+ *
207
+ * // With options
208
+ * const connection = fetchHttpStream('/api/chat', {
209
+ * headers: { 'Authorization': 'Bearer token' }
210
+ * });
211
+ *
212
+ * // With dynamic options
213
+ * const connection = fetchHttpStream('/api/chat', () => ({
214
+ * headers: { 'Authorization': `Bearer ${getToken()}` }
215
+ * }));
216
+ *
217
+ * // With additional body data
218
+ * const connection = fetchHttpStream('/api/chat', async () => ({
219
+ * body: {
220
+ * provider: 'openai',
221
+ * model: 'gpt-4o',
222
+ * }
223
+ * }));
224
+ * ```
225
+ */
226
+ export function fetchHttpStream(
227
+ url: string | (() => string),
228
+ options:
229
+ | FetchConnectionOptions
230
+ | (() => FetchConnectionOptions | Promise<FetchConnectionOptions>) = {},
231
+ ): ConnectionAdapter {
232
+ return {
233
+ async *connect(messages, data, abortSignal) {
234
+ // Resolve URL and options if they are functions
235
+ const resolvedUrl = typeof url === 'function' ? url() : url
236
+ const resolvedOptions =
237
+ typeof options === 'function' ? await options() : options
238
+
239
+ // Convert UIMessages to ModelMessages if needed
240
+ const modelMessages = convertMessagesToModelMessages(messages)
241
+
242
+ const requestHeaders: Record<string, string> = {
243
+ 'Content-Type': 'application/json',
244
+ ...mergeHeaders(resolvedOptions.headers),
245
+ }
246
+
247
+ // Merge body from options with messages and data
248
+ const requestBody = {
249
+ messages: modelMessages,
250
+ data,
251
+ ...resolvedOptions.body,
252
+ }
253
+
254
+ const response = await fetch(resolvedUrl, {
255
+ method: 'POST',
256
+ headers: requestHeaders,
257
+ body: JSON.stringify(requestBody),
258
+ credentials: resolvedOptions.credentials || 'same-origin',
259
+ signal: abortSignal || resolvedOptions.signal,
260
+ })
261
+
262
+ if (!response.ok) {
263
+ throw new Error(
264
+ `HTTP error! status: ${response.status} ${response.statusText}`,
265
+ )
266
+ }
267
+
268
+ // Parse raw HTTP stream (newline-delimited JSON)
269
+ const reader = response.body?.getReader()
270
+ if (!reader) {
271
+ throw new Error('Response body is not readable')
272
+ }
273
+
274
+ for await (const line of readStreamLines(reader, abortSignal)) {
275
+ try {
276
+ const parsed: StreamChunk = JSON.parse(line)
277
+ yield parsed
278
+ } catch (parseError) {
279
+ console.warn('Failed to parse HTTP stream chunk:', line)
280
+ }
281
+ }
282
+ },
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Create a direct stream connection adapter (for server functions or direct streams)
288
+ *
289
+ * @param streamFactory - A function that returns an async iterable of StreamChunks
290
+ * @returns A connection adapter for direct streams
291
+ *
292
+ * @example
293
+ * ```typescript
294
+ * // With TanStack Start server function
295
+ * const connection = stream(() => serverFunction({ messages }));
296
+ *
297
+ * const client = new ChatClient({ connection });
298
+ * ```
299
+ */
300
+ export function stream(
301
+ streamFactory: (
302
+ messages: Array<ModelMessage>,
303
+ data?: Record<string, any>,
304
+ ) => AsyncIterable<StreamChunk>,
305
+ ): ConnectionAdapter {
306
+ return {
307
+ async *connect(messages, data) {
308
+ const modelMessages = convertMessagesToModelMessages(messages)
309
+ yield* streamFactory(modelMessages, data)
310
+ },
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Create an RPC stream connection adapter (for RPC-based streaming like Cap'n Web RPC)
316
+ *
317
+ * @param rpcCall - A function that accepts messages and returns an async iterable of StreamChunks
318
+ * @returns A connection adapter for RPC streams
319
+ *
320
+ * @example
321
+ * ```typescript
322
+ * // With Cap'n Web RPC
323
+ * const connection = rpcStream((messages, data) =>
324
+ * api.streamMurfResponse(messages, data)
325
+ * );
326
+ *
327
+ * const client = new ChatClient({ connection });
328
+ * ```
329
+ */
330
+ export function rpcStream(
331
+ rpcCall: (
332
+ messages: Array<ModelMessage>,
333
+ data?: Record<string, any>,
334
+ ) => AsyncIterable<StreamChunk>,
335
+ ): ConnectionAdapter {
336
+ return {
337
+ async *connect(messages, data) {
338
+ const modelMessages = convertMessagesToModelMessages(messages)
339
+ // Simply yield from the RPC call
340
+ // The RPC layer handles WebSocket transport
341
+ yield* rpcCall(modelMessages, data)
342
+ },
343
+ }
344
+ }
package/src/events.ts ADDED
@@ -0,0 +1,252 @@
1
+ import { aiEventClient } from '@tanstack/ai/event-client'
2
+ import type { UIMessage } from './types'
3
+
4
+ /**
5
+ * Abstract base class for ChatClient event emission
6
+ */
7
+ export abstract class ChatClientEventEmitter {
8
+ protected clientId: string
9
+
10
+ constructor(clientId: string) {
11
+ this.clientId = clientId
12
+ }
13
+
14
+ /**
15
+ * Protected abstract method for emitting events
16
+ * Implementations should handle adding clientId and timestamp
17
+ */
18
+ protected abstract emitEvent(
19
+ eventName: string,
20
+ data?: Record<string, any>,
21
+ ): void
22
+
23
+ /**
24
+ * Emit client created event
25
+ */
26
+ clientCreated(initialMessageCount: number): void {
27
+ this.emitEvent('client:created', {
28
+ initialMessageCount,
29
+ })
30
+ }
31
+
32
+ /**
33
+ * Emit loading state changed event
34
+ */
35
+ loadingChanged(isLoading: boolean): void {
36
+ this.emitEvent('client:loading-changed', { isLoading })
37
+ }
38
+
39
+ /**
40
+ * Emit error state changed event
41
+ */
42
+ errorChanged(error: string | null): void {
43
+ this.emitEvent('client:error-changed', {
44
+ error,
45
+ })
46
+ }
47
+
48
+ /**
49
+ * Emit text update events (combines processor and client events)
50
+ */
51
+ textUpdated(streamId: string, messageId: string, content: string): void {
52
+ this.emitEvent('processor:text-updated', {
53
+ streamId,
54
+ content,
55
+ })
56
+
57
+ this.emitEvent('client:assistant-message-updated', {
58
+ messageId,
59
+ content,
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Emit tool call state change events (combines processor and client events)
65
+ */
66
+ toolCallStateChanged(
67
+ streamId: string,
68
+ messageId: string,
69
+ toolCallId: string,
70
+ toolName: string,
71
+ state: string,
72
+ args: string,
73
+ ): void {
74
+ this.emitEvent('processor:tool-call-state-changed', {
75
+ streamId,
76
+ toolCallId,
77
+ toolName,
78
+ state,
79
+ arguments: args,
80
+ })
81
+
82
+ this.emitEvent('client:tool-call-updated', {
83
+ messageId,
84
+ toolCallId,
85
+ toolName,
86
+ state,
87
+ arguments: args,
88
+ })
89
+ }
90
+
91
+ /**
92
+ * Emit tool result state change event
93
+ */
94
+ toolResultStateChanged(
95
+ streamId: string,
96
+ toolCallId: string,
97
+ content: string,
98
+ state: string,
99
+ error?: string,
100
+ ): void {
101
+ this.emitEvent('processor:tool-result-state-changed', {
102
+ streamId,
103
+ toolCallId,
104
+ content,
105
+ state,
106
+ error,
107
+ })
108
+ }
109
+
110
+ /**
111
+ * Emit thinking update event
112
+ */
113
+ thinkingUpdated(
114
+ streamId: string,
115
+ messageId: string,
116
+ content: string,
117
+ delta?: string,
118
+ ): void {
119
+ this.emitEvent('stream:chunk:thinking', {
120
+ streamId,
121
+ messageId,
122
+ content,
123
+ delta,
124
+ })
125
+ }
126
+
127
+ /**
128
+ * Emit approval requested event
129
+ */
130
+ approvalRequested(
131
+ messageId: string,
132
+ toolCallId: string,
133
+ toolName: string,
134
+ input: any,
135
+ approvalId: string,
136
+ ): void {
137
+ this.emitEvent('client:approval-requested', {
138
+ messageId,
139
+ toolCallId,
140
+ toolName,
141
+ input,
142
+ approvalId,
143
+ })
144
+ }
145
+
146
+ /**
147
+ * Emit message appended event
148
+ */
149
+ messageAppended(uiMessage: UIMessage): void {
150
+ const contentPreview = uiMessage.parts
151
+ .filter((p) => p.type === 'text')
152
+ .map((p) => (p as any).content)
153
+ .join(' ')
154
+ .substring(0, 100)
155
+
156
+ this.emitEvent('client:message-appended', {
157
+ messageId: uiMessage.id,
158
+ role: uiMessage.role,
159
+ contentPreview,
160
+ })
161
+ }
162
+
163
+ /**
164
+ * Emit message sent event
165
+ */
166
+ messageSent(messageId: string, content: string): void {
167
+ this.emitEvent('client:message-sent', {
168
+ messageId,
169
+ content,
170
+ })
171
+ }
172
+
173
+ /**
174
+ * Emit reloaded event
175
+ */
176
+ reloaded(fromMessageIndex: number): void {
177
+ this.emitEvent('client:reloaded', {
178
+ fromMessageIndex,
179
+ })
180
+ }
181
+
182
+ /**
183
+ * Emit stopped event
184
+ */
185
+ stopped(): void {
186
+ this.emitEvent('client:stopped')
187
+ }
188
+
189
+ /**
190
+ * Emit messages cleared event
191
+ */
192
+ messagesCleared(): void {
193
+ this.emitEvent('client:messages-cleared')
194
+ }
195
+
196
+ /**
197
+ * Emit tool result added event
198
+ */
199
+ toolResultAdded(
200
+ toolCallId: string,
201
+ toolName: string,
202
+ output: any,
203
+ state: string,
204
+ ): void {
205
+ this.emitEvent('tool:result-added', {
206
+ toolCallId,
207
+ toolName,
208
+ output,
209
+ state,
210
+ })
211
+ }
212
+
213
+ /**
214
+ * Emit tool approval responded event
215
+ */
216
+ toolApprovalResponded(
217
+ approvalId: string,
218
+ toolCallId: string,
219
+ approved: boolean,
220
+ ): void {
221
+ this.emitEvent('tool:approval-responded', {
222
+ approvalId,
223
+ toolCallId,
224
+ approved,
225
+ })
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Default implementation of ChatClientEventEmitter
231
+ */
232
+ export class DefaultChatClientEventEmitter extends ChatClientEventEmitter {
233
+ /**
234
+ * Emit an event with automatic clientId and timestamp for client/tool events
235
+ */
236
+ protected emitEvent(eventName: string, data?: Record<string, any>): void {
237
+ // For client:* and tool:* events, automatically add clientId and timestamp
238
+ if (eventName.startsWith('client:') || eventName.startsWith('tool:')) {
239
+ aiEventClient.emit(eventName as any, {
240
+ ...data,
241
+ clientId: this.clientId,
242
+ timestamp: Date.now(),
243
+ })
244
+ } else {
245
+ // For other events (e.g., processor:*), just add timestamp
246
+ aiEventClient.emit(eventName as any, {
247
+ ...data,
248
+ timestamp: Date.now(),
249
+ })
250
+ }
251
+ }
252
+ }
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ export { ChatClient } from './chat-client'
2
+ export type {
3
+ // Core message types (re-exported from @tanstack/ai via types.ts)
4
+ UIMessage,
5
+ MessagePart,
6
+ TextPart,
7
+ ToolCallPart,
8
+ ToolResultPart,
9
+ ThinkingPart,
10
+ // Client configuration types
11
+ ChatClientOptions,
12
+ ChatRequestBody,
13
+ InferChatMessages,
14
+ } from './types'
15
+ export { clientTools, createChatClientOptions } from './types'
16
+ export type {
17
+ ExtractToolNames,
18
+ ExtractToolInput,
19
+ ExtractToolOutput,
20
+ } from './tool-types'
21
+ export type { AnyClientTool } from '@tanstack/ai'
22
+ export {
23
+ fetchServerSentEvents,
24
+ fetchHttpStream,
25
+ stream,
26
+ rpcStream,
27
+ type ConnectionAdapter,
28
+ type FetchConnectionOptions,
29
+ } from './connection-adapters'
30
+
31
+ // Re-export message converters from @tanstack/ai
32
+ export {
33
+ uiMessageToModelMessages,
34
+ modelMessageToUIMessage,
35
+ modelMessagesToUIMessages,
36
+ convertMessagesToModelMessages,
37
+ normalizeToUIMessage,
38
+ generateMessageId,
39
+ } from '@tanstack/ai'
40
+
41
+ // Re-export stream processing from @tanstack/ai (shared implementation)
42
+ export {
43
+ StreamProcessor,
44
+ ImmediateStrategy,
45
+ PunctuationStrategy,
46
+ BatchStrategy,
47
+ WordBoundaryStrategy,
48
+ CompositeStrategy,
49
+ parsePartialJSON,
50
+ PartialJSONParser,
51
+ defaultJSONParser,
52
+ type ChunkStrategy,
53
+ type StreamProcessorOptions,
54
+ type StreamProcessorHandlers,
55
+ type StreamProcessorEvents,
56
+ type InternalToolCallState,
57
+ type ToolCallState,
58
+ type ToolResultState,
59
+ type JSONParser,
60
+ type ChunkRecording,
61
+ type ProcessorResult,
62
+ type ProcessorState,
63
+ } from '@tanstack/ai'
@@ -0,0 +1,41 @@
1
+ import type {
2
+ AnyClientTool,
3
+ InferToolInput,
4
+ InferToolOutput,
5
+ } from '@tanstack/ai'
6
+
7
+ /**
8
+ * Extract all tool names from a tools array as a union type
9
+ */
10
+ export type ExtractToolNames<TTools extends ReadonlyArray<AnyClientTool>> =
11
+ TTools[number]['name']
12
+
13
+ /**
14
+ * Find a tool by name in the tools array
15
+ */
16
+ type FindTool<
17
+ TTools extends ReadonlyArray<AnyClientTool>,
18
+ TName extends string,
19
+ > = Extract<TTools[number], { name: TName }>
20
+
21
+ /**
22
+ * Extract the input type for a specific tool by name
23
+ */
24
+ export type ExtractToolInput<
25
+ TTools extends ReadonlyArray<AnyClientTool>,
26
+ TName extends string,
27
+ > =
28
+ TName extends ExtractToolNames<TTools>
29
+ ? InferToolInput<FindTool<TTools, TName>>
30
+ : any
31
+
32
+ /**
33
+ * Extract the output type for a specific tool by name
34
+ */
35
+ export type ExtractToolOutput<
36
+ TTools extends ReadonlyArray<AnyClientTool>,
37
+ TName extends string,
38
+ > =
39
+ TName extends ExtractToolNames<TTools>
40
+ ? InferToolOutput<FindTool<TTools, TName>>
41
+ : any