@tanstack/ai-client 0.5.2 → 0.6.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.
@@ -0,0 +1,371 @@
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
+ GenerationClientState,
7
+ GenerationFetcher,
8
+ VideoGenerateInput,
9
+ VideoGenerateResult,
10
+ VideoGenerationClientOptions,
11
+ VideoStatusInfo,
12
+ } from './generation-types'
13
+
14
+ /**
15
+ * Callbacks stored in a ref so hooks can update them without recreating the client.
16
+ */
17
+ interface VideoCallbacks<TOutput> {
18
+ onResult?: (result: VideoGenerateResult) => TOutput | null | void
19
+ onError?: (error: Error) => void
20
+ onProgress?: (progress: number, message?: string) => void
21
+ onChunk?: (chunk: StreamChunk) => void
22
+ onJobCreated?: (jobId: string) => void
23
+ onStatusUpdate?: (status: VideoStatusInfo) => void
24
+ onResultChange?: (result: TOutput | null) => void
25
+ onLoadingChange?: (isLoading: boolean) => void
26
+ onErrorChange?: (error: Error | undefined) => void
27
+ onStatusChange?: (status: GenerationClientState) => void
28
+ onJobIdChange?: (jobId: string | null) => void
29
+ onVideoStatusChange?: (status: VideoStatusInfo | null) => void
30
+ }
31
+
32
+ /**
33
+ * A specialized client for job-based video generation.
34
+ *
35
+ * Video generation is asynchronous: a job is created, then polled for status
36
+ * until completion. This client handles the full lifecycle.
37
+ *
38
+ * Supports two transport modes:
39
+ * - **ConnectionAdapter** — Server handles the polling loop internally and
40
+ * streams status updates via CUSTOM events.
41
+ * - **Fetcher** — Direct async function that returns a completed
42
+ * `VideoGenerateResult`.
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * // With ConnectionAdapter (server-driven polling)
47
+ * const client = new VideoGenerationClient({
48
+ * connection: fetchServerSentEvents('/api/generate/video'),
49
+ * onResultChange: setResult,
50
+ * onVideoStatusChange: setVideoStatus,
51
+ * })
52
+ *
53
+ * // With fetcher (direct result)
54
+ * const client = new VideoGenerationClient({
55
+ * fetcher: async (input) => {
56
+ * const res = await fetch('/api/video/generate', {
57
+ * method: 'POST',
58
+ * body: JSON.stringify(input),
59
+ * })
60
+ * return res.json() // { jobId, status: 'completed', url, expiresAt }
61
+ * },
62
+ * })
63
+ *
64
+ * await client.generate({ prompt: 'A flying car over a city' })
65
+ * ```
66
+ */
67
+ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
68
+ private connection: ConnectionAdapter | undefined
69
+ private fetcher:
70
+ | GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
71
+ | undefined
72
+ private body: Record<string, any>
73
+
74
+ private result: TOutput | null = null
75
+ private jobId: string | null = null
76
+ private videoStatus: VideoStatusInfo | null = null
77
+ private isLoading = false
78
+ private error: Error | undefined = undefined
79
+ private status: GenerationClientState = 'idle'
80
+ private abortController: AbortController | null = null
81
+ private callbacksRef: VideoCallbacks<TOutput>
82
+
83
+ constructor(
84
+ options: VideoGenerationClientOptions<TOutput> &
85
+ (
86
+ | { connection: ConnectionAdapter; fetcher?: never }
87
+ | {
88
+ fetcher: GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
89
+ connection?: never
90
+ }
91
+ ),
92
+ ) {
93
+ this.connection = options.connection
94
+ this.fetcher = options.fetcher
95
+ this.body = options.body ?? {}
96
+
97
+ this.callbacksRef = {
98
+ onResult: options.onResult,
99
+ onError: options.onError,
100
+ onProgress: options.onProgress,
101
+ onChunk: options.onChunk,
102
+ onJobCreated: options.onJobCreated,
103
+ onStatusUpdate: options.onStatusUpdate,
104
+ onResultChange: options.onResultChange,
105
+ onLoadingChange: options.onLoadingChange,
106
+ onErrorChange: options.onErrorChange,
107
+ onStatusChange: options.onStatusChange,
108
+ onJobIdChange: options.onJobIdChange,
109
+ onVideoStatusChange: options.onVideoStatusChange,
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Trigger video generation.
115
+ * Only one generation can be in-flight at a time.
116
+ */
117
+ async generate(input: VideoGenerateInput): Promise<void> {
118
+ if (this.isLoading) return
119
+
120
+ this.setIsLoading(true)
121
+ this.setStatus('generating')
122
+ this.setError(undefined)
123
+ this.setJobId(null)
124
+ this.setVideoStatus(null)
125
+
126
+ const abortController = new AbortController()
127
+ this.abortController = abortController
128
+ const { signal } = abortController
129
+
130
+ try {
131
+ if (this.fetcher) {
132
+ await this.generateWithFetcher(input, signal)
133
+ } else if (this.connection) {
134
+ const mergedData = { ...this.body, ...input }
135
+ const stream = this.connection.connect([], mergedData, signal)
136
+ await this.processStream(stream)
137
+ } else {
138
+ throw new Error(
139
+ 'VideoGenerationClient requires either a connection or fetcher option',
140
+ )
141
+ }
142
+ } catch (err: any) {
143
+ if (signal.aborted) return
144
+ const error = err instanceof Error ? err : new Error(String(err))
145
+ this.setError(error)
146
+ this.setStatus('error')
147
+ this.callbacksRef.onError?.(error)
148
+ } finally {
149
+ this.abortController = null
150
+ this.setIsLoading(false)
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Direct fetcher mode: call fetcher and set result.
156
+ */
157
+ private async generateWithFetcher(
158
+ input: VideoGenerateInput,
159
+ signal: AbortSignal,
160
+ ): Promise<void> {
161
+ if (!this.fetcher) return
162
+
163
+ // Fetcher returns a completed result directly, or a Response with SSE body
164
+ const result = await this.fetcher(input, { signal })
165
+ if (signal.aborted) return
166
+
167
+ if (result instanceof Response) {
168
+ // Server function returned SSE Response — parse stream
169
+ await this.processStream(parseSSEResponse(result, signal))
170
+ } else {
171
+ this.setResult(result)
172
+ this.setStatus('success')
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Process a stream of AG-UI events from the ConnectionAdapter.
178
+ * The server handles the polling loop and streams status updates.
179
+ */
180
+ private async processStream(
181
+ source: AsyncIterable<StreamChunk>,
182
+ ): Promise<void> {
183
+ for await (const chunk of source) {
184
+ if (this.abortController?.signal.aborted) break
185
+
186
+ this.callbacksRef.onChunk?.(chunk)
187
+
188
+ switch (chunk.type) {
189
+ case 'CUSTOM': {
190
+ if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) {
191
+ const { jobId } = chunk.value as { jobId: string }
192
+ this.setJobId(jobId)
193
+ this.callbacksRef.onJobCreated?.(jobId)
194
+ } else if (chunk.name === GENERATION_EVENTS.VIDEO_STATUS) {
195
+ const statusInfo = chunk.value as VideoStatusInfo
196
+ this.setVideoStatus(statusInfo)
197
+ this.callbacksRef.onStatusUpdate?.(statusInfo)
198
+ if (statusInfo.progress !== undefined) {
199
+ this.callbacksRef.onProgress?.(statusInfo.progress)
200
+ }
201
+ } else if (chunk.name === GENERATION_EVENTS.RESULT) {
202
+ this.setResult(chunk.value as VideoGenerateResult)
203
+ } else if (chunk.name === GENERATION_EVENTS.PROGRESS) {
204
+ const { progress, message } = chunk.value as {
205
+ progress: number
206
+ message?: string
207
+ }
208
+ this.callbacksRef.onProgress?.(progress, message)
209
+ }
210
+ break
211
+ }
212
+ case 'RUN_FINISHED': {
213
+ this.setStatus('success')
214
+ break
215
+ }
216
+ case 'RUN_ERROR': {
217
+ throw new Error(chunk.error.message)
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Abort any in-flight generation or polling.
225
+ */
226
+ stop(): void {
227
+ if (this.abortController) {
228
+ this.abortController.abort()
229
+ this.abortController = null
230
+ }
231
+ this.setIsLoading(false)
232
+ if (this.status === 'generating') {
233
+ this.setStatus('idle')
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Clear all state and return to idle.
239
+ */
240
+ reset(): void {
241
+ this.stop()
242
+ this.setResult(null)
243
+ this.setJobId(null)
244
+ this.setVideoStatus(null)
245
+ this.setError(undefined)
246
+ this.setStatus('idle')
247
+ }
248
+
249
+ /**
250
+ * Update options without recreating the client.
251
+ */
252
+ updateOptions(
253
+ options: Partial<
254
+ Pick<
255
+ VideoGenerationClientOptions<TOutput>,
256
+ | 'body'
257
+ | 'onResult'
258
+ | 'onError'
259
+ | 'onProgress'
260
+ | 'onChunk'
261
+ | 'onJobCreated'
262
+ | 'onStatusUpdate'
263
+ >
264
+ >,
265
+ ): void {
266
+ if (options.body !== undefined) {
267
+ this.body = options.body ?? {}
268
+ }
269
+ if (options.onResult !== undefined) {
270
+ this.callbacksRef.onResult = options.onResult
271
+ }
272
+ if (options.onError !== undefined) {
273
+ this.callbacksRef.onError = options.onError
274
+ }
275
+ if (options.onProgress !== undefined) {
276
+ this.callbacksRef.onProgress = options.onProgress
277
+ }
278
+ if (options.onChunk !== undefined) {
279
+ this.callbacksRef.onChunk = options.onChunk
280
+ }
281
+ if (options.onJobCreated !== undefined) {
282
+ this.callbacksRef.onJobCreated = options.onJobCreated
283
+ }
284
+ if (options.onStatusUpdate !== undefined) {
285
+ this.callbacksRef.onStatusUpdate = options.onStatusUpdate
286
+ }
287
+ }
288
+
289
+ // ===========================
290
+ // Getters
291
+ // ===========================
292
+
293
+ getResult(): TOutput | null {
294
+ return this.result
295
+ }
296
+
297
+ getJobId(): string | null {
298
+ return this.jobId
299
+ }
300
+
301
+ getVideoStatus(): VideoStatusInfo | null {
302
+ return this.videoStatus
303
+ }
304
+
305
+ getIsLoading(): boolean {
306
+ return this.isLoading
307
+ }
308
+
309
+ getError(): Error | undefined {
310
+ return this.error
311
+ }
312
+
313
+ getStatus(): GenerationClientState {
314
+ return this.status
315
+ }
316
+
317
+ // ===========================
318
+ // Private state setters
319
+ // ===========================
320
+
321
+ private setResult(rawResult: VideoGenerateResult | null): void {
322
+ if (rawResult === null) {
323
+ this.result = null
324
+ this.callbacksRef.onResultChange?.(null)
325
+ return
326
+ }
327
+
328
+ if (this.callbacksRef.onResult) {
329
+ const transformed = this.callbacksRef.onResult(rawResult)
330
+ if (transformed === null) {
331
+ // null return → keep previous result unchanged
332
+ return
333
+ }
334
+ if (transformed !== undefined) {
335
+ // Non-null, non-undefined → use transformed value
336
+ this.result = transformed
337
+ this.callbacksRef.onResultChange?.(this.result)
338
+ return
339
+ }
340
+ }
341
+
342
+ // No onResult callback, or callback returned void → use raw value
343
+ this.result = rawResult as unknown as TOutput
344
+ this.callbacksRef.onResultChange?.(this.result)
345
+ }
346
+
347
+ private setJobId(jobId: string | null): void {
348
+ this.jobId = jobId
349
+ this.callbacksRef.onJobIdChange?.(jobId)
350
+ }
351
+
352
+ private setVideoStatus(status: VideoStatusInfo | null): void {
353
+ this.videoStatus = status
354
+ this.callbacksRef.onVideoStatusChange?.(status)
355
+ }
356
+
357
+ private setIsLoading(isLoading: boolean): void {
358
+ this.isLoading = isLoading
359
+ this.callbacksRef.onLoadingChange?.(isLoading)
360
+ }
361
+
362
+ private setError(error: Error | undefined): void {
363
+ this.error = error
364
+ this.callbacksRef.onErrorChange?.(error)
365
+ }
366
+
367
+ private setStatus(status: GenerationClientState): void {
368
+ this.status = status
369
+ this.callbacksRef.onStatusChange?.(status)
370
+ }
371
+ }