@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,522 @@
1
+ import {
2
+ StreamProcessor,
3
+ generateMessageId,
4
+ normalizeToUIMessage,
5
+ } from '@tanstack/ai'
6
+ import { DefaultChatClientEventEmitter } from './events'
7
+ import type {
8
+ ChatClientOptions,
9
+ MessagePart,
10
+ ToolCallPart,
11
+ UIMessage,
12
+ } from './types'
13
+ import type { AnyClientTool, ModelMessage, StreamChunk } from '@tanstack/ai'
14
+ import type { ConnectionAdapter } from './connection-adapters'
15
+ import type { ChatClientEventEmitter } from './events'
16
+
17
+ export class ChatClient {
18
+ private processor: StreamProcessor
19
+ private connection: ConnectionAdapter
20
+ private uniqueId: string
21
+ private body: Record<string, any> = {}
22
+ private isLoading = false
23
+ private error: Error | undefined = undefined
24
+ private abortController: AbortController | null = null
25
+ private events: ChatClientEventEmitter
26
+ private clientToolsRef: { current: Map<string, AnyClientTool> }
27
+ private currentStreamId: string | null = null
28
+ private currentMessageId: string | null = null
29
+
30
+ private callbacksRef: {
31
+ current: {
32
+ onResponse: (response?: Response) => void | Promise<void>
33
+ onChunk: (chunk: StreamChunk) => void
34
+ onFinish: (message: UIMessage) => void
35
+ onError: (error: Error) => void
36
+ onMessagesChange: (messages: Array<UIMessage>) => void
37
+ onLoadingChange: (isLoading: boolean) => void
38
+ onErrorChange: (error: Error | undefined) => void
39
+ }
40
+ }
41
+
42
+ constructor(options: ChatClientOptions) {
43
+ this.uniqueId = options.id || this.generateUniqueId('chat')
44
+ this.body = options.body || {}
45
+ this.connection = options.connection
46
+ this.events = new DefaultChatClientEventEmitter(this.uniqueId)
47
+
48
+ // Build client tools map
49
+ this.clientToolsRef = { current: new Map() }
50
+ if (options.tools) {
51
+ for (const tool of options.tools) {
52
+ this.clientToolsRef.current.set(tool.name, tool)
53
+ }
54
+ }
55
+
56
+ this.callbacksRef = {
57
+ current: {
58
+ onResponse: options.onResponse || (() => {}),
59
+ onChunk: options.onChunk || (() => {}),
60
+ onFinish: options.onFinish || (() => {}),
61
+ onError: options.onError || (() => {}),
62
+ onMessagesChange: options.onMessagesChange || (() => {}),
63
+ onLoadingChange: options.onLoadingChange || (() => {}),
64
+ onErrorChange: options.onErrorChange || (() => {}),
65
+ },
66
+ }
67
+
68
+ // Create StreamProcessor with event handlers
69
+ this.processor = new StreamProcessor({
70
+ chunkStrategy: options.streamProcessor?.chunkStrategy,
71
+ initialMessages: options.initialMessages,
72
+ events: {
73
+ onMessagesChange: (messages: Array<UIMessage>) => {
74
+ this.callbacksRef.current.onMessagesChange(messages)
75
+ },
76
+ onStreamStart: () => {
77
+ // Stream started
78
+ },
79
+ onStreamEnd: (message: UIMessage) => {
80
+ this.callbacksRef.current.onFinish(message)
81
+ },
82
+ onError: (error: Error) => {
83
+ this.setError(error)
84
+ this.callbacksRef.current.onError(error)
85
+ },
86
+ onTextUpdate: (messageId: string, content: string) => {
87
+ // Emit text update to devtools
88
+ if (this.currentStreamId) {
89
+ this.events.textUpdated(this.currentStreamId, messageId, content)
90
+ }
91
+ },
92
+ onThinkingUpdate: (messageId: string, content: string) => {
93
+ // Emit thinking update to devtools
94
+ if (this.currentStreamId) {
95
+ this.events.thinkingUpdated(
96
+ this.currentStreamId,
97
+ messageId,
98
+ content,
99
+ )
100
+ }
101
+ },
102
+ onToolCallStateChange: (
103
+ messageId: string,
104
+ toolCallId: string,
105
+ state: string,
106
+ args: string,
107
+ ) => {
108
+ // Get the tool name from the messages
109
+ const messages = this.processor.getMessages()
110
+ const message = messages.find((m: UIMessage) => m.id === messageId)
111
+ const toolCallPart = message?.parts.find(
112
+ (p: MessagePart): p is ToolCallPart =>
113
+ p.type === 'tool-call' && p.id === toolCallId,
114
+ )
115
+ const toolName = toolCallPart?.name || 'unknown'
116
+
117
+ // Emit tool call state change to devtools
118
+ if (this.currentStreamId) {
119
+ this.events.toolCallStateChanged(
120
+ this.currentStreamId,
121
+ messageId,
122
+ toolCallId,
123
+ toolName,
124
+ state,
125
+ args,
126
+ )
127
+ }
128
+ },
129
+ onToolCall: async (args: {
130
+ toolCallId: string
131
+ toolName: string
132
+ input: any
133
+ }) => {
134
+ // Handle client-side tool execution automatically
135
+ const clientTool = this.clientToolsRef.current.get(args.toolName)
136
+ if (clientTool?.execute) {
137
+ try {
138
+ const output = await clientTool.execute(args.input)
139
+ await this.addToolResult({
140
+ toolCallId: args.toolCallId,
141
+ tool: args.toolName,
142
+ output,
143
+ state: 'output-available',
144
+ })
145
+ } catch (error: any) {
146
+ await this.addToolResult({
147
+ toolCallId: args.toolCallId,
148
+ tool: args.toolName,
149
+ output: null,
150
+ state: 'output-error',
151
+ errorText: error.message,
152
+ })
153
+ }
154
+ }
155
+ },
156
+ onApprovalRequest: (args: {
157
+ toolCallId: string
158
+ toolName: string
159
+ input: any
160
+ approvalId: string
161
+ }) => {
162
+ this.events.approvalRequested(
163
+ this.currentMessageId || '',
164
+ args.toolCallId,
165
+ args.toolName,
166
+ args.input,
167
+ args.approvalId,
168
+ )
169
+ },
170
+ },
171
+ })
172
+
173
+ this.events.clientCreated(this.processor.getMessages().length)
174
+ }
175
+
176
+ private generateUniqueId(prefix: string): string {
177
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
178
+ }
179
+
180
+ private setIsLoading(isLoading: boolean): void {
181
+ this.isLoading = isLoading
182
+ this.callbacksRef.current.onLoadingChange(isLoading)
183
+ this.events.loadingChanged(isLoading)
184
+ }
185
+
186
+ private setError(error: Error | undefined): void {
187
+ this.error = error
188
+ this.callbacksRef.current.onErrorChange(error)
189
+ this.events.errorChanged(error?.message || null)
190
+ }
191
+
192
+ /**
193
+ * Process a stream through the StreamProcessor
194
+ */
195
+ private async processStream(
196
+ source: AsyncIterable<StreamChunk>,
197
+ ): Promise<UIMessage> {
198
+ // Generate a stream ID for this streaming operation
199
+ this.currentStreamId = this.generateUniqueId('stream')
200
+
201
+ // Start a new assistant message
202
+ const messageId = this.processor.startAssistantMessage()
203
+ this.currentMessageId = messageId
204
+
205
+ // Emit message appended event for the new assistant message
206
+ const assistantMessage: UIMessage = {
207
+ id: messageId,
208
+ role: 'assistant',
209
+ parts: [],
210
+ createdAt: new Date(),
211
+ }
212
+ this.events.messageAppended(assistantMessage)
213
+
214
+ // Process each chunk
215
+ for await (const chunk of source) {
216
+ this.callbacksRef.current.onChunk(chunk)
217
+ this.processor.processChunk(chunk)
218
+
219
+ // Yield control back to event loop to allow UI updates
220
+ await new Promise((resolve) => setTimeout(resolve, 0))
221
+ }
222
+
223
+ // Finalize the stream
224
+ this.processor.finalizeStream()
225
+
226
+ // Clear the current stream and message IDs
227
+ this.currentStreamId = null
228
+ this.currentMessageId = null
229
+
230
+ // Return the assistant message
231
+ const messages = this.processor.getMessages()
232
+ const finalAssistantMessage = messages.find(
233
+ (m: UIMessage) => m.id === messageId,
234
+ )
235
+
236
+ return (
237
+ finalAssistantMessage || {
238
+ id: messageId,
239
+ role: 'assistant',
240
+ parts: [],
241
+ createdAt: new Date(),
242
+ }
243
+ )
244
+ }
245
+
246
+ /**
247
+ * Send a message and stream the response
248
+ */
249
+ async sendMessage(content: string): Promise<void> {
250
+ if (!content.trim() || this.isLoading) {
251
+ return
252
+ }
253
+
254
+ // Add user message via processor
255
+ const userMessage = this.processor.addUserMessage(content.trim())
256
+ this.events.messageSent(userMessage.id, content.trim())
257
+
258
+ await this.streamResponse()
259
+ }
260
+
261
+ /**
262
+ * Append a message and stream the response
263
+ */
264
+ async append(message: UIMessage | ModelMessage): Promise<void> {
265
+ // Normalize the message to ensure it has id and createdAt
266
+ const normalizedMessage = normalizeToUIMessage(message, generateMessageId)
267
+
268
+ // Skip system messages - they're handled via systemPrompts, not UIMessages
269
+ if (normalizedMessage.role === 'system') {
270
+ return
271
+ }
272
+
273
+ // Type assertion: after checking for system, we know it's user or assistant
274
+ const uiMessage = normalizedMessage as UIMessage
275
+
276
+ // Emit message appended event
277
+ this.events.messageAppended(uiMessage)
278
+
279
+ // Add to messages
280
+ const messages = this.processor.getMessages()
281
+ this.processor.setMessages([...messages, uiMessage])
282
+
283
+ await this.streamResponse()
284
+ }
285
+
286
+ /**
287
+ * Stream a response from the LLM
288
+ */
289
+ private async streamResponse(): Promise<void> {
290
+ this.setIsLoading(true)
291
+ this.setError(undefined)
292
+ this.abortController = new AbortController()
293
+
294
+ try {
295
+ // Get model messages for the LLM
296
+ const modelMessages = this.processor.toModelMessages()
297
+
298
+ // Call onResponse callback
299
+ await this.callbacksRef.current.onResponse()
300
+
301
+ // Include conversationId in the body for server-side event correlation
302
+ const bodyWithConversationId = {
303
+ ...this.body,
304
+ conversationId: this.uniqueId,
305
+ }
306
+
307
+ // Connect and stream
308
+ const stream = this.connection.connect(
309
+ modelMessages,
310
+ bodyWithConversationId,
311
+ this.abortController.signal,
312
+ )
313
+
314
+ await this.processStream(stream)
315
+ } catch (err) {
316
+ if (err instanceof Error) {
317
+ if (err.name === 'AbortError') {
318
+ return
319
+ }
320
+ this.setError(err)
321
+ this.callbacksRef.current.onError(err)
322
+ }
323
+ } finally {
324
+ this.abortController = null
325
+ this.setIsLoading(false)
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Reload the last assistant message
331
+ */
332
+ async reload(): Promise<void> {
333
+ const messages = this.processor.getMessages()
334
+ if (messages.length === 0) return
335
+
336
+ // Find the last user message
337
+ const lastUserMessageIndex = messages.findLastIndex(
338
+ (m: UIMessage) => m.role === 'user',
339
+ )
340
+
341
+ if (lastUserMessageIndex === -1) return
342
+
343
+ this.events.reloaded(lastUserMessageIndex)
344
+
345
+ // Remove all messages after the last user message
346
+ this.processor.removeMessagesAfter(lastUserMessageIndex)
347
+
348
+ // Resend
349
+ await this.streamResponse()
350
+ }
351
+
352
+ /**
353
+ * Stop the current stream
354
+ */
355
+ stop(): void {
356
+ if (this.abortController) {
357
+ this.abortController.abort()
358
+ this.abortController = null
359
+ }
360
+ this.setIsLoading(false)
361
+ this.events.stopped()
362
+ }
363
+
364
+ /**
365
+ * Clear all messages
366
+ */
367
+ clear(): void {
368
+ this.processor.clearMessages()
369
+ this.setError(undefined)
370
+ this.events.messagesCleared()
371
+ }
372
+
373
+ /**
374
+ * Add the result of a client-side tool execution
375
+ */
376
+ async addToolResult(result: {
377
+ toolCallId: string
378
+ tool: string
379
+ output: any
380
+ state?: 'output-available' | 'output-error'
381
+ errorText?: string
382
+ }): Promise<void> {
383
+ this.events.toolResultAdded(
384
+ result.toolCallId,
385
+ result.tool,
386
+ result.output,
387
+ result.state || 'output-available',
388
+ )
389
+
390
+ // Add result via processor
391
+ this.processor.addToolResult(
392
+ result.toolCallId,
393
+ result.output,
394
+ result.errorText,
395
+ )
396
+
397
+ // Check if we should auto-send
398
+ if (this.shouldAutoSend()) {
399
+ await this.continueFlow()
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Respond to a tool approval request
405
+ */
406
+ async addToolApprovalResponse(response: {
407
+ id: string // approval.id, not toolCallId
408
+ approved: boolean
409
+ }): Promise<void> {
410
+ // Find the tool call ID from the approval ID
411
+ const messages = this.processor.getMessages()
412
+ let foundToolCallId: string | undefined
413
+
414
+ for (const msg of messages) {
415
+ const toolCallPart = msg.parts.find(
416
+ (p: MessagePart): p is ToolCallPart =>
417
+ p.type === 'tool-call' && p.approval?.id === response.id,
418
+ )
419
+ if (toolCallPart) {
420
+ foundToolCallId = toolCallPart.id
421
+ break
422
+ }
423
+ }
424
+
425
+ if (foundToolCallId) {
426
+ this.events.toolApprovalResponded(
427
+ response.id,
428
+ foundToolCallId,
429
+ response.approved,
430
+ )
431
+ }
432
+
433
+ // Add response via processor
434
+ this.processor.addToolApprovalResponse(response.id, response.approved)
435
+
436
+ // Check if we should auto-send
437
+ if (this.shouldAutoSend()) {
438
+ await this.continueFlow()
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Continue the agent flow with current messages
444
+ */
445
+ private async continueFlow(): Promise<void> {
446
+ if (this.isLoading) return
447
+ await this.streamResponse()
448
+ }
449
+
450
+ /**
451
+ * Check if all tool calls are complete and we should auto-send
452
+ */
453
+ private shouldAutoSend(): boolean {
454
+ return this.processor.areAllToolsComplete()
455
+ }
456
+
457
+ /**
458
+ * Get current messages
459
+ */
460
+ getMessages(): Array<UIMessage> {
461
+ return this.processor.getMessages()
462
+ }
463
+
464
+ /**
465
+ * Get loading state
466
+ */
467
+ getIsLoading(): boolean {
468
+ return this.isLoading
469
+ }
470
+
471
+ /**
472
+ * Get current error
473
+ */
474
+ getError(): Error | undefined {
475
+ return this.error
476
+ }
477
+
478
+ /**
479
+ * Manually set messages
480
+ */
481
+ setMessagesManually(messages: Array<UIMessage>): void {
482
+ this.processor.setMessages(messages)
483
+ }
484
+
485
+ /**
486
+ * Update options refs (for use in React hooks to avoid recreating client)
487
+ */
488
+ updateOptions(options: {
489
+ connection?: ConnectionAdapter
490
+ body?: Record<string, any>
491
+ tools?: ReadonlyArray<AnyClientTool>
492
+ onResponse?: (response?: Response) => void | Promise<void>
493
+ onChunk?: (chunk: StreamChunk) => void
494
+ onFinish?: (message: UIMessage) => void
495
+ onError?: (error: Error) => void
496
+ }): void {
497
+ if (options.connection !== undefined) {
498
+ this.connection = options.connection
499
+ }
500
+ if (options.body !== undefined) {
501
+ this.body = options.body
502
+ }
503
+ if (options.tools !== undefined) {
504
+ this.clientToolsRef.current = new Map()
505
+ for (const tool of options.tools) {
506
+ this.clientToolsRef.current.set(tool.name, tool)
507
+ }
508
+ }
509
+ if (options.onResponse !== undefined) {
510
+ this.callbacksRef.current.onResponse = options.onResponse
511
+ }
512
+ if (options.onChunk !== undefined) {
513
+ this.callbacksRef.current.onChunk = options.onChunk
514
+ }
515
+ if (options.onFinish !== undefined) {
516
+ this.callbacksRef.current.onFinish = options.onFinish
517
+ }
518
+ if (options.onError !== undefined) {
519
+ this.callbacksRef.current.onError = options.onError
520
+ }
521
+ }
522
+ }