@tanstack/ai-client 0.5.3 → 0.7.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,526 @@
1
+ import { convertSchemaToJsonSchema } from '@tanstack/ai'
2
+ import type {
3
+ AnyClientTool,
4
+ AudioVisualization,
5
+ RealtimeMessage,
6
+ RealtimeMode,
7
+ RealtimeStatus,
8
+ RealtimeToken,
9
+ } from '@tanstack/ai'
10
+ import type {
11
+ RealtimeClientOptions,
12
+ RealtimeClientState,
13
+ RealtimeConnection,
14
+ RealtimeStateChangeCallback,
15
+ } from './realtime-types'
16
+
17
+ // Token refresh buffer - refresh 1 minute before expiry
18
+ const TOKEN_REFRESH_BUFFER_MS = 60_000
19
+
20
+ /**
21
+ * Client for managing realtime voice conversations.
22
+ *
23
+ * Handles connection lifecycle, audio I/O, message state,
24
+ * and tool execution for realtime voice-to-voice AI interactions.
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * import { RealtimeClient } from '@tanstack/ai-client'
29
+ * import { openaiRealtime } from '@tanstack/ai-openai'
30
+ *
31
+ * const client = new RealtimeClient({
32
+ * getToken: () => fetch('/api/realtime-token').then(r => r.json()),
33
+ * adapter: openaiRealtime(),
34
+ * tools: [myTool.client(handler)],
35
+ * onMessage: (msg) => console.log('Message:', msg),
36
+ * })
37
+ *
38
+ * await client.connect()
39
+ * ```
40
+ */
41
+ export class RealtimeClient {
42
+ private options: RealtimeClientOptions
43
+ private connection: RealtimeConnection | null = null
44
+ private token: RealtimeToken | null = null
45
+ private tokenRefreshTimeout: ReturnType<typeof setTimeout> | null = null
46
+ private clientTools: Map<string, AnyClientTool>
47
+ private stateChangeCallbacks: Set<RealtimeStateChangeCallback> = new Set()
48
+ private unsubscribers: Array<() => void> = []
49
+
50
+ private state: RealtimeClientState = {
51
+ status: 'idle',
52
+ mode: 'idle',
53
+ messages: [],
54
+ pendingUserTranscript: null,
55
+ pendingAssistantTranscript: null,
56
+ error: null,
57
+ }
58
+
59
+ constructor(options: RealtimeClientOptions) {
60
+ this.options = {
61
+ autoPlayback: true,
62
+ autoCapture: true,
63
+ vadMode: 'server',
64
+ ...options,
65
+ }
66
+
67
+ // Build client tools map
68
+ this.clientTools = new Map()
69
+ if (options.tools) {
70
+ for (const tool of options.tools) {
71
+ this.clientTools.set(tool.name, tool)
72
+ }
73
+ }
74
+ }
75
+
76
+ // ============================================================================
77
+ // Connection Lifecycle
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Connect to the realtime session.
82
+ * Fetches a token and establishes the connection.
83
+ */
84
+ async connect(): Promise<void> {
85
+ if (this.state.status === 'connected') {
86
+ return
87
+ }
88
+
89
+ this.updateState({ status: 'connecting', error: null })
90
+
91
+ try {
92
+ // Fetch token from server
93
+ this.token = await this.options.getToken()
94
+
95
+ // Schedule token refresh
96
+ this.scheduleTokenRefresh()
97
+
98
+ // Connect via adapter (pass tools for providers like ElevenLabs that need them at connect time)
99
+ const toolsList =
100
+ this.clientTools.size > 0
101
+ ? Array.from(this.clientTools.values())
102
+ : undefined
103
+ this.connection = await this.options.adapter.connect(
104
+ this.token,
105
+ toolsList,
106
+ )
107
+
108
+ // Subscribe to connection events
109
+ this.subscribeToConnectionEvents()
110
+
111
+ // Auto-configure session with client-provided settings
112
+ this.applySessionConfig()
113
+
114
+ // Start audio capture if configured
115
+ if (this.options.autoCapture) {
116
+ await this.connection.startAudioCapture()
117
+ }
118
+
119
+ this.updateState({ status: 'connected', mode: 'listening' })
120
+ this.options.onConnect?.()
121
+ } catch (error) {
122
+ const err = error instanceof Error ? error : new Error(String(error))
123
+ this.updateState({ status: 'error', error: err })
124
+ this.options.onError?.(err)
125
+ throw err
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Disconnect from the realtime session.
131
+ */
132
+ async disconnect(): Promise<void> {
133
+ if (this.tokenRefreshTimeout) {
134
+ clearTimeout(this.tokenRefreshTimeout)
135
+ this.tokenRefreshTimeout = null
136
+ }
137
+
138
+ // Unsubscribe from all events
139
+ for (const unsub of this.unsubscribers) {
140
+ unsub()
141
+ }
142
+ this.unsubscribers = []
143
+
144
+ if (this.connection) {
145
+ await this.connection.disconnect()
146
+ this.connection = null
147
+ }
148
+
149
+ this.token = null
150
+ this.updateState({
151
+ status: 'idle',
152
+ mode: 'idle',
153
+ pendingUserTranscript: null,
154
+ pendingAssistantTranscript: null,
155
+ })
156
+ this.options.onDisconnect?.()
157
+ }
158
+
159
+ // ============================================================================
160
+ // Voice Control
161
+ // ============================================================================
162
+
163
+ /**
164
+ * Start listening for voice input.
165
+ * Only needed when vadMode is 'manual'.
166
+ */
167
+ startListening(): void {
168
+ if (!this.connection || this.state.status !== 'connected') {
169
+ return
170
+ }
171
+ this.connection.startAudioCapture()
172
+ this.updateState({ mode: 'listening' })
173
+ }
174
+
175
+ /**
176
+ * Stop listening for voice input.
177
+ * Only needed when vadMode is 'manual'.
178
+ */
179
+ stopListening(): void {
180
+ if (!this.connection) {
181
+ return
182
+ }
183
+ this.connection.stopAudioCapture()
184
+ this.updateState({ mode: 'idle' })
185
+ }
186
+
187
+ /**
188
+ * Interrupt the current assistant response.
189
+ */
190
+ interrupt(): void {
191
+ if (!this.connection) {
192
+ return
193
+ }
194
+ this.connection.interrupt()
195
+ }
196
+
197
+ // ============================================================================
198
+ // Text Input
199
+ // ============================================================================
200
+
201
+ /**
202
+ * Send a text message instead of voice.
203
+ */
204
+ sendText(text: string): void {
205
+ if (!this.connection || this.state.status !== 'connected') {
206
+ return
207
+ }
208
+
209
+ // Add user message
210
+ const userMessage: RealtimeMessage = {
211
+ id: this.generateId(),
212
+ role: 'user',
213
+ timestamp: Date.now(),
214
+ parts: [{ type: 'text', content: text }],
215
+ }
216
+ this.addMessage(userMessage)
217
+
218
+ // Send to provider
219
+ this.connection.sendText(text)
220
+ }
221
+
222
+ /**
223
+ * Send an image to the conversation.
224
+ * @param imageData - Base64-encoded image data or a URL
225
+ * @param mimeType - MIME type of the image (e.g., 'image/png', 'image/jpeg')
226
+ */
227
+ sendImage(imageData: string, mimeType: string): void {
228
+ if (!this.connection || this.state.status !== 'connected') {
229
+ return
230
+ }
231
+
232
+ // Add user message with image part
233
+ const userMessage: RealtimeMessage = {
234
+ id: this.generateId(),
235
+ role: 'user',
236
+ timestamp: Date.now(),
237
+ parts: [{ type: 'image', data: imageData, mimeType }],
238
+ }
239
+ this.addMessage(userMessage)
240
+
241
+ // Send to provider
242
+ this.connection.sendImage(imageData, mimeType)
243
+ }
244
+
245
+ // ============================================================================
246
+ // State Access
247
+ // ============================================================================
248
+
249
+ /** Get current connection status */
250
+ get status(): RealtimeStatus {
251
+ return this.state.status
252
+ }
253
+
254
+ /** Get current mode */
255
+ get mode(): RealtimeMode {
256
+ return this.state.mode
257
+ }
258
+
259
+ /** Get conversation messages */
260
+ get messages(): Array<RealtimeMessage> {
261
+ return this.state.messages
262
+ }
263
+
264
+ /** Get current error, if any */
265
+ get error(): Error | null {
266
+ return this.state.error
267
+ }
268
+
269
+ /** Get pending user transcript (while user is speaking) */
270
+ get pendingUserTranscript(): string | null {
271
+ return this.state.pendingUserTranscript
272
+ }
273
+
274
+ /** Get pending assistant transcript (while assistant is speaking) */
275
+ get pendingAssistantTranscript(): string | null {
276
+ return this.state.pendingAssistantTranscript
277
+ }
278
+
279
+ /** Get audio visualization data */
280
+ get audio(): AudioVisualization | null {
281
+ return this.connection?.getAudioVisualization() ?? null
282
+ }
283
+
284
+ // ============================================================================
285
+ // State Subscription
286
+ // ============================================================================
287
+
288
+ /**
289
+ * Subscribe to state changes.
290
+ * @returns Unsubscribe function
291
+ */
292
+ onStateChange(callback: RealtimeStateChangeCallback): () => void {
293
+ this.stateChangeCallbacks.add(callback)
294
+ return () => {
295
+ this.stateChangeCallbacks.delete(callback)
296
+ }
297
+ }
298
+
299
+ // ============================================================================
300
+ // Cleanup
301
+ // ============================================================================
302
+
303
+ /**
304
+ * Clean up resources.
305
+ * Call this when disposing of the client.
306
+ */
307
+ destroy(): void {
308
+ this.disconnect()
309
+ this.stateChangeCallbacks.clear()
310
+ }
311
+
312
+ // ============================================================================
313
+ // Private Methods
314
+ // ============================================================================
315
+
316
+ private updateState(updates: Partial<RealtimeClientState>): void {
317
+ this.state = { ...this.state, ...updates }
318
+
319
+ // Notify callbacks
320
+ for (const callback of this.stateChangeCallbacks) {
321
+ callback(this.state)
322
+ }
323
+
324
+ // Notify specific callbacks
325
+ if ('status' in updates && updates.status !== undefined) {
326
+ this.options.onStatusChange?.(updates.status)
327
+ }
328
+ if ('mode' in updates && updates.mode !== undefined) {
329
+ this.options.onModeChange?.(updates.mode)
330
+ }
331
+ }
332
+
333
+ private addMessage(message: RealtimeMessage): void {
334
+ this.updateState({
335
+ messages: [...this.state.messages, message],
336
+ })
337
+ this.options.onMessage?.(message)
338
+ }
339
+
340
+ private scheduleTokenRefresh(): void {
341
+ if (!this.token) return
342
+
343
+ const timeUntilExpiry = this.token.expiresAt - Date.now()
344
+ const refreshIn = Math.max(0, timeUntilExpiry - TOKEN_REFRESH_BUFFER_MS)
345
+
346
+ this.tokenRefreshTimeout = setTimeout(() => {
347
+ this.refreshToken()
348
+ }, refreshIn)
349
+ }
350
+
351
+ private async refreshToken(): Promise<void> {
352
+ try {
353
+ this.token = await this.options.getToken()
354
+ this.scheduleTokenRefresh()
355
+ // Note: Some providers may require reconnection with new token
356
+ // This is handled by the adapter implementation
357
+ } catch (error) {
358
+ const err = error instanceof Error ? error : new Error(String(error))
359
+ this.updateState({ error: err })
360
+ this.options.onError?.(err)
361
+ }
362
+ }
363
+
364
+ private subscribeToConnectionEvents(): void {
365
+ if (!this.connection) return
366
+
367
+ // Status changes
368
+ this.unsubscribers.push(
369
+ this.connection.on('status_change', ({ status }) => {
370
+ this.updateState({ status })
371
+ }),
372
+ )
373
+
374
+ // Mode changes
375
+ this.unsubscribers.push(
376
+ this.connection.on('mode_change', ({ mode }) => {
377
+ this.updateState({ mode })
378
+ }),
379
+ )
380
+
381
+ // Transcripts (streaming)
382
+ // User transcripts are added as messages when final (no separate message_complete for user input)
383
+ // Assistant transcripts are streamed, final message comes via message_complete
384
+ this.unsubscribers.push(
385
+ this.connection.on('transcript', ({ role, transcript, isFinal }) => {
386
+ if (role === 'user') {
387
+ this.updateState({
388
+ pendingUserTranscript: isFinal ? null : transcript,
389
+ })
390
+ // Add user message when transcript is finalized
391
+ if (isFinal && transcript) {
392
+ this.addMessage({
393
+ id: this.generateId(),
394
+ role: 'user',
395
+ timestamp: Date.now(),
396
+ parts: [{ type: 'audio', transcript, durationMs: 0 }],
397
+ })
398
+ }
399
+ } else {
400
+ // Assistant transcripts - just update pending, message_complete handles final
401
+ this.updateState({
402
+ pendingAssistantTranscript: isFinal ? null : transcript,
403
+ })
404
+ }
405
+ }),
406
+ )
407
+
408
+ // Tool calls
409
+ this.unsubscribers.push(
410
+ this.connection.on(
411
+ 'tool_call',
412
+ async ({ toolCallId, toolName, input }) => {
413
+ const tool = this.clientTools.get(toolName)
414
+ if (tool?.execute) {
415
+ try {
416
+ const output = await tool.execute(input)
417
+ this.connection?.sendToolResult(
418
+ toolCallId,
419
+ typeof output === 'string' ? output : JSON.stringify(output),
420
+ )
421
+ } catch (error) {
422
+ const errMsg =
423
+ error instanceof Error ? error.message : String(error)
424
+ this.connection?.sendToolResult(
425
+ toolCallId,
426
+ JSON.stringify({ error: errMsg }),
427
+ )
428
+ }
429
+ }
430
+ },
431
+ ),
432
+ )
433
+
434
+ // Message complete
435
+ this.unsubscribers.push(
436
+ this.connection.on('message_complete', ({ message }) => {
437
+ // Replace pending message with final version if needed
438
+ const existingIndex = this.state.messages.findIndex(
439
+ (m) => m.id === message.id,
440
+ )
441
+ if (existingIndex >= 0) {
442
+ const newMessages = [...this.state.messages]
443
+ newMessages[existingIndex] = message
444
+ this.updateState({ messages: newMessages })
445
+ } else {
446
+ this.addMessage(message)
447
+ }
448
+ }),
449
+ )
450
+
451
+ // Interruption
452
+ this.unsubscribers.push(
453
+ this.connection.on('interrupted', ({ messageId }) => {
454
+ if (messageId) {
455
+ const newMessages = this.state.messages.map((m) =>
456
+ m.id === messageId ? { ...m, interrupted: true } : m,
457
+ )
458
+ this.updateState({ messages: newMessages })
459
+ }
460
+ this.updateState({
461
+ mode: 'listening',
462
+ pendingAssistantTranscript: null,
463
+ })
464
+ this.options.onInterrupted?.()
465
+ }),
466
+ )
467
+
468
+ // Errors
469
+ this.unsubscribers.push(
470
+ this.connection.on('error', ({ error }) => {
471
+ this.updateState({ error })
472
+ this.options.onError?.(error)
473
+ }),
474
+ )
475
+ }
476
+
477
+ private applySessionConfig(): void {
478
+ if (!this.connection) return
479
+
480
+ const {
481
+ instructions,
482
+ voice,
483
+ vadMode,
484
+ tools,
485
+ outputModalities,
486
+ temperature,
487
+ maxOutputTokens,
488
+ semanticEagerness,
489
+ } = this.options
490
+ const hasConfig =
491
+ instructions ||
492
+ voice ||
493
+ vadMode ||
494
+ (tools && tools.length > 0) ||
495
+ outputModalities ||
496
+ temperature !== undefined ||
497
+ maxOutputTokens !== undefined ||
498
+ semanticEagerness
499
+ if (!hasConfig) return
500
+
501
+ const toolsConfig = tools
502
+ ? Array.from(this.clientTools.values()).map((t) => ({
503
+ name: t.name,
504
+ description: t.description,
505
+ inputSchema: t.inputSchema
506
+ ? convertSchemaToJsonSchema(t.inputSchema)
507
+ : undefined,
508
+ }))
509
+ : undefined
510
+
511
+ this.connection.updateSession({
512
+ instructions,
513
+ voice,
514
+ vadMode,
515
+ tools: toolsConfig,
516
+ outputModalities,
517
+ temperature,
518
+ maxOutputTokens,
519
+ semanticEagerness,
520
+ })
521
+ }
522
+
523
+ private generateId(): string {
524
+ return `msg-${Date.now()}-${Math.random().toString(36).substring(7)}`
525
+ }
526
+ }
@@ -0,0 +1,180 @@
1
+ import type {
2
+ AnyClientTool,
3
+ AudioVisualization,
4
+ RealtimeEvent,
5
+ RealtimeEventHandler,
6
+ RealtimeMessage,
7
+ RealtimeMode,
8
+ RealtimeSessionConfig,
9
+ RealtimeStatus,
10
+ RealtimeToken,
11
+ } from '@tanstack/ai'
12
+
13
+ // ============================================================================
14
+ // Adapter Interface
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Adapter interface for connecting to realtime providers.
19
+ * Each provider (OpenAI, ElevenLabs, etc.) implements this interface.
20
+ */
21
+ export interface RealtimeAdapter {
22
+ /** Provider identifier */
23
+ provider: string
24
+
25
+ /**
26
+ * Create a connection using the provided token
27
+ * @param token - The ephemeral token from the server
28
+ * @param clientTools - Optional client-side tools to register with the provider
29
+ * @returns A connection instance
30
+ */
31
+ connect: (
32
+ token: RealtimeToken,
33
+ clientTools?: ReadonlyArray<AnyClientTool>,
34
+ ) => Promise<RealtimeConnection>
35
+ }
36
+
37
+ /**
38
+ * Connection interface representing an active realtime session.
39
+ * Handles audio I/O, events, and session management.
40
+ */
41
+ export interface RealtimeConnection {
42
+ // Lifecycle
43
+ /** Disconnect from the realtime session */
44
+ disconnect: () => Promise<void>
45
+
46
+ // Audio I/O
47
+ /** Start capturing audio from the microphone */
48
+ startAudioCapture: () => Promise<void>
49
+ /** Stop capturing audio */
50
+ stopAudioCapture: () => void
51
+
52
+ // Text input
53
+ /** Send a text message (fallback for when voice isn't available) */
54
+ sendText: (text: string) => void
55
+
56
+ // Image input
57
+ /** Send an image to the conversation */
58
+ sendImage: (imageData: string, mimeType: string) => void
59
+
60
+ // Tool results
61
+ /** Send a tool execution result back to the provider */
62
+ sendToolResult: (callId: string, result: string) => void
63
+
64
+ // Session management
65
+ /** Update session configuration */
66
+ updateSession: (config: Partial<RealtimeSessionConfig>) => void
67
+ /** Interrupt the current response */
68
+ interrupt: () => void
69
+
70
+ // Events
71
+ /** Subscribe to connection events */
72
+ on: <TEvent extends RealtimeEvent>(
73
+ event: TEvent,
74
+ handler: RealtimeEventHandler<TEvent>,
75
+ ) => () => void
76
+
77
+ // Audio visualization
78
+ /** Get audio visualization data */
79
+ getAudioVisualization: () => AudioVisualization
80
+ }
81
+
82
+ // ============================================================================
83
+ // Client Options
84
+ // ============================================================================
85
+
86
+ /**
87
+ * Options for the RealtimeClient
88
+ */
89
+ export interface RealtimeClientOptions {
90
+ /**
91
+ * Function to fetch a realtime token from the server.
92
+ * Called on connect and when token needs refresh.
93
+ */
94
+ getToken: () => Promise<RealtimeToken>
95
+
96
+ /**
97
+ * The realtime adapter to use (e.g., openaiRealtime())
98
+ */
99
+ adapter: RealtimeAdapter
100
+
101
+ /**
102
+ * Client-side tools with execution logic
103
+ */
104
+ tools?: ReadonlyArray<AnyClientTool>
105
+
106
+ /**
107
+ * Auto-play assistant audio (default: true)
108
+ */
109
+ autoPlayback?: boolean
110
+
111
+ /**
112
+ * Request microphone access on connect (default: true)
113
+ */
114
+ autoCapture?: boolean
115
+
116
+ /**
117
+ * System instructions for the assistant
118
+ */
119
+ instructions?: string
120
+
121
+ /**
122
+ * Voice to use for audio output
123
+ */
124
+ voice?: string
125
+
126
+ /**
127
+ * Voice activity detection mode (default: 'server')
128
+ */
129
+ vadMode?: 'server' | 'semantic' | 'manual'
130
+
131
+ /**
132
+ * Output modalities for responses (e.g., ['audio', 'text'])
133
+ */
134
+ outputModalities?: Array<'audio' | 'text'>
135
+
136
+ /**
137
+ * Temperature for generation (provider-specific range)
138
+ */
139
+ temperature?: number
140
+
141
+ /**
142
+ * Maximum number of tokens in a response
143
+ */
144
+ maxOutputTokens?: number | 'inf'
145
+
146
+ /**
147
+ * Eagerness level for semantic VAD ('low', 'medium', 'high')
148
+ */
149
+ semanticEagerness?: 'low' | 'medium' | 'high'
150
+
151
+ // Callbacks
152
+ onStatusChange?: (status: RealtimeStatus) => void
153
+ onModeChange?: (mode: RealtimeMode) => void
154
+ onMessage?: (message: RealtimeMessage) => void
155
+ onError?: (error: Error) => void
156
+ onConnect?: () => void
157
+ onDisconnect?: () => void
158
+ onInterrupted?: () => void
159
+ }
160
+
161
+ // ============================================================================
162
+ // Client State
163
+ // ============================================================================
164
+
165
+ /**
166
+ * Internal state of the RealtimeClient
167
+ */
168
+ export interface RealtimeClientState {
169
+ status: RealtimeStatus
170
+ mode: RealtimeMode
171
+ messages: Array<RealtimeMessage>
172
+ pendingUserTranscript: string | null
173
+ pendingAssistantTranscript: string | null
174
+ error: Error | null
175
+ }
176
+
177
+ /**
178
+ * Callback type for state changes
179
+ */
180
+ export type RealtimeStateChangeCallback = (state: RealtimeClientState) => void