@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,302 @@
1
+ import { GENERATION_EVENTS } from './generation-types'
2
+ import { parseSSEResponse } from './sse-parser'
3
+ import type { StreamChunk } from '@tanstack/ai'
4
+ import type { ConnectionAdapter } from './connection-adapters'
5
+ import type {
6
+ GenerationClientOptions,
7
+ GenerationClientState,
8
+ GenerationFetcher,
9
+ } from './generation-types'
10
+
11
+ /**
12
+ * Callbacks stored in a ref so hooks can update them without recreating the client.
13
+ */
14
+ interface GenerationCallbacks<TResult, TOutput> {
15
+ onResult?: (result: TResult) => TOutput | null | void
16
+ onError?: (error: Error) => void
17
+ onProgress?: (progress: number, message?: string) => void
18
+ onChunk?: (chunk: StreamChunk) => void
19
+ onResultChange?: (result: TOutput | null) => void
20
+ onLoadingChange?: (isLoading: boolean) => void
21
+ onErrorChange?: (error: Error | undefined) => void
22
+ onStatusChange?: (status: GenerationClientState) => void
23
+ }
24
+
25
+ /**
26
+ * A lightweight, generic client for one-shot generation tasks
27
+ * (image, speech, transcription, summarize).
28
+ *
29
+ * Supports two transport modes:
30
+ * - **ConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom).
31
+ * Server wraps results in StreamChunk events with CUSTOM event names.
32
+ * - **Fetcher** — Direct async function call. No streaming protocol needed.
33
+ *
34
+ * @template TInput - The input type for the generation request
35
+ * @template TResult - The result type returned by the generation
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * // With ConnectionAdapter (streaming)
40
+ * const client = new GenerationClient<ImageGenerateInput, ImageGenerationResult>({
41
+ * connection: fetchServerSentEvents('/api/generate/image'),
42
+ * onResultChange: setResult,
43
+ * onLoadingChange: setIsLoading,
44
+ * })
45
+ *
46
+ * // With fetcher (direct)
47
+ * const client = new GenerationClient<ImageGenerateInput, ImageGenerationResult>({
48
+ * fetcher: async (input) => {
49
+ * const res = await fetch('/api/generate/image', {
50
+ * method: 'POST',
51
+ * body: JSON.stringify(input),
52
+ * })
53
+ * return res.json()
54
+ * },
55
+ * })
56
+ *
57
+ * await client.generate({ prompt: 'A sunset over mountains' })
58
+ * ```
59
+ */
60
+ export class GenerationClient<
61
+ TInput extends Record<string, any>,
62
+ TResult,
63
+ TOutput = TResult,
64
+ > {
65
+ private connection: ConnectionAdapter | undefined
66
+ private fetcher: GenerationFetcher<TInput, TResult> | undefined
67
+ private body: Record<string, any>
68
+ private result: TOutput | null = null
69
+ private isLoading = false
70
+ private error: Error | undefined = undefined
71
+ private status: GenerationClientState = 'idle'
72
+ private abortController: AbortController | null = null
73
+ private callbacksRef: GenerationCallbacks<TResult, TOutput>
74
+
75
+ constructor(
76
+ options: GenerationClientOptions<TInput, TResult, TOutput> &
77
+ (
78
+ | { connection: ConnectionAdapter; fetcher?: never }
79
+ | {
80
+ fetcher: GenerationFetcher<TInput, TResult>
81
+ connection?: never
82
+ }
83
+ ),
84
+ ) {
85
+ this.connection = options.connection
86
+ this.fetcher = options.fetcher
87
+ this.body = options.body ?? {}
88
+
89
+ this.callbacksRef = {
90
+ onResult: options.onResult,
91
+ onError: options.onError,
92
+ onProgress: options.onProgress,
93
+ onChunk: options.onChunk,
94
+ onResultChange: options.onResultChange,
95
+ onLoadingChange: options.onLoadingChange,
96
+ onErrorChange: options.onErrorChange,
97
+ onStatusChange: options.onStatusChange,
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Trigger a generation request.
103
+ * Only one generation can be in-flight at a time; calling generate()
104
+ * while already generating will be a no-op.
105
+ */
106
+ async generate(input: TInput): Promise<void> {
107
+ if (this.isLoading) return
108
+
109
+ this.setIsLoading(true)
110
+ this.setStatus('generating')
111
+ this.setError(undefined)
112
+
113
+ const abortController = new AbortController()
114
+ this.abortController = abortController
115
+ const { signal } = abortController
116
+
117
+ try {
118
+ if (this.fetcher) {
119
+ // Direct fetch path
120
+ const result = await this.fetcher(input, { signal })
121
+ if (signal.aborted) return
122
+ if (result instanceof Response) {
123
+ // Server function returned SSE Response — parse stream
124
+ await this.processStream(parseSSEResponse(result, signal))
125
+ } else {
126
+ this.setResult(result)
127
+ this.setStatus('success')
128
+ }
129
+ } else if (this.connection) {
130
+ // ConnectionAdapter streaming path
131
+ const mergedData = { ...this.body, ...input }
132
+ const stream = this.connection.connect([], mergedData, signal)
133
+ await this.processStream(stream)
134
+ } else {
135
+ throw new Error(
136
+ 'GenerationClient requires either a connection or fetcher option',
137
+ )
138
+ }
139
+ } catch (err: any) {
140
+ if (signal.aborted) return
141
+ const error = err instanceof Error ? err : new Error(String(err))
142
+ this.setError(error)
143
+ this.setStatus('error')
144
+ this.callbacksRef.onError?.(error)
145
+ } finally {
146
+ this.abortController = null
147
+ this.setIsLoading(false)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Process a stream of AG-UI events from the ConnectionAdapter.
153
+ */
154
+ private async processStream(
155
+ source: AsyncIterable<StreamChunk>,
156
+ ): Promise<void> {
157
+ for await (const chunk of source) {
158
+ if (this.abortController?.signal.aborted) break
159
+
160
+ this.callbacksRef.onChunk?.(chunk)
161
+
162
+ switch (chunk.type) {
163
+ case 'CUSTOM': {
164
+ if (chunk.name === GENERATION_EVENTS.RESULT) {
165
+ this.setResult(chunk.value as TResult)
166
+ } else if (chunk.name === GENERATION_EVENTS.PROGRESS) {
167
+ const { progress, message } = chunk.value as {
168
+ progress: number
169
+ message?: string
170
+ }
171
+ this.callbacksRef.onProgress?.(progress, message)
172
+ }
173
+ break
174
+ }
175
+ case 'RUN_FINISHED': {
176
+ this.setStatus('success')
177
+ break
178
+ }
179
+ case 'RUN_ERROR': {
180
+ throw new Error(chunk.error.message)
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Abort any in-flight generation request.
188
+ */
189
+ stop(): void {
190
+ if (this.abortController) {
191
+ this.abortController.abort()
192
+ this.abortController = null
193
+ }
194
+ this.setIsLoading(false)
195
+ if (this.status === 'generating') {
196
+ this.setStatus('idle')
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Clear the result, error, and return to idle state.
202
+ */
203
+ reset(): void {
204
+ this.stop()
205
+ this.setResult(null)
206
+ this.setError(undefined)
207
+ this.setStatus('idle')
208
+ }
209
+
210
+ /**
211
+ * Update options without recreating the client.
212
+ */
213
+ updateOptions(
214
+ options: Partial<
215
+ Pick<
216
+ GenerationClientOptions<TInput, TResult, TOutput>,
217
+ 'body' | 'onResult' | 'onError' | 'onProgress' | 'onChunk'
218
+ >
219
+ >,
220
+ ): void {
221
+ if (options.body !== undefined) {
222
+ this.body = options.body ?? {}
223
+ }
224
+ if (options.onResult !== undefined) {
225
+ this.callbacksRef.onResult = options.onResult
226
+ }
227
+ if (options.onError !== undefined) {
228
+ this.callbacksRef.onError = options.onError
229
+ }
230
+ if (options.onProgress !== undefined) {
231
+ this.callbacksRef.onProgress = options.onProgress
232
+ }
233
+ if (options.onChunk !== undefined) {
234
+ this.callbacksRef.onChunk = options.onChunk
235
+ }
236
+ }
237
+
238
+ // ===========================
239
+ // Getters
240
+ // ===========================
241
+
242
+ getResult(): TOutput | null {
243
+ return this.result
244
+ }
245
+
246
+ getIsLoading(): boolean {
247
+ return this.isLoading
248
+ }
249
+
250
+ getError(): Error | undefined {
251
+ return this.error
252
+ }
253
+
254
+ getStatus(): GenerationClientState {
255
+ return this.status
256
+ }
257
+
258
+ // ===========================
259
+ // Private state setters
260
+ // ===========================
261
+
262
+ private setResult(rawResult: TResult | null): void {
263
+ if (rawResult === null) {
264
+ this.result = null
265
+ this.callbacksRef.onResultChange?.(null)
266
+ return
267
+ }
268
+
269
+ if (this.callbacksRef.onResult) {
270
+ const transformed = this.callbacksRef.onResult(rawResult)
271
+ if (transformed === null) {
272
+ // null return → keep previous result unchanged
273
+ return
274
+ }
275
+ if (transformed !== undefined) {
276
+ // Non-null, non-undefined → use transformed value
277
+ this.result = transformed
278
+ this.callbacksRef.onResultChange?.(this.result)
279
+ return
280
+ }
281
+ }
282
+
283
+ // No onResult callback, or callback returned void → use raw value
284
+ this.result = rawResult as unknown as TOutput
285
+ this.callbacksRef.onResultChange?.(this.result)
286
+ }
287
+
288
+ private setIsLoading(isLoading: boolean): void {
289
+ this.isLoading = isLoading
290
+ this.callbacksRef.onLoadingChange?.(isLoading)
291
+ }
292
+
293
+ private setError(error: Error | undefined): void {
294
+ this.error = error
295
+ this.callbacksRef.onErrorChange?.(error)
296
+ }
297
+
298
+ private setStatus(status: GenerationClientState): void {
299
+ this.status = status
300
+ this.callbacksRef.onStatusChange?.(status)
301
+ }
302
+ }
@@ -0,0 +1,270 @@
1
+ import type { StreamChunk } from '@tanstack/ai'
2
+ import type { ConnectionAdapter } from './connection-adapters'
3
+
4
+ // ===========================
5
+ // Inference Utilities
6
+ // ===========================
7
+
8
+ /**
9
+ * Infers the output type from an `onResult` callback's return type.
10
+ *
11
+ * - If the callback returns a concrete type (excluding null/void/undefined), uses that type.
12
+ * - If the callback only returns null/void/undefined, or is not provided, falls back to TResult.
13
+ *
14
+ * @template TResult - The raw result type from the generation
15
+ * @template TFn - The onResult callback type (or undefined if not provided)
16
+ */
17
+ export type InferGenerationOutput<TResult, TFn> = TFn extends (
18
+ result: any,
19
+ ) => infer R
20
+ ? [Exclude<R, null | void | undefined>] extends [never]
21
+ ? TResult
22
+ : Exclude<R, null | void | undefined>
23
+ : TResult
24
+
25
+ // ===========================
26
+ // State
27
+ // ===========================
28
+
29
+ /**
30
+ * State machine for generation clients.
31
+ * Simpler than ChatClientState since generation is a single request/response cycle.
32
+ */
33
+ export type GenerationClientState = 'idle' | 'generating' | 'success' | 'error'
34
+
35
+ // ===========================
36
+ // Event Constants
37
+ // ===========================
38
+
39
+ /**
40
+ * Well-known CUSTOM event names used by generation clients.
41
+ * These events are emitted by the server-side streaming helpers
42
+ * and consumed by the client-side GenerationClient.
43
+ */
44
+ export const GENERATION_EVENTS = {
45
+ /** The generation result payload */
46
+ RESULT: 'generation:result',
47
+ /** Progress update (0-100) with optional message */
48
+ PROGRESS: 'generation:progress',
49
+ /** Video job created with jobId */
50
+ VIDEO_JOB_CREATED: 'video:job:created',
51
+ /** Video job status update */
52
+ VIDEO_STATUS: 'video:status',
53
+ } as const
54
+
55
+ // ===========================
56
+ // Transport Types
57
+ // ===========================
58
+
59
+ /**
60
+ * Options passed to a fetcher function by the generation client.
61
+ */
62
+ export interface GenerationFetcherOptions {
63
+ /** AbortSignal that is triggered when the user calls `stop()` */
64
+ signal: AbortSignal
65
+ }
66
+
67
+ /**
68
+ * A direct async function that performs a generation request.
69
+ *
70
+ * Can return the result directly, or return a `Response` with an SSE body
71
+ * (e.g., from a TanStack Start server function using `toServerSentEventsResponse()`).
72
+ * When a `Response` is returned, the client will parse it as an SSE stream.
73
+ *
74
+ * @template TInput - The input type for the generation request
75
+ * @template TResult - The result type returned by the generation
76
+ */
77
+ export type GenerationFetcher<TInput, TResult> = (
78
+ input: TInput,
79
+ options?: GenerationFetcherOptions,
80
+ ) => Promise<TResult | Response>
81
+
82
+ /**
83
+ * Transport configuration for generation clients.
84
+ * Supports either a ConnectionAdapter (streaming) or a direct fetcher function.
85
+ */
86
+ export type GenerationTransport<TInput, TResult> =
87
+ | { connection: ConnectionAdapter; fetcher?: never }
88
+ | { fetcher: GenerationFetcher<TInput, TResult>; connection?: never }
89
+
90
+ // ===========================
91
+ // Client Options
92
+ // ===========================
93
+
94
+ /**
95
+ * Options for the GenerationClient.
96
+ *
97
+ * @template TInput - The input type for the generation request (used by consuming code)
98
+ * @template TResult - The result type returned by the generation
99
+ * @template TOutput - The output type after optional transform (defaults to TResult)
100
+ */
101
+ // eslint-disable-next-line @typescript-eslint/naming-convention
102
+ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
103
+ /** Unique identifier for this generation client instance */
104
+ id?: string
105
+
106
+ /** Additional body parameters to send with ConnectionAdapter requests */
107
+ body?: Record<string, any>
108
+
109
+ /**
110
+ * Callback when a result is received. Can optionally return a transformed value
111
+ * that replaces the stored result.
112
+ *
113
+ * - Return a non-null value to transform and store it as the result
114
+ * - Return `null` to keep the previous result unchanged
115
+ * - Return nothing (`void`) to store the raw result as-is
116
+ */
117
+ onResult?: (result: TResult) => TOutput | null | void
118
+ /** Callback when an error occurs */
119
+ onError?: (error: Error) => void
120
+ /** Callback when progress is reported (0-100) */
121
+ onProgress?: (progress: number, message?: string) => void
122
+ /** Callback for each stream chunk (ConnectionAdapter mode only) */
123
+ onChunk?: (chunk: StreamChunk) => void
124
+
125
+ // Framework state callbacks (set by hooks, not users)
126
+ /** @internal Called when result changes */
127
+ onResultChange?: (result: TOutput | null) => void
128
+ /** @internal Called when loading state changes */
129
+ onLoadingChange?: (isLoading: boolean) => void
130
+ /** @internal Called when error state changes */
131
+ onErrorChange?: (error: Error | undefined) => void
132
+ /** @internal Called when generation status changes */
133
+ onStatusChange?: (status: GenerationClientState) => void
134
+ }
135
+
136
+ // ===========================
137
+ // Video-Specific Options
138
+ // ===========================
139
+
140
+ /**
141
+ * Video status information returned during job polling.
142
+ */
143
+ export interface VideoStatusInfo {
144
+ /** Job identifier */
145
+ jobId: string
146
+ /** Current status of the video generation job */
147
+ status: 'pending' | 'processing' | 'completed' | 'failed'
148
+ /** Progress percentage (0-100), if available */
149
+ progress?: number
150
+ /** URL to the generated video (when completed) */
151
+ url?: string
152
+ /** Error message if status is 'failed' */
153
+ error?: string
154
+ }
155
+
156
+ /**
157
+ * Composite result for video generation (job completion).
158
+ */
159
+ export interface VideoGenerateResult {
160
+ /** Job identifier */
161
+ jobId: string
162
+ /** Final status */
163
+ status: 'completed'
164
+ /** URL to the generated video */
165
+ url: string
166
+ /** When the URL expires, if applicable */
167
+ expiresAt?: Date
168
+ }
169
+
170
+ /**
171
+ * Options for the VideoGenerationClient.
172
+ */
173
+ export interface VideoGenerationClientOptions<
174
+ TOutput = VideoGenerateResult,
175
+ > extends GenerationClientOptions<
176
+ VideoGenerateInput,
177
+ VideoGenerateResult,
178
+ TOutput
179
+ > {
180
+ /** Callback when a video job is created */
181
+ onJobCreated?: (jobId: string) => void
182
+ /** Callback on each status update */
183
+ onStatusUpdate?: (status: VideoStatusInfo) => void
184
+
185
+ // Framework state callbacks
186
+ /** @internal Called when jobId changes */
187
+ onJobIdChange?: (jobId: string | null) => void
188
+ /** @internal Called when video status changes */
189
+ onVideoStatusChange?: (status: VideoStatusInfo | null) => void
190
+ }
191
+
192
+ // ===========================
193
+ // Input Types
194
+ // ===========================
195
+
196
+ /**
197
+ * Input for image generation.
198
+ */
199
+ export interface ImageGenerateInput {
200
+ /** Text description of the desired image(s) */
201
+ prompt: string
202
+ /** Number of images to generate (default: 1) */
203
+ numberOfImages?: number
204
+ /** Image size in WIDTHxHEIGHT format (e.g., "1024x1024") */
205
+ size?: string
206
+ /** Model-specific options */
207
+ modelOptions?: Record<string, any>
208
+ }
209
+
210
+ /**
211
+ * Input for text-to-speech generation.
212
+ */
213
+ export interface SpeechGenerateInput {
214
+ /** The text to convert to speech */
215
+ text: string
216
+ /** The voice to use for generation */
217
+ voice?: string
218
+ /** The output audio format */
219
+ format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
220
+ /** The speed of the generated audio (0.25 to 4.0) */
221
+ speed?: number
222
+ /** Model-specific options */
223
+ modelOptions?: Record<string, any>
224
+ }
225
+
226
+ /**
227
+ * Input for audio transcription.
228
+ */
229
+ export interface TranscriptionGenerateInput {
230
+ /** The audio data to transcribe - can be base64 string, File, or Blob */
231
+ audio: string | File | Blob
232
+ /** The language of the audio in ISO-639-1 format (e.g., 'en') */
233
+ language?: string
234
+ /** An optional prompt to guide the transcription */
235
+ prompt?: string
236
+ /** The format of the transcription output */
237
+ responseFormat?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
238
+ /** Model-specific options */
239
+ modelOptions?: Record<string, any>
240
+ }
241
+
242
+ /**
243
+ * Input for text summarization.
244
+ */
245
+ export interface SummarizeGenerateInput {
246
+ /** The text to summarize */
247
+ text: string
248
+ /** Maximum length of the summary */
249
+ maxLength?: number
250
+ /** Style of the summary */
251
+ style?: 'bullet-points' | 'paragraph' | 'concise'
252
+ /** Topics to focus on */
253
+ focus?: Array<string>
254
+ /** Model-specific options */
255
+ modelOptions?: Record<string, any>
256
+ }
257
+
258
+ /**
259
+ * Input for video generation.
260
+ */
261
+ export interface VideoGenerateInput {
262
+ /** Text description of the desired video */
263
+ prompt: string
264
+ /** Video size — format depends on provider (e.g., "16:9", "1280x720") */
265
+ size?: string
266
+ /** Video duration in seconds */
267
+ duration?: number
268
+ /** Model-specific options */
269
+ modelOptions?: Record<string, any>
270
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export { ChatClient } from './chat-client'
2
+ export { RealtimeClient } from './realtime-client'
3
+ export { GenerationClient } from './generation-client'
4
+ export { VideoGenerationClient } from './video-generation-client'
2
5
  export type {
3
6
  // Core message types (re-exported from @tanstack/ai via types.ts)
4
7
  UIMessage,
@@ -15,6 +18,24 @@ export type {
15
18
  // Multimodal content input type
16
19
  MultimodalContent,
17
20
  } from './types'
21
+ // Generation client types
22
+ export type {
23
+ InferGenerationOutput,
24
+ GenerationClientState,
25
+ GenerationClientOptions,
26
+ GenerationFetcher,
27
+ GenerationFetcherOptions,
28
+ GenerationTransport,
29
+ VideoGenerationClientOptions,
30
+ VideoStatusInfo,
31
+ VideoGenerateResult,
32
+ ImageGenerateInput,
33
+ SpeechGenerateInput,
34
+ TranscriptionGenerateInput,
35
+ SummarizeGenerateInput,
36
+ VideoGenerateInput,
37
+ } from './generation-types'
38
+ export { GENERATION_EVENTS } from './generation-types'
18
39
  export { clientTools, createChatClientOptions } from './types'
19
40
  export type {
20
41
  ExtractToolNames,
@@ -22,6 +43,13 @@ export type {
22
43
  ExtractToolOutput,
23
44
  } from './tool-types'
24
45
  export type { AnyClientTool } from '@tanstack/ai'
46
+ export type {
47
+ RealtimeAdapter,
48
+ RealtimeConnection,
49
+ RealtimeClientOptions,
50
+ RealtimeClientState,
51
+ RealtimeStateChangeCallback,
52
+ } from './realtime-types'
25
53
  export {
26
54
  fetchServerSentEvents,
27
55
  fetchHttpStream,