@tanstack/ai-client 0.6.0 → 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,118 @@
1
+ import { AnyClientTool, AudioVisualization, RealtimeEvent, RealtimeEventHandler, RealtimeMessage, RealtimeMode, RealtimeSessionConfig, RealtimeStatus, RealtimeToken } from '@tanstack/ai';
2
+ /**
3
+ * Adapter interface for connecting to realtime providers.
4
+ * Each provider (OpenAI, ElevenLabs, etc.) implements this interface.
5
+ */
6
+ export interface RealtimeAdapter {
7
+ /** Provider identifier */
8
+ provider: string;
9
+ /**
10
+ * Create a connection using the provided token
11
+ * @param token - The ephemeral token from the server
12
+ * @param clientTools - Optional client-side tools to register with the provider
13
+ * @returns A connection instance
14
+ */
15
+ connect: (token: RealtimeToken, clientTools?: ReadonlyArray<AnyClientTool>) => Promise<RealtimeConnection>;
16
+ }
17
+ /**
18
+ * Connection interface representing an active realtime session.
19
+ * Handles audio I/O, events, and session management.
20
+ */
21
+ export interface RealtimeConnection {
22
+ /** Disconnect from the realtime session */
23
+ disconnect: () => Promise<void>;
24
+ /** Start capturing audio from the microphone */
25
+ startAudioCapture: () => Promise<void>;
26
+ /** Stop capturing audio */
27
+ stopAudioCapture: () => void;
28
+ /** Send a text message (fallback for when voice isn't available) */
29
+ sendText: (text: string) => void;
30
+ /** Send an image to the conversation */
31
+ sendImage: (imageData: string, mimeType: string) => void;
32
+ /** Send a tool execution result back to the provider */
33
+ sendToolResult: (callId: string, result: string) => void;
34
+ /** Update session configuration */
35
+ updateSession: (config: Partial<RealtimeSessionConfig>) => void;
36
+ /** Interrupt the current response */
37
+ interrupt: () => void;
38
+ /** Subscribe to connection events */
39
+ on: <TEvent extends RealtimeEvent>(event: TEvent, handler: RealtimeEventHandler<TEvent>) => () => void;
40
+ /** Get audio visualization data */
41
+ getAudioVisualization: () => AudioVisualization;
42
+ }
43
+ /**
44
+ * Options for the RealtimeClient
45
+ */
46
+ export interface RealtimeClientOptions {
47
+ /**
48
+ * Function to fetch a realtime token from the server.
49
+ * Called on connect and when token needs refresh.
50
+ */
51
+ getToken: () => Promise<RealtimeToken>;
52
+ /**
53
+ * The realtime adapter to use (e.g., openaiRealtime())
54
+ */
55
+ adapter: RealtimeAdapter;
56
+ /**
57
+ * Client-side tools with execution logic
58
+ */
59
+ tools?: ReadonlyArray<AnyClientTool>;
60
+ /**
61
+ * Auto-play assistant audio (default: true)
62
+ */
63
+ autoPlayback?: boolean;
64
+ /**
65
+ * Request microphone access on connect (default: true)
66
+ */
67
+ autoCapture?: boolean;
68
+ /**
69
+ * System instructions for the assistant
70
+ */
71
+ instructions?: string;
72
+ /**
73
+ * Voice to use for audio output
74
+ */
75
+ voice?: string;
76
+ /**
77
+ * Voice activity detection mode (default: 'server')
78
+ */
79
+ vadMode?: 'server' | 'semantic' | 'manual';
80
+ /**
81
+ * Output modalities for responses (e.g., ['audio', 'text'])
82
+ */
83
+ outputModalities?: Array<'audio' | 'text'>;
84
+ /**
85
+ * Temperature for generation (provider-specific range)
86
+ */
87
+ temperature?: number;
88
+ /**
89
+ * Maximum number of tokens in a response
90
+ */
91
+ maxOutputTokens?: number | 'inf';
92
+ /**
93
+ * Eagerness level for semantic VAD ('low', 'medium', 'high')
94
+ */
95
+ semanticEagerness?: 'low' | 'medium' | 'high';
96
+ onStatusChange?: (status: RealtimeStatus) => void;
97
+ onModeChange?: (mode: RealtimeMode) => void;
98
+ onMessage?: (message: RealtimeMessage) => void;
99
+ onError?: (error: Error) => void;
100
+ onConnect?: () => void;
101
+ onDisconnect?: () => void;
102
+ onInterrupted?: () => void;
103
+ }
104
+ /**
105
+ * Internal state of the RealtimeClient
106
+ */
107
+ export interface RealtimeClientState {
108
+ status: RealtimeStatus;
109
+ mode: RealtimeMode;
110
+ messages: Array<RealtimeMessage>;
111
+ pendingUserTranscript: string | null;
112
+ pendingAssistantTranscript: string | null;
113
+ error: Error | null;
114
+ }
115
+ /**
116
+ * Callback type for state changes
117
+ */
118
+ export type RealtimeStateChangeCallback = (state: RealtimeClientState) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-client",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Framework-agnostic headless client for TanStack AI",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -31,7 +31,8 @@
31
31
  "src"
32
32
  ],
33
33
  "dependencies": {
34
- "@tanstack/ai": "0.6.3"
34
+ "@tanstack/ai": "0.8.0",
35
+ "@tanstack/ai-event-client": "0.1.0"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@vitest/coverage-v8": "4.0.14",
package/src/events.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { aiEventClient } from '@tanstack/ai/event-client'
1
+ import { aiEventClient } from '@tanstack/ai-event-client'
2
2
  import type { ContentPart } from '@tanstack/ai'
3
3
  import type { UIMessage } from './types'
4
4
 
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { ChatClient } from './chat-client'
2
+ export { RealtimeClient } from './realtime-client'
2
3
  export { GenerationClient } from './generation-client'
3
4
  export { VideoGenerationClient } from './video-generation-client'
4
5
  export type {
@@ -42,6 +43,13 @@ export type {
42
43
  ExtractToolOutput,
43
44
  } from './tool-types'
44
45
  export type { AnyClientTool } from '@tanstack/ai'
46
+ export type {
47
+ RealtimeAdapter,
48
+ RealtimeConnection,
49
+ RealtimeClientOptions,
50
+ RealtimeClientState,
51
+ RealtimeStateChangeCallback,
52
+ } from './realtime-types'
45
53
  export {
46
54
  fetchServerSentEvents,
47
55
  fetchHttpStream,
@@ -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
+ }