@tanstack/ai-client 0.12.0 → 0.13.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.
package/src/types.ts CHANGED
@@ -13,6 +13,8 @@ import type {
13
13
  VideoPart,
14
14
  } from '@tanstack/ai'
15
15
  import type { ConnectionAdapter } from './connection-adapters'
16
+ import type { AIDevtoolsClientMetadata } from './devtools'
17
+ import type { ChatDevtoolsBridgeFactory } from './devtools-noop'
16
18
 
17
19
  export type { StructuredOutputPart } from '@tanstack/ai'
18
20
 
@@ -270,6 +272,7 @@ export interface UIMessage<
270
272
  */
271
273
  export type ChatClientOptions<
272
274
  TTools extends ReadonlyArray<AnyClientTool> = any,
275
+ TContext = unknown,
273
276
  > = {
274
277
  /**
275
278
  * Initial messages to populate the chat
@@ -387,6 +390,25 @@ export type ChatClientOptions<
387
390
  */
388
391
  tools?: TTools
389
392
 
393
+ /**
394
+ * Client-local context passed to client-side tool execution.
395
+ */
396
+ context?: TContext
397
+
398
+ /**
399
+ * Devtools hook metadata for this client instance.
400
+ */
401
+ devtools?: Partial<AIDevtoolsClientMetadata>
402
+
403
+ /**
404
+ * Factory that constructs the devtools bridge. Default is a no-op
405
+ * factory, which keeps `@tanstack/ai-client/devtools` (the heavy
406
+ * bridge implementation) out of the main entry's bundle. Frameworks
407
+ * that need live devtools should pass the real factory from
408
+ * `@tanstack/ai-client/devtools`.
409
+ */
410
+ devtoolsBridgeFactory?: ChatDevtoolsBridgeFactory
411
+
390
412
  /**
391
413
  * Stream processing options (optional)
392
414
  * Configure chunking strategy
@@ -444,7 +466,10 @@ export function clientTools<const T extends Array<AnyClientTool>>(
444
466
  */
445
467
  export function createChatClientOptions<
446
468
  const TTools extends ReadonlyArray<AnyClientTool>,
447
- >(options: ChatClientOptions<TTools>): ChatClientOptions<TTools> {
469
+ TContext = unknown,
470
+ >(
471
+ options: ChatClientOptions<TTools, TContext>,
472
+ ): ChatClientOptions<TTools, TContext> {
448
473
  return options
449
474
  }
450
475
 
@@ -463,4 +488,6 @@ export function createChatClientOptions<
463
488
  * ```
464
489
  */
465
490
  export type InferChatMessages<T> =
466
- T extends ChatClientOptions<infer TTools> ? Array<UIMessage<TTools>> : never
491
+ T extends ChatClientOptions<infer TTools, any>
492
+ ? Array<UIMessage<TTools>>
493
+ : never
@@ -1,7 +1,17 @@
1
1
  import { GENERATION_EVENTS } from './generation-types'
2
+ import { createNoOpVideoDevtoolsBridge } from './devtools-noop'
2
3
  import { parseSSEResponse } from './sse-parser'
3
4
  import type { StreamChunk } from '@tanstack/ai'
4
- import type { ConnectConnectionAdapter } from './connection-adapters'
5
+ import type {
6
+ ConnectConnectionAdapter,
7
+ RunAgentInputContext,
8
+ } from './connection-adapters'
9
+ import type {
10
+ AIDevtoolsClientMetadata,
11
+ AIDevtoolsGenerationProgress,
12
+ VideoDevtoolsBridge,
13
+ VideoDevtoolsBridgeOptions,
14
+ } from './devtools'
5
15
  import type {
6
16
  GenerationClientState,
7
17
  GenerationFetcher,
@@ -74,9 +84,15 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
74
84
  private readonly fetcher:
75
85
  | GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
76
86
  | undefined
87
+ private readonly uniqueId: string
88
+ private readonly devtoolsMetadata: AIDevtoolsClientMetadata
89
+ private readonly devtoolsBridge: VideoDevtoolsBridge<TOutput>
90
+ private readonly threadId: string
77
91
  private body: Record<string, any>
78
92
 
79
93
  private result: TOutput | null = null
94
+ private input: VideoGenerateInput | null = null
95
+ private progress: AIDevtoolsGenerationProgress | null = null
80
96
  private jobId: string | null = null
81
97
  private videoStatus: VideoStatusInfo | null = null
82
98
  private isLoading = false
@@ -84,6 +100,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
84
100
  private status: GenerationClientState = 'idle'
85
101
  private abortController: AbortController | null = null
86
102
  private readonly callbacksRef: VideoCallbacks<TOutput>
103
+ private devtoolsMounted = false
87
104
 
88
105
  constructor(
89
106
  options: VideoGenerationClientOptions<TOutput> &
@@ -95,6 +112,8 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
95
112
  }
96
113
  ),
97
114
  ) {
115
+ this.uniqueId = options.id ?? this.generateUniqueId('video')
116
+ this.threadId = this.uniqueId
98
117
  this.connection = options.connection
99
118
  this.fetcher = options.fetcher
100
119
  this.body = options.body ?? {}
@@ -113,6 +132,40 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
113
132
  onJobIdChange: options.onJobIdChange,
114
133
  onVideoStatusChange: options.onVideoStatusChange,
115
134
  }
135
+
136
+ this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools)
137
+ this.devtoolsBridge = (
138
+ options.devtoolsBridgeFactory ?? createNoOpVideoDevtoolsBridge
139
+ )<TOutput>(this.buildDevtoolsBridgeOptions())
140
+ }
141
+
142
+ private buildDevtoolsBridgeOptions(): VideoDevtoolsBridgeOptions<TOutput> {
143
+ return {
144
+ hookId: this.uniqueId,
145
+ clientId: this.uniqueId,
146
+ threadId: this.threadId,
147
+ metadata: this.devtoolsMetadata,
148
+ getCoreState: () => ({
149
+ input: this.input,
150
+ result: this.result,
151
+ progress: this.progress,
152
+ status: this.status,
153
+ isLoading: this.isLoading,
154
+ jobId: this.jobId,
155
+ videoStatus: this.videoStatus,
156
+ ...(this.error ? { error: this.error.message } : {}),
157
+ }),
158
+ }
159
+ }
160
+
161
+ mountDevtools(): void {
162
+ if (this.devtoolsMounted) {
163
+ return
164
+ }
165
+
166
+ this.devtoolsMounted = true
167
+ this.devtoolsBridge.emitRegistered()
168
+ this.devtoolsBridge.emitSnapshot()
116
169
  }
117
170
 
118
171
  /**
@@ -120,8 +173,12 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
120
173
  * Only one generation can be in-flight at a time.
121
174
  */
122
175
  async generate(input: VideoGenerateInput): Promise<void> {
176
+ this.mountDevtools()
123
177
  if (this.isLoading) return
124
178
 
179
+ this.input = input
180
+ this.progress = null
181
+ const runId = this.devtoolsBridge.beginRun(input)
125
182
  this.setIsLoading(true)
126
183
  this.setStatus('generating')
127
184
  this.setError(undefined)
@@ -134,21 +191,39 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
134
191
 
135
192
  try {
136
193
  if (this.fetcher) {
137
- await this.generateWithFetcher(input, signal)
194
+ await this.generateWithFetcher(input, signal, runId)
138
195
  } else if (this.connection) {
139
196
  const mergedData = { ...this.body, ...input }
140
- const stream = this.connection.connect([], mergedData, signal)
141
- await this.processStream(stream)
197
+ const stream = this.connection.connect(
198
+ [],
199
+ mergedData,
200
+ signal,
201
+ this.createRunContext(runId),
202
+ )
203
+ await this.processStream(stream, runId)
142
204
  } else {
143
205
  throw new Error(
144
206
  'VideoGenerationClient requires either a connection or fetcher option',
145
207
  )
146
208
  }
147
- } catch (err: any) {
209
+ if (!signal.aborted && this.status === 'success') {
210
+ this.devtoolsBridge.finishRun(
211
+ this.devtoolsBridge.getActiveRunId() ?? runId,
212
+ 'run:completed',
213
+ 'completed',
214
+ )
215
+ }
216
+ } catch (err: unknown) {
148
217
  if (signal.aborted) return
149
218
  const error = err instanceof Error ? err : new Error(String(err))
150
219
  this.setError(error)
151
220
  this.setStatus('error')
221
+ this.devtoolsBridge.finishRun(
222
+ this.devtoolsBridge.getActiveRunId() ?? runId,
223
+ 'run:errored',
224
+ 'errored',
225
+ error.message,
226
+ )
152
227
  this.callbacksRef.onError?.(error)
153
228
  } finally {
154
229
  this.abortController = null
@@ -162,6 +237,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
162
237
  private async generateWithFetcher(
163
238
  input: VideoGenerateInput,
164
239
  signal: AbortSignal,
240
+ runId: string,
165
241
  ): Promise<void> {
166
242
  if (!this.fetcher) return
167
243
 
@@ -171,8 +247,9 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
171
247
 
172
248
  if (result instanceof Response) {
173
249
  // Server function returned SSE Response — parse stream
174
- await this.processStream(parseSSEResponse(result, signal))
250
+ await this.processStream(parseSSEResponse(result, signal), runId)
175
251
  } else {
252
+ this.devtoolsBridge.ensureRunStarted(runId)
176
253
  this.setResult(result)
177
254
  this.setStatus('success')
178
255
  }
@@ -184,15 +261,28 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
184
261
  */
185
262
  private async processStream(
186
263
  source: AsyncIterable<StreamChunk>,
264
+ fallbackRunId: string,
187
265
  ): Promise<void> {
266
+ let streamRunId: string | undefined
267
+
188
268
  for await (const chunk of source) {
189
269
  if (this.abortController?.signal.aborted) break
190
270
 
191
271
  this.callbacksRef.onChunk?.(chunk)
272
+ const chunkRunId =
273
+ 'runId' in chunk && typeof chunk.runId === 'string'
274
+ ? chunk.runId
275
+ : undefined
192
276
 
193
277
  // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- AG-UI EventType has ~22 variants; this consumer only handles the subset relevant to video generation lifecycle.
194
278
  switch (chunk.type) {
279
+ case 'RUN_STARTED': {
280
+ streamRunId = chunk.runId
281
+ this.devtoolsBridge.ensureRunStarted(chunk.runId)
282
+ break
283
+ }
195
284
  case 'CUSTOM': {
285
+ this.devtoolsBridge.ensureRunStarted(streamRunId ?? fallbackRunId)
196
286
  if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) {
197
287
  const { jobId } = chunk.value as { jobId: string }
198
288
  this.setJobId(jobId)
@@ -202,7 +292,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
202
292
  this.setVideoStatus(statusInfo)
203
293
  this.callbacksRef.onStatusUpdate?.(statusInfo)
204
294
  if (statusInfo.progress !== undefined) {
205
- this.callbacksRef.onProgress?.(statusInfo.progress)
295
+ this.setProgress(statusInfo.progress)
206
296
  }
207
297
  } else if (chunk.name === GENERATION_EVENTS.RESULT) {
208
298
  this.setResult(chunk.value as VideoGenerateResult)
@@ -211,15 +301,20 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
211
301
  progress: number
212
302
  message?: string
213
303
  }
214
- this.callbacksRef.onProgress?.(progress, message)
304
+ this.setProgress(progress, message)
215
305
  }
216
306
  break
217
307
  }
218
308
  case 'RUN_FINISHED': {
309
+ streamRunId = chunk.runId
310
+ this.devtoolsBridge.ensureRunStarted(chunk.runId)
219
311
  this.setStatus('success')
220
312
  break
221
313
  }
222
314
  case 'RUN_ERROR': {
315
+ this.devtoolsBridge.ensureRunStarted(
316
+ chunkRunId ?? streamRunId ?? fallbackRunId,
317
+ )
223
318
  // Prefer spec `message`; fall back to deprecated `error.message`
224
319
  const msg =
225
320
  (chunk.message as string | undefined) ||
@@ -237,6 +332,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
237
332
  * Abort any in-flight generation or polling.
238
333
  */
239
334
  stop(): void {
335
+ const runId = this.devtoolsBridge.getActiveRunId()
240
336
  if (this.abortController) {
241
337
  this.abortController.abort()
242
338
  this.abortController = null
@@ -244,6 +340,9 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
244
340
  this.setIsLoading(false)
245
341
  if (this.status === 'generating') {
246
342
  this.setStatus('idle')
343
+ if (runId) {
344
+ this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled')
345
+ }
247
346
  }
248
347
  }
249
348
 
@@ -253,10 +352,14 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
253
352
  reset(): void {
254
353
  this.stop()
255
354
  this.setResult(null)
355
+ this.input = null
356
+ this.progress = null
357
+ this.devtoolsBridge.resetRuns()
256
358
  this.setJobId(null)
257
359
  this.setVideoStatus(null)
258
360
  this.setError(undefined)
259
361
  this.setStatus('idle')
362
+ this.devtoolsBridge.emitState()
260
363
  }
261
364
 
262
365
  /**
@@ -299,6 +402,12 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
299
402
  }
300
403
  }
301
404
 
405
+ dispose(): void {
406
+ this.stop()
407
+ this.devtoolsBridge.dispose()
408
+ this.devtoolsMounted = false
409
+ }
410
+
302
411
  // ===========================
303
412
  // Getters
304
413
  // ===========================
@@ -335,50 +444,116 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
335
444
  if (rawResult === null) {
336
445
  this.result = null
337
446
  this.callbacksRef.onResultChange?.(null)
447
+ this.devtoolsBridge.recordResultChange()
338
448
  return
339
449
  }
340
450
 
451
+ const completedStatus = this.createCompletedVideoStatus(rawResult)
452
+ if (this.progress?.value !== 100) {
453
+ this.setProgress(100, this.progress?.message)
454
+ }
455
+ this.setJobId(rawResult.jobId)
456
+ this.setVideoStatus(completedStatus)
457
+
341
458
  if (this.callbacksRef.onResult) {
342
459
  const transformed = this.callbacksRef.onResult(rawResult)
343
460
  if (transformed === null) {
344
- // null return → keep previous result unchanged
461
+ // null return → keep previous result unchanged, just re-emit
462
+ this.devtoolsBridge.emitState()
345
463
  return
346
464
  }
347
465
  if (transformed !== undefined) {
348
466
  // Non-null, non-undefined → use transformed value
349
467
  this.result = transformed
350
468
  this.callbacksRef.onResultChange?.(this.result)
469
+ this.devtoolsBridge.recordResultChange()
351
470
  return
352
471
  }
353
472
  }
354
473
 
355
- // No onResult callback, or callback returned void → use raw value
356
- this.result = rawResult as TOutput
474
+ // No onResult callback, or callback returned void → use raw value as
475
+ // TOutput. When the caller did not supply an onResult transform,
476
+ // `TOutput` defaults to `VideoGenerateResult`, so the runtime cast is
477
+ // sound.
478
+ // eslint-disable-next-line no-restricted-syntax -- TOutput defaults to VideoGenerateResult when no onResult transform is supplied
479
+ this.result = rawResult as unknown as TOutput
357
480
  this.callbacksRef.onResultChange?.(this.result)
481
+ this.devtoolsBridge.recordResultChange()
358
482
  }
359
483
 
360
484
  private setJobId(jobId: string | null): void {
361
485
  this.jobId = jobId
362
486
  this.callbacksRef.onJobIdChange?.(jobId)
487
+ this.devtoolsBridge.recordJobIdChange()
363
488
  }
364
489
 
365
490
  private setVideoStatus(status: VideoStatusInfo | null): void {
366
491
  this.videoStatus = status
367
492
  this.callbacksRef.onVideoStatusChange?.(status)
493
+ this.devtoolsBridge.recordVideoStatusChange()
368
494
  }
369
495
 
370
496
  private setIsLoading(isLoading: boolean): void {
371
497
  this.isLoading = isLoading
372
498
  this.callbacksRef.onLoadingChange?.(isLoading)
499
+ this.devtoolsBridge.recordLoadingChange()
373
500
  }
374
501
 
375
502
  private setError(error: Error | undefined): void {
376
503
  this.error = error
377
504
  this.callbacksRef.onErrorChange?.(error)
505
+ this.devtoolsBridge.recordErrorChange(error)
378
506
  }
379
507
 
380
508
  private setStatus(status: GenerationClientState): void {
381
509
  this.status = status
382
510
  this.callbacksRef.onStatusChange?.(status)
511
+ this.devtoolsBridge.recordStatusChange(status)
512
+ }
513
+
514
+ private setProgress(value: number, message?: string): void {
515
+ this.progress = {
516
+ value,
517
+ ...(message ? { message } : {}),
518
+ }
519
+ if (message === undefined) {
520
+ this.callbacksRef.onProgress?.(value)
521
+ } else {
522
+ this.callbacksRef.onProgress?.(value, message)
523
+ }
524
+ this.devtoolsBridge.recordProgressChange()
525
+ }
526
+
527
+ private createCompletedVideoStatus(
528
+ result: VideoGenerateResult,
529
+ ): VideoStatusInfo {
530
+ return {
531
+ jobId: result.jobId,
532
+ status: result.status,
533
+ progress: 100,
534
+ url: result.url,
535
+ }
536
+ }
537
+
538
+ private createDevtoolsMetadata(
539
+ metadata?: Partial<AIDevtoolsClientMetadata>,
540
+ ): AIDevtoolsClientMetadata {
541
+ return {
542
+ hookName: metadata?.hookName ?? 'useGenerateVideo',
543
+ outputKind: metadata?.outputKind ?? 'video',
544
+ ...(metadata?.framework ? { framework: metadata.framework } : {}),
545
+ ...(metadata?.name ? { name: metadata.name } : {}),
546
+ }
547
+ }
548
+
549
+ private generateUniqueId(prefix: string): string {
550
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
551
+ }
552
+
553
+ private createRunContext(runId: string): RunAgentInputContext {
554
+ return {
555
+ threadId: this.threadId,
556
+ runId,
557
+ }
383
558
  }
384
559
  }