@tanstack/ai-client 0.12.0 → 0.14.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/chat-client.d.ts +16 -2
  2. package/dist/esm/chat-client.js +197 -31
  3. package/dist/esm/chat-client.js.map +1 -1
  4. package/dist/esm/connection-adapters.d.ts +21 -1
  5. package/dist/esm/connection-adapters.js +262 -68
  6. package/dist/esm/connection-adapters.js.map +1 -1
  7. package/dist/esm/devtools-noop.d.ts +60 -0
  8. package/dist/esm/devtools-noop.js +135 -0
  9. package/dist/esm/devtools-noop.js.map +1 -0
  10. package/dist/esm/devtools.d.ts +276 -0
  11. package/dist/esm/devtools.js +1178 -0
  12. package/dist/esm/devtools.js.map +1 -0
  13. package/dist/esm/events.d.ts +51 -8
  14. package/dist/esm/events.js +65 -20
  15. package/dist/esm/events.js.map +1 -1
  16. package/dist/esm/generation-client.d.ts +14 -0
  17. package/dist/esm/generation-client.js +135 -5
  18. package/dist/esm/generation-client.js.map +1 -1
  19. package/dist/esm/generation-types.d.ts +16 -2
  20. package/dist/esm/generation-types.js.map +1 -1
  21. package/dist/esm/index.d.ts +6 -4
  22. package/dist/esm/index.js +9 -3
  23. package/dist/esm/index.js.map +1 -1
  24. package/dist/esm/realtime-client.d.ts +1 -1
  25. package/dist/esm/realtime-client.js +1 -1
  26. package/dist/esm/realtime-client.js.map +1 -1
  27. package/dist/esm/realtime-types.d.ts +1 -1
  28. package/dist/esm/response-stream.d.ts +7 -0
  29. package/dist/esm/response-stream.js +32 -0
  30. package/dist/esm/response-stream.js.map +1 -0
  31. package/dist/esm/sse-parser.d.ts +1 -1
  32. package/dist/esm/sse-parser.js +5 -6
  33. package/dist/esm/sse-parser.js.map +1 -1
  34. package/dist/esm/sse-utils.d.ts +1 -0
  35. package/dist/esm/sse-utils.js +11 -0
  36. package/dist/esm/sse-utils.js.map +1 -0
  37. package/dist/esm/tool-types.d.ts +1 -1
  38. package/dist/esm/types.d.ts +23 -5
  39. package/dist/esm/types.js.map +1 -1
  40. package/dist/esm/video-generation-client.d.ts +15 -0
  41. package/dist/esm/video-generation-client.js +147 -8
  42. package/dist/esm/video-generation-client.js.map +1 -1
  43. package/package.json +7 -3
  44. package/src/chat-client.ts +250 -33
  45. package/src/connection-adapters.ts +360 -68
  46. package/src/devtools-noop.ts +205 -0
  47. package/src/devtools.ts +1866 -0
  48. package/src/events.ts +143 -12
  49. package/src/generation-client.ts +177 -10
  50. package/src/generation-types.ts +24 -5
  51. package/src/index.ts +16 -3
  52. package/src/realtime-client.ts +2 -2
  53. package/src/realtime-types.ts +1 -1
  54. package/src/response-stream.ts +42 -0
  55. package/src/sse-parser.ts +9 -7
  56. package/src/sse-utils.ts +8 -0
  57. package/src/tool-types.ts +1 -1
  58. package/src/types.ts +31 -4
  59. package/src/video-generation-client.ts +187 -12
package/src/events.ts CHANGED
@@ -1,7 +1,57 @@
1
- import { aiEventClient } from '@tanstack/ai-event-client'
2
- import type { ContentPart } from '@tanstack/ai'
1
+ import {
2
+ aiEventClient,
3
+ createAIDevtoolsEventEnvelope,
4
+ } from '@tanstack/ai-event-client'
5
+ import type { ContentPart } from '@tanstack/ai/client'
3
6
  import type { UIMessage } from './types'
4
7
 
8
+ export interface ChatClientRunEventContext {
9
+ threadId: string
10
+ runId: string
11
+ toolCallId?: string
12
+ }
13
+
14
+ export interface ChatClientEventContext {
15
+ threadId?: string
16
+ runId?: string
17
+ toolCallId?: string
18
+ }
19
+
20
+ export interface ChatClientToolFixtureAppliedEvent {
21
+ hookId: string
22
+ threadId: string
23
+ toolName: string
24
+ input: unknown
25
+ output: unknown
26
+ execute?: boolean
27
+ message?: {
28
+ id: string
29
+ role: 'system' | 'user' | 'assistant'
30
+ parts: Array<unknown>
31
+ createdAt?: number | string
32
+ }
33
+ messageId: string
34
+ toolCallId: string
35
+ runId?: string
36
+ errorText?: string
37
+ }
38
+
39
+ export interface ChatClientStructuredOutputEvent {
40
+ status: 'streaming' | 'complete' | 'error'
41
+ raw?: string
42
+ partial?: unknown
43
+ data?: unknown
44
+ reasoning?: string
45
+ errorMessage?: string
46
+ delta?: string
47
+ }
48
+
49
+ export type ChatClientStructuredOutputEventName =
50
+ | 'structured-output:started'
51
+ | 'structured-output:updated'
52
+ | 'structured-output:completed'
53
+ | 'structured-output:errored'
54
+
5
55
  /**
6
56
  * Abstract base class for ChatClient event emission
7
57
  */
@@ -49,11 +99,17 @@ export abstract class ChatClientEventEmitter {
49
99
  /**
50
100
  * Emit text update events (combines processor and client events)
51
101
  */
52
- textUpdated(streamId: string, messageId: string, content: string): void {
102
+ textUpdated(
103
+ streamId: string,
104
+ messageId: string,
105
+ content: string,
106
+ context?: ChatClientRunEventContext,
107
+ ): void {
53
108
  this.emitEvent('text:chunk:content', {
54
109
  streamId,
55
110
  messageId,
56
111
  content,
112
+ ...context,
57
113
  })
58
114
  }
59
115
 
@@ -67,6 +123,7 @@ export abstract class ChatClientEventEmitter {
67
123
  toolName: string,
68
124
  state: string,
69
125
  args: string,
126
+ context?: ChatClientRunEventContext,
70
127
  ): void {
71
128
  this.emitEvent('tools:call:updated', {
72
129
  streamId,
@@ -75,6 +132,7 @@ export abstract class ChatClientEventEmitter {
75
132
  toolName,
76
133
  state,
77
134
  arguments: args,
135
+ ...context,
78
136
  })
79
137
  }
80
138
 
@@ -89,12 +147,29 @@ export abstract class ChatClientEventEmitter {
89
147
  messageId: string,
90
148
  content: string,
91
149
  delta?: string,
150
+ context?: ChatClientRunEventContext,
92
151
  ): void {
93
152
  this.emitEvent('text:chunk:thinking', {
94
153
  streamId,
95
154
  messageId,
96
155
  content,
97
156
  delta,
157
+ ...context,
158
+ })
159
+ }
160
+
161
+ structuredOutputChanged(
162
+ eventName: ChatClientStructuredOutputEventName,
163
+ streamId: string,
164
+ messageId: string,
165
+ output: ChatClientStructuredOutputEvent,
166
+ context?: ChatClientRunEventContext,
167
+ ): void {
168
+ this.emitEvent(eventName, {
169
+ streamId,
170
+ messageId,
171
+ ...output,
172
+ ...context,
98
173
  })
99
174
  }
100
175
 
@@ -108,6 +183,7 @@ export abstract class ChatClientEventEmitter {
108
183
  toolName: string,
109
184
  input: unknown,
110
185
  approvalId: string,
186
+ context?: ChatClientRunEventContext,
111
187
  ): void {
112
188
  this.emitEvent('tools:approval:requested', {
113
189
  streamId,
@@ -116,13 +192,18 @@ export abstract class ChatClientEventEmitter {
116
192
  toolName,
117
193
  input,
118
194
  approvalId,
195
+ ...context,
119
196
  })
120
197
  }
121
198
 
122
199
  /**
123
200
  * Emit message appended event
124
201
  */
125
- messageAppended(uiMessage: UIMessage, streamId?: string): void {
202
+ messageAppended(
203
+ uiMessage: UIMessage,
204
+ streamId?: string,
205
+ context?: ChatClientEventContext,
206
+ ): void {
126
207
  const content = uiMessage.parts
127
208
  .filter((part) => part.type === 'text')
128
209
  .map((part) => part.content)
@@ -134,6 +215,7 @@ export abstract class ChatClientEventEmitter {
134
215
  role: uiMessage.role,
135
216
  content,
136
217
  parts: uiMessage.parts,
218
+ ...context,
137
219
  })
138
220
  }
139
221
 
@@ -201,12 +283,14 @@ export abstract class ChatClientEventEmitter {
201
283
  toolName: string,
202
284
  output: unknown,
203
285
  state: string,
286
+ context?: ChatClientEventContext,
204
287
  ): void {
205
288
  this.emitEvent('tools:result:added', {
206
289
  toolCallId,
207
290
  toolName,
208
291
  output,
209
292
  state,
293
+ ...context,
210
294
  })
211
295
  }
212
296
 
@@ -217,13 +301,22 @@ export abstract class ChatClientEventEmitter {
217
301
  approvalId: string,
218
302
  toolCallId: string,
219
303
  approved: boolean,
304
+ context?: ChatClientRunEventContext,
220
305
  ): void {
221
306
  this.emitEvent('tools:approval:responded', {
222
307
  approvalId,
223
308
  toolCallId,
224
309
  approved,
310
+ ...context,
225
311
  })
226
312
  }
313
+
314
+ /**
315
+ * Emit tool fixture applied event.
316
+ */
317
+ toolFixtureApplied(fixture: ChatClientToolFixtureAppliedEvent): void {
318
+ this.emitEvent('devtools:tool-fixture:applied', { ...fixture })
319
+ }
227
320
  }
228
321
 
229
322
  /**
@@ -234,23 +327,61 @@ export class DefaultChatClientEventEmitter extends ChatClientEventEmitter {
234
327
  * Emit an event with automatic clientId and timestamp for client/tool events
235
328
  */
236
329
  protected emitEvent(eventName: string, data?: Record<string, any>): void {
237
- // For client:* and tool:* events, automatically add clientId and timestamp
238
- if (
330
+ const timestamp = Date.now()
331
+ const isUserVisibleEvent =
332
+ eventName.startsWith('text:') ||
333
+ eventName.startsWith('tools:') ||
334
+ eventName.startsWith('structured-output:') ||
335
+ eventName === 'devtools:tool-fixture:applied'
336
+ const includesClientContext =
239
337
  eventName.startsWith('client:') ||
240
338
  eventName.startsWith('tools:') ||
241
- eventName.startsWith('text:')
242
- ) {
243
- aiEventClient.emit(eventName as any, {
244
- ...data,
339
+ eventName.startsWith('text:') ||
340
+ eventName.startsWith('structured-output:') ||
341
+ eventName === 'devtools:tool-fixture:applied'
342
+ const visibility = isUserVisibleEvent ? 'user-visible' : 'client-state'
343
+ const envelopeContext = {
344
+ hookId: this.clientId,
345
+ ...(typeof data?.threadId === 'string'
346
+ ? { threadId: data.threadId }
347
+ : {}),
348
+ ...(typeof data?.runId === 'string' ? { runId: data.runId } : {}),
349
+ ...(typeof data?.streamId === 'string'
350
+ ? { streamId: data.streamId }
351
+ : {}),
352
+ ...(typeof data?.messageId === 'string'
353
+ ? { messageId: data.messageId }
354
+ : {}),
355
+ ...(typeof data?.toolCallId === 'string'
356
+ ? { toolCallId: data.toolCallId }
357
+ : {}),
358
+ }
359
+
360
+ // For client:* and tool:* events, automatically add clientId and timestamp
361
+ if (includesClientContext) {
362
+ const envelope = createAIDevtoolsEventEnvelope({
363
+ eventType: eventName,
245
364
  clientId: this.clientId,
365
+ ...envelopeContext,
246
366
  source: 'client',
247
- timestamp: Date.now(),
367
+ visibility,
368
+ timestamp,
369
+ })
370
+ aiEventClient.emit(eventName as any, {
371
+ ...data,
372
+ ...envelope,
248
373
  })
249
374
  } else {
375
+ const envelope = createAIDevtoolsEventEnvelope({
376
+ eventType: eventName,
377
+ source: 'client',
378
+ visibility: 'client-state',
379
+ timestamp,
380
+ })
250
381
  // For other events, just add timestamp
251
382
  aiEventClient.emit(eventName as any, {
252
383
  ...data,
253
- timestamp: Date.now(),
384
+ ...envelope,
254
385
  })
255
386
  }
256
387
  }
@@ -1,7 +1,17 @@
1
1
  import { GENERATION_EVENTS } from './generation-types'
2
+ import { createNoOpGenerationDevtoolsBridge } from './devtools-noop'
2
3
  import { parseSSEResponse } from './sse-parser'
3
- import type { StreamChunk } from '@tanstack/ai'
4
- import type { ConnectConnectionAdapter } from './connection-adapters'
4
+ import type { StreamChunk } from '@tanstack/ai/client'
5
+ import type {
6
+ ConnectConnectionAdapter,
7
+ RunAgentInputContext,
8
+ } from './connection-adapters'
9
+ import type {
10
+ AIDevtoolsClientMetadata,
11
+ AIDevtoolsGenerationProgress,
12
+ GenerationDevtoolsBridge,
13
+ GenerationDevtoolsBridgeOptions,
14
+ } from './devtools'
5
15
  import type {
6
16
  GenerationClientOptions,
7
17
  GenerationClientState,
@@ -67,13 +77,20 @@ export class GenerationClient<
67
77
  > {
68
78
  private readonly connection: ConnectConnectionAdapter | undefined
69
79
  private readonly fetcher: GenerationFetcher<TInput, TResult> | undefined
80
+ private readonly uniqueId: string
81
+ private readonly devtoolsMetadata: AIDevtoolsClientMetadata
82
+ private readonly devtoolsBridge: GenerationDevtoolsBridge<TOutput>
83
+ private readonly threadId: string
70
84
  private body: Record<string, any>
71
85
  private result: TOutput | null = null
86
+ private input: TInput | null = null
87
+ private progress: AIDevtoolsGenerationProgress | null = null
72
88
  private isLoading = false
73
89
  private error: Error | undefined = undefined
74
90
  private status: GenerationClientState = 'idle'
75
91
  private abortController: AbortController | null = null
76
92
  private readonly callbacksRef: GenerationCallbacks<TResult, TOutput>
93
+ private devtoolsMounted = false
77
94
 
78
95
  constructor(
79
96
  options: GenerationClientOptions<TInput, TResult, TOutput> &
@@ -85,6 +102,8 @@ export class GenerationClient<
85
102
  }
86
103
  ),
87
104
  ) {
105
+ this.uniqueId = options.id ?? this.generateUniqueId('generation')
106
+ this.threadId = this.uniqueId
88
107
  this.connection = options.connection
89
108
  this.fetcher = options.fetcher
90
109
  this.body = options.body ?? {}
@@ -99,6 +118,38 @@ export class GenerationClient<
99
118
  onErrorChange: options.onErrorChange,
100
119
  onStatusChange: options.onStatusChange,
101
120
  }
121
+
122
+ this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools)
123
+ this.devtoolsBridge = (
124
+ options.devtoolsBridgeFactory ?? createNoOpGenerationDevtoolsBridge
125
+ )<TOutput>(this.buildDevtoolsBridgeOptions())
126
+ }
127
+
128
+ private buildDevtoolsBridgeOptions(): GenerationDevtoolsBridgeOptions<TOutput> {
129
+ return {
130
+ hookId: this.uniqueId,
131
+ clientId: this.uniqueId,
132
+ threadId: this.threadId,
133
+ metadata: this.devtoolsMetadata,
134
+ getCoreState: () => ({
135
+ input: this.input,
136
+ result: this.result,
137
+ progress: this.progress,
138
+ status: this.status,
139
+ isLoading: this.isLoading,
140
+ ...(this.error ? { error: this.error.message } : {}),
141
+ }),
142
+ }
143
+ }
144
+
145
+ mountDevtools(): void {
146
+ if (this.devtoolsMounted) {
147
+ return
148
+ }
149
+
150
+ this.devtoolsMounted = true
151
+ this.devtoolsBridge.emitRegistered()
152
+ this.devtoolsBridge.emitSnapshot()
102
153
  }
103
154
 
104
155
  /**
@@ -107,8 +158,12 @@ export class GenerationClient<
107
158
  * while already generating will be a no-op.
108
159
  */
109
160
  async generate(input: TInput): Promise<void> {
161
+ this.mountDevtools()
110
162
  if (this.isLoading) return
111
163
 
164
+ this.input = input
165
+ this.progress = null
166
+ const runId = this.devtoolsBridge.beginRun(input)
112
167
  this.setIsLoading(true)
113
168
  this.setStatus('generating')
114
169
  this.setError(undefined)
@@ -124,26 +179,50 @@ export class GenerationClient<
124
179
  if (signal.aborted) return
125
180
  if (result instanceof Response) {
126
181
  // Server function returned SSE Response — parse stream
127
- await this.processStream(parseSSEResponse(result, signal))
182
+ await this.processStream(parseSSEResponse(result, signal), runId)
128
183
  } else {
184
+ this.devtoolsBridge.ensureRunStarted(runId)
129
185
  this.setResult(result)
130
186
  this.setStatus('success')
131
187
  }
132
188
  } else if (this.connection) {
133
189
  // Streaming adapter path
134
190
  const mergedData = { ...this.body, ...input }
135
- const stream = this.connection.connect([], mergedData, signal)
136
- await this.processStream(stream)
191
+ const stream = this.connection.connect(
192
+ [],
193
+ mergedData,
194
+ signal,
195
+ this.createRunContext(runId),
196
+ )
197
+ await this.processStream(stream, runId)
137
198
  } else {
138
199
  throw new Error(
139
200
  'GenerationClient requires either a connection or fetcher option',
140
201
  )
141
202
  }
142
- } catch (err: any) {
203
+ if (!signal.aborted && this.status === 'success') {
204
+ // Bump progress to 100 on successful completion so devtools
205
+ // snapshots reflect the final state. The bridge mirrors this in
206
+ // the run's recorded progress, but the snapshot reads `progress`
207
+ // from the client's core state.
208
+ this.progress = completeProgressValue(this.progress)
209
+ this.devtoolsBridge.finishRun(
210
+ this.devtoolsBridge.getActiveRunId() ?? runId,
211
+ 'run:completed',
212
+ 'completed',
213
+ )
214
+ }
215
+ } catch (err: unknown) {
143
216
  if (signal.aborted) return
144
217
  const error = err instanceof Error ? err : new Error(String(err))
145
218
  this.setError(error)
146
219
  this.setStatus('error')
220
+ this.devtoolsBridge.finishRun(
221
+ this.devtoolsBridge.getActiveRunId() ?? runId,
222
+ 'run:errored',
223
+ 'errored',
224
+ error.message,
225
+ )
147
226
  this.callbacksRef.onError?.(error)
148
227
  } finally {
149
228
  this.abortController = null
@@ -156,15 +235,28 @@ export class GenerationClient<
156
235
  */
157
236
  private async processStream(
158
237
  source: AsyncIterable<StreamChunk>,
238
+ fallbackRunId: string,
159
239
  ): Promise<void> {
240
+ let streamRunId: string | undefined
241
+
160
242
  for await (const chunk of source) {
161
243
  if (this.abortController?.signal.aborted) break
162
244
 
163
245
  this.callbacksRef.onChunk?.(chunk)
246
+ const chunkRunId =
247
+ 'runId' in chunk && typeof chunk.runId === 'string'
248
+ ? chunk.runId
249
+ : undefined
164
250
 
165
251
  // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- AG-UI EventType has ~22 variants; this consumer only handles the subset relevant to generation lifecycle.
166
252
  switch (chunk.type) {
253
+ case 'RUN_STARTED': {
254
+ streamRunId = chunk.runId
255
+ this.devtoolsBridge.ensureRunStarted(chunk.runId)
256
+ break
257
+ }
167
258
  case 'CUSTOM': {
259
+ this.devtoolsBridge.ensureRunStarted(streamRunId ?? fallbackRunId)
168
260
  if (chunk.name === GENERATION_EVENTS.RESULT) {
169
261
  this.setResult(chunk.value as TResult)
170
262
  } else if (chunk.name === GENERATION_EVENTS.PROGRESS) {
@@ -172,15 +264,20 @@ export class GenerationClient<
172
264
  progress: number
173
265
  message?: string
174
266
  }
175
- this.callbacksRef.onProgress?.(progress, message)
267
+ this.setProgress(progress, message)
176
268
  }
177
269
  break
178
270
  }
179
271
  case 'RUN_FINISHED': {
272
+ streamRunId = chunk.runId
273
+ this.devtoolsBridge.ensureRunStarted(chunk.runId)
180
274
  this.setStatus('success')
181
275
  break
182
276
  }
183
277
  case 'RUN_ERROR': {
278
+ this.devtoolsBridge.ensureRunStarted(
279
+ chunkRunId ?? streamRunId ?? fallbackRunId,
280
+ )
184
281
  // Prefer spec `message`; fall back to deprecated `error.message`
185
282
  const msg =
186
283
  (chunk.message as string | undefined) ||
@@ -198,6 +295,7 @@ export class GenerationClient<
198
295
  * Abort any in-flight generation request.
199
296
  */
200
297
  stop(): void {
298
+ const runId = this.devtoolsBridge.getActiveRunId()
201
299
  if (this.abortController) {
202
300
  this.abortController.abort()
203
301
  this.abortController = null
@@ -205,6 +303,9 @@ export class GenerationClient<
205
303
  this.setIsLoading(false)
206
304
  if (this.status === 'generating') {
207
305
  this.setStatus('idle')
306
+ if (runId) {
307
+ this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled')
308
+ }
208
309
  }
209
310
  }
210
311
 
@@ -214,8 +315,12 @@ export class GenerationClient<
214
315
  reset(): void {
215
316
  this.stop()
216
317
  this.setResult(null)
318
+ this.input = null
319
+ this.progress = null
320
+ this.devtoolsBridge.resetRuns()
217
321
  this.setError(undefined)
218
322
  this.setStatus('idle')
323
+ this.devtoolsBridge.emitState()
219
324
  }
220
325
 
221
326
  /**
@@ -246,6 +351,12 @@ export class GenerationClient<
246
351
  }
247
352
  }
248
353
 
354
+ dispose(): void {
355
+ this.stop()
356
+ this.devtoolsBridge.dispose()
357
+ this.devtoolsMounted = false
358
+ }
359
+
249
360
  // ===========================
250
361
  // Getters
251
362
  // ===========================
@@ -274,40 +385,96 @@ export class GenerationClient<
274
385
  if (rawResult === null) {
275
386
  this.result = null
276
387
  this.callbacksRef.onResultChange?.(null)
388
+ this.devtoolsBridge.recordResultChange()
277
389
  return
278
390
  }
279
391
 
280
392
  if (this.callbacksRef.onResult) {
281
393
  const transformed = this.callbacksRef.onResult(rawResult)
282
394
  if (transformed === null) {
283
- // null return → keep previous result unchanged
395
+ // null return → keep previous result unchanged, just re-emit
396
+ this.devtoolsBridge.emitState()
284
397
  return
285
398
  }
286
399
  if (transformed !== undefined) {
287
400
  // Non-null, non-undefined → use transformed value
288
401
  this.result = transformed
289
402
  this.callbacksRef.onResultChange?.(this.result)
403
+ this.devtoolsBridge.recordResultChange()
290
404
  return
291
405
  }
292
406
  }
293
407
 
294
- // No onResult callback, or callback returned void → use raw value
295
- this.result = rawResult as TOutput
408
+ // No onResult callback, or callback returned void → use raw value as
409
+ // TOutput. When the caller did not supply an onResult transform,
410
+ // `TOutput` defaults to `TResult`, so the runtime cast is sound.
411
+ // eslint-disable-next-line no-restricted-syntax -- TOutput defaults to TResult when no onResult transform is supplied
412
+ this.result = rawResult as unknown as TOutput
296
413
  this.callbacksRef.onResultChange?.(this.result)
414
+ this.devtoolsBridge.recordResultChange()
297
415
  }
298
416
 
299
417
  private setIsLoading(isLoading: boolean): void {
300
418
  this.isLoading = isLoading
301
419
  this.callbacksRef.onLoadingChange?.(isLoading)
420
+ this.devtoolsBridge.recordLoadingChange()
302
421
  }
303
422
 
304
423
  private setError(error: Error | undefined): void {
305
424
  this.error = error
306
425
  this.callbacksRef.onErrorChange?.(error)
426
+ this.devtoolsBridge.recordErrorChange(error)
307
427
  }
308
428
 
309
429
  private setStatus(status: GenerationClientState): void {
310
430
  this.status = status
311
431
  this.callbacksRef.onStatusChange?.(status)
432
+ this.devtoolsBridge.recordStatusChange(status)
433
+ }
434
+
435
+ private setProgress(value: number, message?: string): void {
436
+ this.progress = {
437
+ value,
438
+ ...(message ? { message } : {}),
439
+ }
440
+ if (message === undefined) {
441
+ this.callbacksRef.onProgress?.(value)
442
+ } else {
443
+ this.callbacksRef.onProgress?.(value, message)
444
+ }
445
+ this.devtoolsBridge.recordProgressChange()
446
+ }
447
+
448
+ private createDevtoolsMetadata(
449
+ metadata?: Partial<AIDevtoolsClientMetadata>,
450
+ ): AIDevtoolsClientMetadata {
451
+ return {
452
+ hookName: metadata?.hookName ?? 'useGeneration',
453
+ ...(metadata?.framework ? { framework: metadata.framework } : {}),
454
+ ...(metadata?.outputKind ? { outputKind: metadata.outputKind } : {}),
455
+ ...(metadata?.name ? { name: metadata.name } : {}),
456
+ }
457
+ }
458
+
459
+ private generateUniqueId(prefix: string): string {
460
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
461
+ }
462
+
463
+ private createRunContext(runId: string): RunAgentInputContext {
464
+ return {
465
+ threadId: this.threadId,
466
+ runId,
467
+ }
468
+ }
469
+ }
470
+
471
+ function completeProgressValue(
472
+ progress: AIDevtoolsGenerationProgress | null,
473
+ ): AIDevtoolsGenerationProgress | null {
474
+ if (!progress) return null
475
+ const message = progress.message
476
+ return {
477
+ value: 100,
478
+ ...(message ? { message } : {}),
312
479
  }
313
480
  }
@@ -1,5 +1,10 @@
1
- import type { StreamChunk } from '@tanstack/ai'
1
+ import type { StreamChunk } from '@tanstack/ai/client'
2
2
  import type { ConnectConnectionAdapter } from './connection-adapters'
3
+ import type { AIDevtoolsClientMetadata } from './devtools'
4
+ import type {
5
+ GenerationDevtoolsBridgeFactory,
6
+ VideoDevtoolsBridgeFactory,
7
+ } from './devtools-noop'
3
8
 
4
9
  // ===========================
5
10
  // Inference Utilities
@@ -106,6 +111,15 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
106
111
  /** Additional body parameters to send with connect-based adapter requests */
107
112
  body?: Record<string, any>
108
113
 
114
+ /** Metadata used to register this generation hook with TanStack AI Devtools */
115
+ devtools?: Partial<AIDevtoolsClientMetadata>
116
+
117
+ /**
118
+ * Factory that constructs the devtools bridge. Default is a no-op
119
+ * factory; the real implementation lives in `@tanstack/ai-client/devtools`.
120
+ */
121
+ devtoolsBridgeFactory?: GenerationDevtoolsBridgeFactory
122
+
109
123
  /**
110
124
  * Callback when a result is received. Can optionally return a transformed value
111
125
  * that replaces the stored result.
@@ -172,11 +186,16 @@ export interface VideoGenerateResult {
172
186
  */
173
187
  export interface VideoGenerationClientOptions<
174
188
  TOutput = VideoGenerateResult,
175
- > extends GenerationClientOptions<
176
- VideoGenerateInput,
177
- VideoGenerateResult,
178
- TOutput
189
+ > extends Omit<
190
+ GenerationClientOptions<VideoGenerateInput, VideoGenerateResult, TOutput>,
191
+ 'devtoolsBridgeFactory'
179
192
  > {
193
+ /**
194
+ * Factory that constructs the video devtools bridge. Default is a no-op
195
+ * factory; the real implementation lives in `@tanstack/ai-client/devtools`.
196
+ */
197
+ devtoolsBridgeFactory?: VideoDevtoolsBridgeFactory
198
+
180
199
  /** Callback when a video job is created */
181
200
  onJobCreated?: (jobId: string) => void
182
201
  /** Callback on each status update */