@tanstack/ai-client 0.18.5 → 0.19.0

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.
Files changed (59) hide show
  1. package/dist/esm/audio-recorder.d.ts +76 -0
  2. package/dist/esm/audio-recorder.js +215 -0
  3. package/dist/esm/audio-recorder.js.map +1 -0
  4. package/dist/esm/chat-client.d.ts +264 -0
  5. package/dist/esm/chat-client.js +1097 -0
  6. package/dist/esm/chat-client.js.map +1 -0
  7. package/dist/esm/client-persistor.d.ts +86 -0
  8. package/dist/esm/client-persistor.js +243 -0
  9. package/dist/esm/client-persistor.js.map +1 -0
  10. package/dist/esm/connection-adapters.d.ts +203 -0
  11. package/dist/esm/connection-adapters.js +611 -0
  12. package/dist/esm/connection-adapters.js.map +1 -0
  13. package/dist/esm/devtools-noop.d.ts +63 -0
  14. package/dist/esm/devtools-noop.js +141 -0
  15. package/dist/esm/devtools-noop.js.map +1 -0
  16. package/dist/esm/devtools.d.ts +276 -0
  17. package/dist/esm/devtools.js +1178 -0
  18. package/dist/esm/devtools.js.map +1 -0
  19. package/dist/esm/events.d.ts +129 -0
  20. package/dist/esm/events.js +227 -0
  21. package/dist/esm/events.js.map +1 -0
  22. package/dist/esm/generation-client.d.ts +99 -0
  23. package/dist/esm/generation-client.js +325 -0
  24. package/dist/esm/generation-client.js.map +1 -0
  25. package/dist/esm/generation-types.d.ts +261 -0
  26. package/dist/esm/generation-types.js +14 -0
  27. package/dist/esm/generation-types.js.map +1 -0
  28. package/dist/esm/index.d.ts +20 -0
  29. package/dist/esm/index.js +48 -0
  30. package/dist/esm/index.js.map +1 -0
  31. package/dist/esm/mcp-app-bridge.d.ts +27 -0
  32. package/dist/esm/mcp-app-bridge.js +70 -0
  33. package/dist/esm/mcp-app-bridge.js.map +1 -0
  34. package/dist/esm/realtime-client.d.ts +98 -0
  35. package/dist/esm/realtime-client.js +403 -0
  36. package/dist/esm/realtime-client.js.map +1 -0
  37. package/dist/esm/realtime-types.d.ts +78 -0
  38. package/dist/esm/response-stream.d.ts +7 -0
  39. package/dist/esm/response-stream.js +32 -0
  40. package/dist/esm/response-stream.js.map +1 -0
  41. package/dist/esm/sse-parser.d.ts +8 -0
  42. package/dist/esm/sse-parser.js +52 -0
  43. package/dist/esm/sse-parser.js.map +1 -0
  44. package/dist/esm/sse-utils.d.ts +1 -0
  45. package/dist/esm/sse-utils.js +11 -0
  46. package/dist/esm/sse-utils.js.map +1 -0
  47. package/dist/esm/tool-types.d.ts +20 -0
  48. package/dist/esm/types.d.ts +419 -0
  49. package/dist/esm/types.js +11 -0
  50. package/dist/esm/types.js.map +1 -0
  51. package/dist/esm/video-generation-client.d.ts +110 -0
  52. package/dist/esm/video-generation-client.js +382 -0
  53. package/dist/esm/video-generation-client.js.map +1 -0
  54. package/package.json +4 -3
  55. package/src/audio-recorder.ts +322 -0
  56. package/src/generation-types.ts +24 -4
  57. package/src/index.ts +10 -0
  58. package/src/mcp-app-bridge.ts +120 -0
  59. package/src/types.ts +2 -0
@@ -0,0 +1,322 @@
1
+ import { arrayBufferToBase64 } from '@tanstack/ai-utils'
2
+ import type { AudioPart } from '@tanstack/ai/client'
3
+
4
+ /** Lifecycle state of an {@link AudioRecorder}. */
5
+ export type AudioRecorderState = 'idle' | 'recording' | 'stopping'
6
+
7
+ export interface AudioRecorderOptions {
8
+ /** Constraints forwarded to `getUserMedia({ audio })`. Defaults to `true`. */
9
+ audio?: MediaTrackConstraints | boolean
10
+ /**
11
+ * Preferred recorder mime type. Used only when
12
+ * `MediaRecorder.isTypeSupported` reports it; otherwise the browser default
13
+ * is used.
14
+ */
15
+ mimeType?: string
16
+ /** Fired on `getUserMedia` rejection (permission denied) or recorder error. */
17
+ onError?: (error: Error) => void
18
+ }
19
+
20
+ /**
21
+ * Resolves the value `stop()` produces from a recorder transform callback.
22
+ *
23
+ * - If the callback returns a value — sync or async — that value's awaited type
24
+ * is used (a returned `null` is a real value and is preserved).
25
+ * - If the callback returns nothing (`void`/`undefined`), or is absent, falls
26
+ * back to {@link AudioRecording}.
27
+ *
28
+ * @template TFn - The transform callback type (or undefined if not provided)
29
+ */
30
+ export type InferAudioRecordingOutput<TFn> = TFn extends (
31
+ recording: AudioRecording,
32
+ ) => infer R
33
+ ? [Exclude<Awaited<R>, void | undefined>] extends [never]
34
+ ? AudioRecording
35
+ : Exclude<Awaited<R>, void | undefined>
36
+ : AudioRecording
37
+
38
+ export interface AudioRecording {
39
+ /** The raw recorded media blob. */
40
+ blob: Blob
41
+ /** Base64 of the recorded bytes (no `data:` prefix). */
42
+ base64: string
43
+ /** The recorder's native mime type, e.g. `audio/webm;codecs=opus`. */
44
+ mimeType: string
45
+ /** Recording length in milliseconds. */
46
+ durationMs: number
47
+ /**
48
+ * Ready-to-use audio content part for `sendMessage`/generation prompts:
49
+ * `{ type: 'audio', source: { type: 'data', value: base64, mimeType } }`.
50
+ */
51
+ part: AudioPart
52
+ }
53
+
54
+ /**
55
+ * Framework-agnostic browser audio recorder. Wraps `getUserMedia` +
56
+ * `MediaRecorder`, returns the recorder's native output (no transcode), and
57
+ * builds a plug-and-play {@link AudioRecording.part}.
58
+ */
59
+ export class AudioRecorder {
60
+ private readonly options: AudioRecorderOptions
61
+ private recorder: MediaRecorder | null = null
62
+ private stream: MediaStream | null = null
63
+ private chunks: Array<Blob> = []
64
+ private startedAt = 0
65
+ private _state: AudioRecorderState = 'idle'
66
+ // True while start() is awaiting getUserMedia (state is still 'idle' then).
67
+ private starting = false
68
+ // Set by cancel()/teardown during that window so start() releases the
69
+ // freshly acquired stream instead of beginning a leaked recording.
70
+ private pendingCancel = false
71
+ private readonly listeners = new Set<(state: AudioRecorderState) => void>()
72
+ private stopResolve: ((recording: AudioRecording) => void) | null = null
73
+ private stopReject: ((error: Error) => void) | null = null
74
+
75
+ constructor(options: AudioRecorderOptions = {}) {
76
+ this.options = options
77
+ }
78
+
79
+ /** Feature-detect the browser media APIs. SSR/Worker-safe. */
80
+ static isSupported(): boolean {
81
+ return (
82
+ typeof navigator !== 'undefined' &&
83
+ typeof navigator.mediaDevices !== 'undefined' &&
84
+ typeof navigator.mediaDevices.getUserMedia === 'function' &&
85
+ typeof MediaRecorder !== 'undefined'
86
+ )
87
+ }
88
+
89
+ get state(): AudioRecorderState {
90
+ return this._state
91
+ }
92
+
93
+ subscribe(cb: (state: AudioRecorderState) => void): () => void {
94
+ this.listeners.add(cb)
95
+ return () => {
96
+ this.listeners.delete(cb)
97
+ }
98
+ }
99
+
100
+ private setState(state: AudioRecorderState): void {
101
+ this._state = state
102
+ for (const cb of this.listeners) {
103
+ cb(state)
104
+ }
105
+ }
106
+
107
+ async start(): Promise<void> {
108
+ if (this._state !== 'idle' || this.starting) {
109
+ return
110
+ }
111
+ this.starting = true
112
+ try {
113
+ const stream = await navigator.mediaDevices.getUserMedia({
114
+ audio: this.options.audio ?? true,
115
+ })
116
+ // cancel()/teardown ran while we were awaiting the mic: release the
117
+ // freshly acquired stream and bail rather than starting a recording the
118
+ // caller can no longer stop (a leaked live microphone).
119
+ if (this.pendingCancel) {
120
+ stream.getTracks().forEach((t) => t.stop())
121
+ return
122
+ }
123
+ this.stream = stream
124
+ const wanted = this.options.mimeType
125
+ const useMimeType =
126
+ wanted &&
127
+ typeof MediaRecorder.isTypeSupported === 'function' &&
128
+ MediaRecorder.isTypeSupported(wanted)
129
+ ? wanted
130
+ : undefined
131
+ const recorder = useMimeType
132
+ ? new MediaRecorder(stream, { mimeType: useMimeType })
133
+ : new MediaRecorder(stream)
134
+ this.chunks = []
135
+ recorder.ondataavailable = (e) => {
136
+ if (e.data.size > 0) {
137
+ this.chunks.push(e.data)
138
+ }
139
+ }
140
+ recorder.onstop = () => {
141
+ void this.finalize()
142
+ }
143
+ recorder.onerror = (event) => {
144
+ const detail =
145
+ 'error' in event && event.error instanceof Error
146
+ ? event.error
147
+ : new Error('Audio recording failed')
148
+ this.handleError(detail)
149
+ }
150
+ this.recorder = recorder
151
+ this.startedAt = Date.now()
152
+ recorder.start()
153
+ this.setState('recording')
154
+ } catch (err) {
155
+ this.releaseStream()
156
+ this.recorder = null
157
+ const error =
158
+ err instanceof Error ? err : new Error('Failed to start recording')
159
+ this.setState('idle')
160
+ this.notifyError(error)
161
+ throw error
162
+ } finally {
163
+ this.starting = false
164
+ // Reset here (not before the await) so a cancel() that arrives mid-start
165
+ // is observed above; clearing it afterward keeps the next start() clean.
166
+ this.pendingCancel = false
167
+ }
168
+ }
169
+
170
+ stop(): Promise<AudioRecording> {
171
+ if (this._state !== 'recording' || !this.recorder) {
172
+ return Promise.reject(
173
+ new Error('AudioRecorder.stop() called while not recording'),
174
+ )
175
+ }
176
+ this.setState('stopping')
177
+ const recorder = this.recorder
178
+ return new Promise<AudioRecording>((resolve, reject) => {
179
+ // Some browsers/codecs never fire onstop; this watchdog unwedges the
180
+ // recorder instead of leaking this promise forever.
181
+ const watchdog = setTimeout(() => {
182
+ if (this._state !== 'stopping') {
183
+ return
184
+ }
185
+ // Detach handlers so a late onstop/onerror from the stalled recorder
186
+ // can't reach back in and fire finalize()/onError a second time.
187
+ this.detachRecorder()
188
+ if (this.chunks.length > 0) {
189
+ // onstop never fired, but ondataavailable already delivered the
190
+ // audio — finalize from the buffered chunks rather than discarding a
191
+ // recording the user successfully captured.
192
+ void this.finalize()
193
+ } else {
194
+ this.handleError(
195
+ new Error('Recording stop timed out after 10s with no audio'),
196
+ )
197
+ }
198
+ }, 10_000)
199
+ this.stopResolve = (rec) => {
200
+ clearTimeout(watchdog)
201
+ resolve(rec)
202
+ }
203
+ this.stopReject = (err) => {
204
+ clearTimeout(watchdog)
205
+ reject(err)
206
+ }
207
+ recorder.stop()
208
+ })
209
+ }
210
+
211
+ cancel(): void {
212
+ if (this.starting) {
213
+ // A start() is awaiting getUserMedia; flag it so the resolved stream is
214
+ // released instead of beginning a recording with no handle to stop it.
215
+ this.pendingCancel = true
216
+ return
217
+ }
218
+ if (this._state === 'idle') {
219
+ return
220
+ }
221
+ const recorder = this.recorder
222
+ if (recorder) {
223
+ // Detach handlers so finalize()/onError never run for a discarded
224
+ // recording.
225
+ this.detachRecorder()
226
+ try {
227
+ recorder.stop()
228
+ } catch (err) {
229
+ // Stopping an already-inactive recorder throws InvalidStateError —
230
+ // that's expected here. Anything else is unexpected; surface it rather
231
+ // than swallowing it silently.
232
+ if (
233
+ !(err instanceof DOMException && err.name === 'InvalidStateError')
234
+ ) {
235
+ this.notifyError(
236
+ err instanceof Error ? err : new Error('Failed to stop recorder'),
237
+ )
238
+ }
239
+ }
240
+ }
241
+ this.releaseStream()
242
+ this.recorder = null
243
+ this.chunks = []
244
+ const reject = this.stopReject
245
+ this.stopResolve = null
246
+ this.stopReject = null
247
+ this.setState('idle')
248
+ reject?.(new Error('Recording cancelled'))
249
+ }
250
+
251
+ private async finalize(): Promise<void> {
252
+ const mimeType = this.recorder?.mimeType || 'audio/webm'
253
+ const durationMs = Date.now() - this.startedAt
254
+ try {
255
+ const blob = new Blob(this.chunks, { type: mimeType })
256
+ const base64 = arrayBufferToBase64(await blob.arrayBuffer())
257
+ const recording: AudioRecording = {
258
+ blob,
259
+ base64,
260
+ mimeType,
261
+ durationMs,
262
+ part: {
263
+ type: 'audio',
264
+ source: { type: 'data', value: base64, mimeType },
265
+ },
266
+ }
267
+ this.releaseStream()
268
+ this.recorder = null
269
+ this.chunks = []
270
+ const resolve = this.stopResolve
271
+ this.stopResolve = null
272
+ this.stopReject = null
273
+ this.setState('idle')
274
+ resolve?.(recording)
275
+ } catch (err) {
276
+ this.handleError(
277
+ err instanceof Error ? err : new Error('Failed to finalize recording'),
278
+ )
279
+ }
280
+ }
281
+
282
+ private handleError(error: Error): void {
283
+ this.releaseStream()
284
+ this.recorder = null
285
+ this.chunks = []
286
+ const reject = this.stopReject
287
+ this.stopResolve = null
288
+ this.stopReject = null
289
+ this.setState('idle')
290
+ // Settle the pending stop() promise before invoking the user callback so a
291
+ // throwing onError can't strand the awaiter (the two error channels are
292
+ // independent — see start()/stop() docs).
293
+ reject?.(error)
294
+ this.notifyError(error)
295
+ }
296
+
297
+ /** Invoke the user onError callback, isolating a throw so it can't disrupt
298
+ * internal state teardown or strand a pending promise. */
299
+ private notifyError(error: Error): void {
300
+ try {
301
+ this.options.onError?.(error)
302
+ } catch {
303
+ // A user onError that throws must not propagate into recorder internals.
304
+ }
305
+ }
306
+
307
+ private releaseStream(): void {
308
+ this.stream?.getTracks().forEach((t) => t.stop())
309
+ this.stream = null
310
+ }
311
+
312
+ /** Detach all event handlers so a stale recorder can't mutate our state. */
313
+ private detachRecorder(): void {
314
+ const recorder = this.recorder
315
+ if (!recorder) {
316
+ return
317
+ }
318
+ recorder.onstop = null
319
+ recorder.onerror = null
320
+ recorder.ondataavailable = null
321
+ }
322
+ }
@@ -11,7 +11,29 @@ import type {
11
11
  // ===========================
12
12
 
13
13
  /**
14
- * Infers the output type from an `onResult` callback's return type.
14
+ * Maps an `onResult` transform's raw return type to the stored output type.
15
+ *
16
+ * - A concrete return (excluding null/void/undefined) becomes the output type.
17
+ * - A return of only null/void/undefined falls back to TResult (the transform
18
+ * reacted to the result or chose to keep it, rather than replacing it).
19
+ *
20
+ * Hooks infer `TReturn` directly from the `onResult` return position — a
21
+ * covariant inference site that works even for an optional nested property —
22
+ * which both contextually types the callback parameter as `TResult` and
23
+ * narrows `result`. See issue #848.
24
+ *
25
+ * @template TResult - The raw result type from the generation
26
+ * @template TReturn - The transform's return type (defaults to `void` when no
27
+ * transform is provided)
28
+ */
29
+ export type InferGenerationOutputFromReturn<TResult, TReturn> = [
30
+ Exclude<TReturn, null | void | undefined>,
31
+ ] extends [never]
32
+ ? TResult
33
+ : Exclude<TReturn, null | void | undefined>
34
+
35
+ /**
36
+ * Infers the output type from an `onResult` callback's type.
15
37
  *
16
38
  * - If the callback returns a concrete type (excluding null/void/undefined), uses that type.
17
39
  * - If the callback only returns null/void/undefined, or is not provided, falls back to TResult.
@@ -22,9 +44,7 @@ import type {
22
44
  export type InferGenerationOutput<TResult, TFn> = TFn extends (
23
45
  result: any,
24
46
  ) => infer R
25
- ? [Exclude<R, null | void | undefined>] extends [never]
26
- ? TResult
27
- : Exclude<R, null | void | undefined>
47
+ ? InferGenerationOutputFromReturn<TResult, R>
28
48
  : TResult
29
49
 
30
50
  // ===========================
package/src/index.ts CHANGED
@@ -1,4 +1,13 @@
1
+ export { AudioRecorder } from './audio-recorder'
2
+ export type {
3
+ AudioRecorderOptions,
4
+ AudioRecorderState,
5
+ AudioRecording,
6
+ InferAudioRecordingOutput,
7
+ } from './audio-recorder'
1
8
  export { ChatClient } from './chat-client'
9
+ export { createMcpAppBridge } from './mcp-app-bridge'
10
+ export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge'
2
11
  export { RealtimeClient } from './realtime-client'
3
12
  export { GenerationClient } from './generation-client'
4
13
  export { VideoGenerationClient } from './video-generation-client'
@@ -30,6 +39,7 @@ export type {
30
39
  // Generation client types
31
40
  export type {
32
41
  InferGenerationOutput,
42
+ InferGenerationOutputFromReturn,
33
43
  GenerationClientState,
34
44
  GenerationClientOptions,
35
45
  GenerationFetcher,
@@ -0,0 +1,120 @@
1
+ export interface CreateMcpAppBridgeOptions {
2
+ threadId: string
3
+ callEndpoint: string
4
+ chat: {
5
+ sendMessage: (
6
+ content: string,
7
+ body?: Record<string, unknown>,
8
+ ) => Promise<void>
9
+ }
10
+ fetchImpl?: typeof fetch
11
+ onLink?: (url: string) => void
12
+ }
13
+
14
+ export interface McpAppBridge {
15
+ callTool: (input: {
16
+ serverId?: string
17
+ toolName: string
18
+ args?: Record<string, unknown>
19
+ /**
20
+ * Reserved — forwarded to the call handler for correlation purposes but
21
+ * not consumed by the handler. Accepted on the wire; the handler does not
22
+ * read it (mirrors the `meta` convention on `UIResourcePart`).
23
+ */
24
+ messageId?: string
25
+ }) => Promise<unknown>
26
+ sendPrompt: (text: string) => Promise<void>
27
+ openLink: (url: string) => { isError: boolean }
28
+ }
29
+
30
+ interface ToolCallResponse {
31
+ ok: boolean
32
+ result?: unknown
33
+ error?: string
34
+ }
35
+
36
+ function isToolCallResponse(value: unknown): value is ToolCallResponse {
37
+ return (
38
+ value !== null &&
39
+ typeof value === 'object' &&
40
+ 'ok' in value &&
41
+ typeof value.ok === 'boolean'
42
+ )
43
+ }
44
+
45
+ // Links arrive from an untrusted sandboxed widget. Only hand http(s)/mailto
46
+ // URLs to the host's onLink; reject javascript:/data:/file:/etc. so a widget
47
+ // can't smuggle a script-executing or local-resource URL through the bridge.
48
+ const SAFE_LINK_SCHEMES = new Set(['http:', 'https:', 'mailto:'])
49
+ function isSafeLink(url: string): boolean {
50
+ try {
51
+ return SAFE_LINK_SCHEMES.has(new URL(url).protocol)
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ export function createMcpAppBridge(
58
+ options: CreateMcpAppBridgeOptions,
59
+ ): McpAppBridge {
60
+ const { threadId, callEndpoint, chat, fetchImpl, onLink } = options
61
+ const doFetch = fetchImpl ?? fetch
62
+
63
+ return {
64
+ async callTool(input) {
65
+ const response = await doFetch(callEndpoint, {
66
+ method: 'POST',
67
+ headers: { 'content-type': 'application/json' },
68
+ body: JSON.stringify({
69
+ threadId,
70
+ serverId: input.serverId,
71
+ toolName: input.toolName,
72
+ args: input.args,
73
+ messageId: input.messageId,
74
+ }),
75
+ })
76
+
77
+ if (!response.ok) {
78
+ throw new Error(`MCP app tool call failed: HTTP ${response.status}`)
79
+ }
80
+
81
+ const raw: unknown = await response.json()
82
+ if (!isToolCallResponse(raw)) {
83
+ throw new Error('MCP app tool call failed')
84
+ }
85
+
86
+ if (!raw.ok) {
87
+ throw new Error(raw.error ?? 'MCP app tool call failed')
88
+ }
89
+
90
+ return raw.result
91
+ },
92
+
93
+ async sendPrompt(text) {
94
+ await chat.sendMessage(text)
95
+ },
96
+
97
+ openLink(url) {
98
+ if (!isSafeLink(url)) {
99
+ console.warn(
100
+ '[mcp-app-bridge] openLink rejected: unsupported URL scheme',
101
+ url,
102
+ )
103
+ return { isError: true }
104
+ }
105
+ if (onLink) {
106
+ try {
107
+ onLink(url)
108
+ return { isError: false }
109
+ } catch (err) {
110
+ console.warn('[mcp-app-bridge] openLink: onLink handler threw', err)
111
+ return { isError: true }
112
+ }
113
+ }
114
+ console.warn(
115
+ '[mcp-app-bridge] openLink ignored: no onLink handler configured',
116
+ )
117
+ return { isError: true }
118
+ },
119
+ }
120
+ }
package/src/types.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  ModelMessage,
11
11
  StreamChunk,
12
12
  StructuredOutputPart,
13
+ UIResourcePart,
13
14
  VideoPart,
14
15
  } from '@tanstack/ai/client'
15
16
  import type { ConnectionAdapter } from './connection-adapters'
@@ -244,6 +245,7 @@ export type MessagePart<
244
245
  | ToolResultPart
245
246
  | ThinkingPart
246
247
  | StructuredOutputPart<TData>
248
+ | UIResourcePart
247
249
 
248
250
  /**
249
251
  * UIMessage - Domain-specific message format optimized for building chat UIs