@tanstack/ai-client 0.7.1 → 0.7.4

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.
@@ -62,15 +62,9 @@ async function* readStreamLines(
62
62
  }
63
63
  }
64
64
 
65
- /**
66
- * Connection adapter interface - converts a connection into a stream of chunks
67
- */
68
- export interface ConnectionAdapter {
65
+ export interface ConnectConnectionAdapter {
69
66
  /**
70
- * Connect and return an async iterable of StreamChunks
71
- * @param messages - The messages to send (UIMessages or ModelMessages)
72
- * @param data - Additional data to send
73
- * @param abortSignal - Optional abort signal for request cancellation
67
+ * Connect and return an async iterable of StreamChunks.
74
68
  */
75
69
  connect: (
76
70
  messages: Array<UIMessage> | Array<ModelMessage>,
@@ -79,6 +73,147 @@ export interface ConnectionAdapter {
79
73
  ) => AsyncIterable<StreamChunk>
80
74
  }
81
75
 
76
+ export interface SubscribeConnectionAdapter {
77
+ /**
78
+ * Subscribe to stream chunks.
79
+ */
80
+ subscribe: (abortSignal?: AbortSignal) => AsyncIterable<StreamChunk>
81
+ /**
82
+ * Send a request; chunks arrive through subscribe().
83
+ */
84
+ send: (
85
+ messages: Array<UIMessage> | Array<ModelMessage>,
86
+ data?: Record<string, any>,
87
+ abortSignal?: AbortSignal,
88
+ ) => Promise<void>
89
+ }
90
+
91
+ /**
92
+ * Connection adapter union.
93
+ * Provide either `connect`, or `subscribe` + `send`.
94
+ */
95
+ export type ConnectionAdapter =
96
+ | ConnectConnectionAdapter
97
+ | SubscribeConnectionAdapter
98
+
99
+ /**
100
+ * Normalize a ConnectionAdapter to subscribe/send operations.
101
+ *
102
+ * If a connection provides native subscribe/send, that mode is used.
103
+ * Otherwise, connect() is wrapped using an async queue.
104
+ */
105
+ export function normalizeConnectionAdapter(
106
+ connection: ConnectionAdapter | undefined,
107
+ ): SubscribeConnectionAdapter {
108
+ if (!connection) {
109
+ throw new Error('Connection adapter is required')
110
+ }
111
+
112
+ const hasConnect = 'connect' in connection
113
+ const hasSubscribe = 'subscribe' in connection
114
+ const hasSend = 'send' in connection
115
+
116
+ if (hasConnect && (hasSubscribe || hasSend)) {
117
+ throw new Error(
118
+ 'Connection adapter must provide either connect or both subscribe and send, not both modes',
119
+ )
120
+ }
121
+
122
+ if (hasSubscribe && hasSend) {
123
+ return {
124
+ subscribe: connection.subscribe.bind(connection),
125
+ send: connection.send.bind(connection),
126
+ }
127
+ }
128
+
129
+ if (!hasConnect) {
130
+ throw new Error(
131
+ 'Connection adapter must provide either connect or both subscribe and send',
132
+ )
133
+ }
134
+
135
+ // Legacy connect() wrapper
136
+ let activeBuffer: Array<StreamChunk> = []
137
+ let activeWaiters: Array<(chunk: StreamChunk | null) => void> = []
138
+
139
+ function push(chunk: StreamChunk): void {
140
+ const waiter = activeWaiters.shift()
141
+ if (waiter) {
142
+ waiter(chunk)
143
+ } else {
144
+ activeBuffer.push(chunk)
145
+ }
146
+ }
147
+
148
+ return {
149
+ subscribe(abortSignal?: AbortSignal): AsyncIterable<StreamChunk> {
150
+ // Transfer ownership to the latest subscriber so only one active
151
+ // subscribe() call receives chunks from the shared connect-wrapper queue.
152
+ const myBuffer: Array<StreamChunk> = activeBuffer.splice(0)
153
+ const myWaiters: Array<(chunk: StreamChunk | null) => void> = []
154
+ activeBuffer = myBuffer
155
+ activeWaiters = myWaiters
156
+
157
+ return (async function* () {
158
+ while (!abortSignal?.aborted) {
159
+ let chunk: StreamChunk | null
160
+ if (myBuffer.length > 0) {
161
+ chunk = myBuffer.shift()!
162
+ } else {
163
+ chunk = await new Promise<StreamChunk | null>((resolve) => {
164
+ const onAbort = () => resolve(null)
165
+ myWaiters.push((c) => {
166
+ abortSignal?.removeEventListener('abort', onAbort)
167
+ resolve(c)
168
+ })
169
+ abortSignal?.addEventListener('abort', onAbort, { once: true })
170
+ })
171
+ }
172
+ if (chunk !== null) yield chunk
173
+ }
174
+ })()
175
+ },
176
+ async send(messages, data, abortSignal) {
177
+ let hasTerminalEvent = false
178
+ try {
179
+ const stream = connection.connect(messages, data, abortSignal)
180
+ for await (const chunk of stream) {
181
+ if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
182
+ hasTerminalEvent = true
183
+ }
184
+ push(chunk)
185
+ }
186
+
187
+ // If the connect stream ended cleanly without a terminal event,
188
+ // synthesize RUN_FINISHED so request-scoped consumers can complete.
189
+ if (!abortSignal?.aborted && !hasTerminalEvent) {
190
+ push({
191
+ type: 'RUN_FINISHED',
192
+ runId: `run-${Date.now()}`,
193
+ model: 'connect-wrapper',
194
+ timestamp: Date.now(),
195
+ finishReason: 'stop',
196
+ })
197
+ }
198
+ } catch (err) {
199
+ if (!abortSignal?.aborted && !hasTerminalEvent) {
200
+ push({
201
+ type: 'RUN_ERROR',
202
+ timestamp: Date.now(),
203
+ error: {
204
+ message:
205
+ err instanceof Error
206
+ ? err.message
207
+ : 'Unknown error in connect()',
208
+ },
209
+ })
210
+ }
211
+ throw err
212
+ }
213
+ },
214
+ }
215
+ }
216
+
82
217
  /**
83
218
  * Options for fetch-based connection adapters
84
219
  */
@@ -129,7 +264,7 @@ export function fetchServerSentEvents(
129
264
  options:
130
265
  | FetchConnectionOptions
131
266
  | (() => FetchConnectionOptions | Promise<FetchConnectionOptions>) = {},
132
- ): ConnectionAdapter {
267
+ ): ConnectConnectionAdapter {
133
268
  return {
134
269
  async *connect(messages, data, abortSignal) {
135
270
  // Resolve URL and options if they are functions
@@ -228,7 +363,7 @@ export function fetchHttpStream(
228
363
  options:
229
364
  | FetchConnectionOptions
230
365
  | (() => FetchConnectionOptions | Promise<FetchConnectionOptions>) = {},
231
- ): ConnectionAdapter {
366
+ ): ConnectConnectionAdapter {
232
367
  return {
233
368
  async *connect(messages, data, abortSignal) {
234
369
  // Resolve URL and options if they are functions
@@ -301,7 +436,7 @@ export function stream(
301
436
  messages: Array<UIMessage> | Array<ModelMessage>,
302
437
  data?: Record<string, any>,
303
438
  ) => AsyncIterable<StreamChunk>,
304
- ): ConnectionAdapter {
439
+ ): ConnectConnectionAdapter {
305
440
  return {
306
441
  async *connect(messages, data) {
307
442
  // Pass messages as-is (UIMessages with parts preserved)
@@ -332,7 +467,7 @@ export function rpcStream(
332
467
  messages: Array<UIMessage> | Array<ModelMessage>,
333
468
  data?: Record<string, any>,
334
469
  ) => AsyncIterable<StreamChunk>,
335
- ): ConnectionAdapter {
470
+ ): ConnectConnectionAdapter {
336
471
  return {
337
472
  async *connect(messages, data) {
338
473
  // Pass messages as-is (UIMessages with parts preserved)
@@ -1,7 +1,7 @@
1
1
  import { GENERATION_EVENTS } from './generation-types'
2
2
  import { parseSSEResponse } from './sse-parser'
3
3
  import type { StreamChunk } from '@tanstack/ai'
4
- import type { ConnectionAdapter } from './connection-adapters'
4
+ import type { ConnectConnectionAdapter } from './connection-adapters'
5
5
  import type {
6
6
  GenerationClientOptions,
7
7
  GenerationClientState,
@@ -27,7 +27,7 @@ interface GenerationCallbacks<TResult, TOutput> {
27
27
  * (image, speech, transcription, summarize).
28
28
  *
29
29
  * Supports two transport modes:
30
- * - **ConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom).
30
+ * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom).
31
31
  * Server wraps results in StreamChunk events with CUSTOM event names.
32
32
  * - **Fetcher** — Direct async function call. No streaming protocol needed.
33
33
  *
@@ -36,7 +36,7 @@ interface GenerationCallbacks<TResult, TOutput> {
36
36
  *
37
37
  * @example
38
38
  * ```typescript
39
- * // With ConnectionAdapter (streaming)
39
+ * // With streaming connection adapter
40
40
  * const client = new GenerationClient<ImageGenerateInput, ImageGenerationResult>({
41
41
  * connection: fetchServerSentEvents('/api/generate/image'),
42
42
  * onResultChange: setResult,
@@ -62,7 +62,7 @@ export class GenerationClient<
62
62
  TResult,
63
63
  TOutput = TResult,
64
64
  > {
65
- private connection: ConnectionAdapter | undefined
65
+ private connection: ConnectConnectionAdapter | undefined
66
66
  private fetcher: GenerationFetcher<TInput, TResult> | undefined
67
67
  private body: Record<string, any>
68
68
  private result: TOutput | null = null
@@ -75,7 +75,7 @@ export class GenerationClient<
75
75
  constructor(
76
76
  options: GenerationClientOptions<TInput, TResult, TOutput> &
77
77
  (
78
- | { connection: ConnectionAdapter; fetcher?: never }
78
+ | { connection: ConnectConnectionAdapter; fetcher?: never }
79
79
  | {
80
80
  fetcher: GenerationFetcher<TInput, TResult>
81
81
  connection?: never
@@ -127,7 +127,7 @@ export class GenerationClient<
127
127
  this.setStatus('success')
128
128
  }
129
129
  } else if (this.connection) {
130
- // ConnectionAdapter streaming path
130
+ // Streaming adapter path
131
131
  const mergedData = { ...this.body, ...input }
132
132
  const stream = this.connection.connect([], mergedData, signal)
133
133
  await this.processStream(stream)
@@ -149,7 +149,7 @@ export class GenerationClient<
149
149
  }
150
150
 
151
151
  /**
152
- * Process a stream of AG-UI events from the ConnectionAdapter.
152
+ * Process a stream of AG-UI events from the streaming connection adapter.
153
153
  */
154
154
  private async processStream(
155
155
  source: AsyncIterable<StreamChunk>,
@@ -1,5 +1,5 @@
1
1
  import type { StreamChunk } from '@tanstack/ai'
2
- import type { ConnectionAdapter } from './connection-adapters'
2
+ import type { ConnectConnectionAdapter } from './connection-adapters'
3
3
 
4
4
  // ===========================
5
5
  // Inference Utilities
@@ -81,10 +81,10 @@ export type GenerationFetcher<TInput, TResult> = (
81
81
 
82
82
  /**
83
83
  * Transport configuration for generation clients.
84
- * Supports either a ConnectionAdapter (streaming) or a direct fetcher function.
84
+ * Supports either a connect-based streaming adapter or a direct fetcher function.
85
85
  */
86
86
  export type GenerationTransport<TInput, TResult> =
87
- | { connection: ConnectionAdapter; fetcher?: never }
87
+ | { connection: ConnectConnectionAdapter; fetcher?: never }
88
88
  | { fetcher: GenerationFetcher<TInput, TResult>; connection?: never }
89
89
 
90
90
  // ===========================
@@ -103,7 +103,7 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
103
103
  /** Unique identifier for this generation client instance */
104
104
  id?: string
105
105
 
106
- /** Additional body parameters to send with ConnectionAdapter requests */
106
+ /** Additional body parameters to send with connect-based adapter requests */
107
107
  body?: Record<string, any>
108
108
 
109
109
  /**
@@ -119,7 +119,7 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
119
119
  onError?: (error: Error) => void
120
120
  /** Callback when progress is reported (0-100) */
121
121
  onProgress?: (progress: number, message?: string) => void
122
- /** Callback for each stream chunk (ConnectionAdapter mode only) */
122
+ /** Callback for each stream chunk (connect-based adapter mode only) */
123
123
  onChunk?: (chunk: StreamChunk) => void
124
124
 
125
125
  // Framework state callbacks (set by hooks, not users)
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export type {
15
15
  ChatRequestBody,
16
16
  InferChatMessages,
17
17
  ChatClientState,
18
+ ConnectionStatus,
18
19
  // Multimodal content input type
19
20
  MultimodalContent,
20
21
  } from './types'
@@ -55,8 +56,10 @@ export {
55
56
  fetchHttpStream,
56
57
  stream,
57
58
  rpcStream,
59
+ type ConnectConnectionAdapter,
58
60
  type ConnectionAdapter,
59
61
  type FetchConnectionOptions,
62
+ type SubscribeConnectionAdapter,
60
63
  } from './connection-adapters'
61
64
 
62
65
  // Re-export message converters from @tanstack/ai
package/src/types.ts CHANGED
@@ -36,6 +36,15 @@ export type ToolResultState =
36
36
  */
37
37
  export type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error'
38
38
 
39
+ /**
40
+ * Connection lifecycle state for the subscription loop.
41
+ */
42
+ export type ConnectionStatus =
43
+ | 'disconnected'
44
+ | 'connecting'
45
+ | 'connected'
46
+ | 'error'
47
+
39
48
  /**
40
49
  * Multimodal content input for sending messages with rich media.
41
50
  * Allows sending text, images, audio, video, and documents to the LLM.
@@ -178,8 +187,9 @@ export interface ChatClientOptions<
178
187
  TTools extends ReadonlyArray<AnyClientTool> = any,
179
188
  > {
180
189
  /**
181
- * Connection adapter for streaming
182
- * Use fetchServerSentEvents(), fetchHttpStream(), or stream() to create adapters
190
+ * Connection adapter for streaming.
191
+ * Supports mutually exclusive modes: request-response via `connect()`, or
192
+ * subscribe/send mode via `subscribe()` + `send()`.
183
193
  */
184
194
  connection: ConnectionAdapter
185
195
 
@@ -239,6 +249,25 @@ export interface ChatClientOptions<
239
249
  */
240
250
  onStatusChange?: (status: ChatClientState) => void
241
251
 
252
+ /**
253
+ * Callback when subscription lifecycle changes.
254
+ * This is independent from request lifecycle (`isLoading`, `status`).
255
+ */
256
+ onSubscriptionChange?: (isSubscribed: boolean) => void
257
+
258
+ /**
259
+ * Callback when connection lifecycle changes.
260
+ */
261
+ onConnectionStatusChange?: (status: ConnectionStatus) => void
262
+
263
+ /**
264
+ * Callback when session generation activity changes.
265
+ * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).
266
+ * Unlike `onLoadingChange` (request-local), this reflects shared generation
267
+ * activity visible to all subscribers (e.g. across tabs/devices).
268
+ */
269
+ onSessionGeneratingChange?: (isGenerating: boolean) => void
270
+
242
271
  /**
243
272
  * Callback when a custom event is received from a server-side tool.
244
273
  * Custom events are emitted by tools using `context.emitCustomEvent()` during execution.
@@ -1,7 +1,7 @@
1
1
  import { GENERATION_EVENTS } from './generation-types'
2
2
  import { parseSSEResponse } from './sse-parser'
3
3
  import type { StreamChunk } from '@tanstack/ai'
4
- import type { ConnectionAdapter } from './connection-adapters'
4
+ import type { ConnectConnectionAdapter } from './connection-adapters'
5
5
  import type {
6
6
  GenerationClientState,
7
7
  GenerationFetcher,
@@ -36,14 +36,14 @@ interface VideoCallbacks<TOutput> {
36
36
  * until completion. This client handles the full lifecycle.
37
37
  *
38
38
  * Supports two transport modes:
39
- * - **ConnectionAdapter** — Server handles the polling loop internally and
39
+ * - **ConnectConnectionAdapter** — Server handles the polling loop internally and
40
40
  * streams status updates via CUSTOM events.
41
41
  * - **Fetcher** — Direct async function that returns a completed
42
42
  * `VideoGenerateResult`.
43
43
  *
44
44
  * @example
45
45
  * ```typescript
46
- * // With ConnectionAdapter (server-driven polling)
46
+ * // With streaming connection adapter (server-driven polling)
47
47
  * const client = new VideoGenerationClient({
48
48
  * connection: fetchServerSentEvents('/api/generate/video'),
49
49
  * onResultChange: setResult,
@@ -65,7 +65,7 @@ interface VideoCallbacks<TOutput> {
65
65
  * ```
66
66
  */
67
67
  export class VideoGenerationClient<TOutput = VideoGenerateResult> {
68
- private connection: ConnectionAdapter | undefined
68
+ private connection: ConnectConnectionAdapter | undefined
69
69
  private fetcher:
70
70
  | GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
71
71
  | undefined
@@ -83,7 +83,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
83
83
  constructor(
84
84
  options: VideoGenerationClientOptions<TOutput> &
85
85
  (
86
- | { connection: ConnectionAdapter; fetcher?: never }
86
+ | { connection: ConnectConnectionAdapter; fetcher?: never }
87
87
  | {
88
88
  fetcher: GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
89
89
  connection?: never
@@ -174,7 +174,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
174
174
  }
175
175
 
176
176
  /**
177
- * Process a stream of AG-UI events from the ConnectionAdapter.
177
+ * Process a stream of AG-UI events from the streaming connection adapter.
178
178
  * The server handles the polling loop and streams status updates.
179
179
  */
180
180
  private async processStream(