@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.
- package/README.md +6 -6
- package/dist/esm/chat-client.d.ts +62 -3
- package/dist/esm/chat-client.js +264 -54
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +28 -12
- package/dist/esm/connection-adapters.js +93 -0
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/generation-client.d.ts +5 -5
- package/dist/esm/generation-client.js +1 -1
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/generation-types.d.ts +5 -5
- package/dist/esm/generation-types.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/types.d.ts +23 -2
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.d.ts +5 -5
- package/dist/esm/video-generation-client.js +1 -1
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +3 -3
- package/src/chat-client.ts +355 -86
- package/src/connection-adapters.ts +147 -12
- package/src/generation-client.ts +7 -7
- package/src/generation-types.ts +5 -5
- package/src/index.ts +3 -0
- package/src/types.ts +31 -2
- package/src/video-generation-client.ts +6 -6
package/src/chat-client.ts
CHANGED
|
@@ -4,17 +4,22 @@ import {
|
|
|
4
4
|
normalizeToUIMessage,
|
|
5
5
|
} from '@tanstack/ai'
|
|
6
6
|
import { DefaultChatClientEventEmitter } from './events'
|
|
7
|
+
import { normalizeConnectionAdapter } from './connection-adapters'
|
|
7
8
|
import type {
|
|
8
9
|
AnyClientTool,
|
|
9
10
|
ContentPart,
|
|
10
11
|
ModelMessage,
|
|
11
12
|
StreamChunk,
|
|
12
13
|
} from '@tanstack/ai'
|
|
13
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
ConnectionAdapter,
|
|
16
|
+
SubscribeConnectionAdapter,
|
|
17
|
+
} from './connection-adapters'
|
|
14
18
|
import type { ChatClientEventEmitter } from './events'
|
|
15
19
|
import type {
|
|
16
20
|
ChatClientOptions,
|
|
17
21
|
ChatClientState,
|
|
22
|
+
ConnectionStatus,
|
|
18
23
|
MessagePart,
|
|
19
24
|
MultimodalContent,
|
|
20
25
|
ToolCallPart,
|
|
@@ -23,13 +28,15 @@ import type {
|
|
|
23
28
|
|
|
24
29
|
export class ChatClient {
|
|
25
30
|
private processor: StreamProcessor
|
|
26
|
-
private connection:
|
|
31
|
+
private connection: SubscribeConnectionAdapter
|
|
27
32
|
private uniqueId: string
|
|
28
33
|
private body: Record<string, any> = {}
|
|
29
34
|
private pendingMessageBody: Record<string, any> | undefined = undefined
|
|
30
35
|
private isLoading = false
|
|
36
|
+
private isSubscribed = false
|
|
31
37
|
private error: Error | undefined = undefined
|
|
32
38
|
private status: ChatClientState = 'ready'
|
|
39
|
+
private connectionStatus: ConnectionStatus = 'disconnected'
|
|
33
40
|
private abortController: AbortController | null = null
|
|
34
41
|
private events: ChatClientEventEmitter
|
|
35
42
|
private clientToolsRef: { current: Map<string, AnyClientTool> }
|
|
@@ -40,9 +47,15 @@ export class ChatClient {
|
|
|
40
47
|
private pendingToolExecutions: Map<string, Promise<void>> = new Map()
|
|
41
48
|
// Flag to deduplicate continuation checks during action draining
|
|
42
49
|
private continuationPending = false
|
|
50
|
+
private subscriptionAbortController: AbortController | null = null
|
|
51
|
+
private processingResolve: (() => void) | null = null
|
|
52
|
+
private errorReportedGeneration: number | null = null
|
|
53
|
+
private streamGeneration = 0
|
|
43
54
|
// Tracks whether a queued checkForContinuation was skipped because
|
|
44
55
|
// continuationPending was true (chained approval scenario)
|
|
45
56
|
private continuationSkipped = false
|
|
57
|
+
private sessionGenerating = false
|
|
58
|
+
private activeRunIds = new Set<string>()
|
|
46
59
|
|
|
47
60
|
private callbacksRef: {
|
|
48
61
|
current: {
|
|
@@ -54,6 +67,9 @@ export class ChatClient {
|
|
|
54
67
|
onLoadingChange: (isLoading: boolean) => void
|
|
55
68
|
onErrorChange: (error: Error | undefined) => void
|
|
56
69
|
onStatusChange: (status: ChatClientState) => void
|
|
70
|
+
onSubscriptionChange: (isSubscribed: boolean) => void
|
|
71
|
+
onConnectionStatusChange: (status: ConnectionStatus) => void
|
|
72
|
+
onSessionGeneratingChange: (isGenerating: boolean) => void
|
|
57
73
|
onCustomEvent: (
|
|
58
74
|
eventType: string,
|
|
59
75
|
data: unknown,
|
|
@@ -65,7 +81,7 @@ export class ChatClient {
|
|
|
65
81
|
constructor(options: ChatClientOptions) {
|
|
66
82
|
this.uniqueId = options.id || this.generateUniqueId('chat')
|
|
67
83
|
this.body = options.body || {}
|
|
68
|
-
this.connection = options.connection
|
|
84
|
+
this.connection = normalizeConnectionAdapter(options.connection)
|
|
69
85
|
this.events = new DefaultChatClientEventEmitter(this.uniqueId)
|
|
70
86
|
|
|
71
87
|
// Build client tools map
|
|
@@ -86,6 +102,11 @@ export class ChatClient {
|
|
|
86
102
|
onLoadingChange: options.onLoadingChange || (() => {}),
|
|
87
103
|
onErrorChange: options.onErrorChange || (() => {}),
|
|
88
104
|
onStatusChange: options.onStatusChange || (() => {}),
|
|
105
|
+
onSubscriptionChange: options.onSubscriptionChange || (() => {}),
|
|
106
|
+
onConnectionStatusChange:
|
|
107
|
+
options.onConnectionStatusChange || (() => {}),
|
|
108
|
+
onSessionGeneratingChange:
|
|
109
|
+
options.onSessionGeneratingChange || (() => {}),
|
|
89
110
|
onCustomEvent: options.onCustomEvent || (() => {}),
|
|
90
111
|
},
|
|
91
112
|
}
|
|
@@ -100,15 +121,31 @@ export class ChatClient {
|
|
|
100
121
|
},
|
|
101
122
|
onStreamStart: () => {
|
|
102
123
|
this.setStatus('streaming')
|
|
124
|
+
const assistantMessageId =
|
|
125
|
+
this.processor.getCurrentAssistantMessageId()
|
|
126
|
+
if (!assistantMessageId) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
const messages = this.processor.getMessages()
|
|
130
|
+
const assistantMessage = messages.find(
|
|
131
|
+
(m: UIMessage) => m.id === assistantMessageId,
|
|
132
|
+
)
|
|
133
|
+
if (assistantMessage) {
|
|
134
|
+
this.currentMessageId = assistantMessage.id
|
|
135
|
+
this.events.messageAppended(
|
|
136
|
+
assistantMessage,
|
|
137
|
+
this.currentStreamId || undefined,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
103
140
|
},
|
|
104
141
|
onStreamEnd: (message: UIMessage) => {
|
|
105
142
|
this.callbacksRef.current.onFinish(message)
|
|
106
143
|
this.setStatus('ready')
|
|
144
|
+
// Resolve the processing-complete promise so streamResponse can continue
|
|
145
|
+
this.resolveProcessing()
|
|
107
146
|
},
|
|
108
147
|
onError: (error: Error) => {
|
|
109
|
-
this.
|
|
110
|
-
this.setStatus('error')
|
|
111
|
-
this.callbacksRef.current.onError(error)
|
|
148
|
+
this.reportStreamError(error)
|
|
112
149
|
},
|
|
113
150
|
onTextUpdate: (messageId: string, content: string) => {
|
|
114
151
|
// Emit text update to devtools
|
|
@@ -235,75 +272,170 @@ export class ChatClient {
|
|
|
235
272
|
this.callbacksRef.current.onStatusChange(status)
|
|
236
273
|
}
|
|
237
274
|
|
|
275
|
+
private setIsSubscribed(isSubscribed: boolean): void {
|
|
276
|
+
this.isSubscribed = isSubscribed
|
|
277
|
+
this.callbacksRef.current.onSubscriptionChange(isSubscribed)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private setConnectionStatus(status: ConnectionStatus): void {
|
|
281
|
+
this.connectionStatus = status
|
|
282
|
+
this.callbacksRef.current.onConnectionStatusChange(status)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private setSessionGenerating(isGenerating: boolean): void {
|
|
286
|
+
if (this.sessionGenerating === isGenerating) return
|
|
287
|
+
this.sessionGenerating = isGenerating
|
|
288
|
+
this.callbacksRef.current.onSessionGeneratingChange(isGenerating)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private resetSessionGenerating(): void {
|
|
292
|
+
this.activeRunIds.clear()
|
|
293
|
+
this.setSessionGenerating(false)
|
|
294
|
+
}
|
|
295
|
+
|
|
238
296
|
private setError(error: Error | undefined): void {
|
|
239
297
|
this.error = error
|
|
240
298
|
this.callbacksRef.current.onErrorChange(error)
|
|
241
299
|
this.events.errorChanged(error?.message || null)
|
|
242
300
|
}
|
|
243
301
|
|
|
302
|
+
private abortSubscriptionLoop(): void {
|
|
303
|
+
this.subscriptionAbortController?.abort()
|
|
304
|
+
this.subscriptionAbortController = null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private resolveProcessing(): void {
|
|
308
|
+
this.processingResolve?.()
|
|
309
|
+
this.processingResolve = null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private cancelInFlightStream(options?: {
|
|
313
|
+
setReadyStatus?: boolean
|
|
314
|
+
abortSubscription?: boolean
|
|
315
|
+
}): void {
|
|
316
|
+
this.abortController?.abort()
|
|
317
|
+
this.abortController = null
|
|
318
|
+
if (options?.abortSubscription) {
|
|
319
|
+
this.abortSubscriptionLoop()
|
|
320
|
+
}
|
|
321
|
+
this.resolveProcessing()
|
|
322
|
+
this.setIsLoading(false)
|
|
323
|
+
if (options?.setReadyStatus) {
|
|
324
|
+
this.setStatus('ready')
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private reportStreamError(error: Error): void {
|
|
329
|
+
const alreadyReported =
|
|
330
|
+
this.errorReportedGeneration === this.streamGeneration
|
|
331
|
+
this.setError(error)
|
|
332
|
+
// Preserve request-level error semantics even if a RUN_ERROR arrives
|
|
333
|
+
// slightly after loading flips false during stream teardown.
|
|
334
|
+
if (
|
|
335
|
+
this.isLoading ||
|
|
336
|
+
this.status === 'submitted' ||
|
|
337
|
+
this.status === 'streaming'
|
|
338
|
+
) {
|
|
339
|
+
this.setStatus('error')
|
|
340
|
+
}
|
|
341
|
+
if (!alreadyReported) {
|
|
342
|
+
this.errorReportedGeneration = this.streamGeneration
|
|
343
|
+
this.callbacksRef.current.onError(error)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
244
347
|
/**
|
|
245
|
-
*
|
|
348
|
+
* Start the background subscription loop.
|
|
246
349
|
*/
|
|
247
|
-
private
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
this.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
350
|
+
private startSubscription(): void {
|
|
351
|
+
this.subscriptionAbortController = new AbortController()
|
|
352
|
+
const signal = this.subscriptionAbortController.signal
|
|
353
|
+
|
|
354
|
+
this.consumeSubscription(signal)
|
|
355
|
+
.catch((err) => {
|
|
356
|
+
if (err instanceof Error && err.name !== 'AbortError') {
|
|
357
|
+
this.setConnectionStatus('error')
|
|
358
|
+
this.resetSessionGenerating()
|
|
359
|
+
this.setIsSubscribed(false)
|
|
360
|
+
this.reportStreamError(err)
|
|
361
|
+
}
|
|
362
|
+
// Resolve pending processing so streamResponse doesn't hang
|
|
363
|
+
this.resolveProcessing()
|
|
364
|
+
})
|
|
365
|
+
.finally(() => {
|
|
366
|
+
// Ignore stale loops that were superseded by a restart.
|
|
367
|
+
if (this.subscriptionAbortController?.signal !== signal) {
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
this.subscriptionAbortController = null
|
|
371
|
+
if (!signal.aborted && this.isSubscribed) {
|
|
372
|
+
this.setIsSubscribed(false)
|
|
373
|
+
if (this.connectionStatus !== 'error') {
|
|
374
|
+
this.setConnectionStatus('disconnected')
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
}
|
|
255
379
|
|
|
256
|
-
|
|
257
|
-
|
|
380
|
+
/**
|
|
381
|
+
* Consume chunks from the connection subscription.
|
|
382
|
+
*/
|
|
383
|
+
private async consumeSubscription(signal: AbortSignal): Promise<void> {
|
|
384
|
+
const stream = this.connection.subscribe(signal)
|
|
385
|
+
for await (const chunk of stream) {
|
|
386
|
+
if (signal.aborted) break
|
|
387
|
+
if (this.connectionStatus === 'connecting') {
|
|
388
|
+
this.setConnectionStatus('connected')
|
|
389
|
+
}
|
|
258
390
|
this.callbacksRef.current.onChunk(chunk)
|
|
259
391
|
this.processor.processChunk(chunk)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
this.events.messageAppended(
|
|
273
|
-
assistantMessage,
|
|
274
|
-
this.currentStreamId || undefined,
|
|
275
|
-
)
|
|
276
|
-
}
|
|
392
|
+
if (chunk.type === 'RUN_STARTED') {
|
|
393
|
+
this.activeRunIds.add(chunk.runId)
|
|
394
|
+
this.setSessionGenerating(true)
|
|
395
|
+
}
|
|
396
|
+
// RUN_FINISHED / RUN_ERROR signal run completion — resolve processing
|
|
397
|
+
// (redundant if onStreamEnd already resolved it, harmless)
|
|
398
|
+
if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
|
|
399
|
+
if (chunk.runId) {
|
|
400
|
+
this.activeRunIds.delete(chunk.runId)
|
|
401
|
+
} else if (chunk.type === 'RUN_ERROR') {
|
|
402
|
+
// RUN_ERROR without runId is a session-level error; clear all runs
|
|
403
|
+
this.activeRunIds.clear()
|
|
277
404
|
}
|
|
405
|
+
this.setSessionGenerating(this.activeRunIds.size > 0)
|
|
406
|
+
this.resolveProcessing()
|
|
278
407
|
}
|
|
279
|
-
|
|
280
|
-
// Yield control back to event loop to allow UI updates
|
|
408
|
+
// Yield control back to event loop for UI updates
|
|
281
409
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
282
410
|
}
|
|
411
|
+
}
|
|
283
412
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Ensure subscription loop is running, starting it if needed.
|
|
415
|
+
*/
|
|
416
|
+
private ensureSubscription(): void {
|
|
417
|
+
if (!this.isSubscribed) {
|
|
418
|
+
this.subscribe()
|
|
419
|
+
return
|
|
288
420
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const messageId = this.processor.getCurrentAssistantMessageId()
|
|
295
|
-
|
|
296
|
-
// Clear the current stream and message IDs
|
|
297
|
-
this.currentStreamId = null
|
|
298
|
-
this.currentMessageId = null
|
|
299
|
-
|
|
300
|
-
// Return the assistant message if one was created
|
|
301
|
-
if (messageId) {
|
|
302
|
-
const messages = this.processor.getMessages()
|
|
303
|
-
return messages.find((m: UIMessage) => m.id === messageId) || null
|
|
421
|
+
if (
|
|
422
|
+
!this.subscriptionAbortController ||
|
|
423
|
+
this.subscriptionAbortController.signal.aborted
|
|
424
|
+
) {
|
|
425
|
+
this.subscribe({ restart: true })
|
|
304
426
|
}
|
|
427
|
+
}
|
|
305
428
|
|
|
306
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Create a promise that resolves when onStreamEnd fires.
|
|
431
|
+
* Used by streamResponse to await processing completion.
|
|
432
|
+
*/
|
|
433
|
+
private waitForProcessing(): Promise<void> {
|
|
434
|
+
// Resolve any stale promise (e.g., from a previous aborted request)
|
|
435
|
+
this.resolveProcessing()
|
|
436
|
+
return new Promise<void>((resolve) => {
|
|
437
|
+
this.processingResolve = resolve
|
|
438
|
+
})
|
|
307
439
|
}
|
|
308
440
|
|
|
309
441
|
/**
|
|
@@ -426,9 +558,13 @@ export class ChatClient {
|
|
|
426
558
|
return false
|
|
427
559
|
}
|
|
428
560
|
|
|
561
|
+
// Track generation so a superseded stream's cleanup doesn't clobber the new one
|
|
562
|
+
const generation = ++this.streamGeneration
|
|
563
|
+
|
|
429
564
|
this.setIsLoading(true)
|
|
430
565
|
this.setStatus('submitted')
|
|
431
566
|
this.setError(undefined)
|
|
567
|
+
this.errorReportedGeneration = null
|
|
432
568
|
this.abortController = new AbortController()
|
|
433
569
|
// Reset pending tool executions for the new stream
|
|
434
570
|
this.pendingToolExecutions.clear()
|
|
@@ -452,42 +588,85 @@ export class ChatClient {
|
|
|
452
588
|
// Clear the pending message body after use
|
|
453
589
|
this.pendingMessageBody = undefined
|
|
454
590
|
|
|
455
|
-
//
|
|
456
|
-
|
|
591
|
+
// Generate stream ID — assistant message will be created by stream events
|
|
592
|
+
this.currentStreamId = this.generateUniqueId('stream')
|
|
593
|
+
this.currentMessageId = null
|
|
594
|
+
|
|
595
|
+
// Reset processor stream state for new response — prevents stale
|
|
596
|
+
// messageStates entries (from a previous stream) from blocking
|
|
597
|
+
// creation of a new assistant message (e.g. after reload).
|
|
598
|
+
this.processor.prepareAssistantMessage()
|
|
599
|
+
|
|
600
|
+
// Ensure subscription loop is running
|
|
601
|
+
this.ensureSubscription()
|
|
602
|
+
|
|
603
|
+
// Set up promise that resolves when onStreamEnd fires
|
|
604
|
+
const processingComplete = this.waitForProcessing()
|
|
605
|
+
|
|
606
|
+
// Send through normalized connection (pushes chunks to subscription queue)
|
|
607
|
+
await this.connection.send(
|
|
457
608
|
messages,
|
|
458
609
|
mergedBody,
|
|
459
610
|
this.abortController.signal,
|
|
460
611
|
)
|
|
461
612
|
|
|
462
|
-
|
|
613
|
+
// Wait for subscription loop to finish processing all chunks
|
|
614
|
+
await processingComplete
|
|
615
|
+
|
|
616
|
+
// If this stream was superseded (e.g. by reload()), bail out —
|
|
617
|
+
// the new stream owns the processor and processingResolve now.
|
|
618
|
+
if (generation !== this.streamGeneration) {
|
|
619
|
+
return false
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// A RUN_ERROR from the stream transitions status to error.
|
|
623
|
+
// Do not treat this stream as a successful completion.
|
|
624
|
+
if (this.status === 'error') {
|
|
625
|
+
return false
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Wait for pending client tool executions
|
|
629
|
+
if (this.pendingToolExecutions.size > 0) {
|
|
630
|
+
await Promise.all(this.pendingToolExecutions.values())
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Finalize (idempotent — may already be done by RUN_FINISHED handler)
|
|
634
|
+
this.processor.finalizeStream()
|
|
463
635
|
streamCompletedSuccessfully = true
|
|
464
636
|
} catch (err) {
|
|
465
637
|
if (err instanceof Error) {
|
|
466
638
|
if (err.name === 'AbortError') {
|
|
467
639
|
return false
|
|
468
640
|
}
|
|
469
|
-
this.
|
|
470
|
-
|
|
471
|
-
|
|
641
|
+
if (generation === this.streamGeneration) {
|
|
642
|
+
this.reportStreamError(err)
|
|
643
|
+
}
|
|
472
644
|
}
|
|
473
645
|
} finally {
|
|
474
|
-
this
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
646
|
+
// Only clean up if this is still the active stream.
|
|
647
|
+
// A superseded stream (e.g. reload() started a new one) must not
|
|
648
|
+
// clobber the new stream's abortController or isLoading state.
|
|
649
|
+
if (generation === this.streamGeneration) {
|
|
650
|
+
this.currentStreamId = null
|
|
651
|
+
this.currentMessageId = null
|
|
652
|
+
this.abortController = null
|
|
653
|
+
this.setIsLoading(false)
|
|
654
|
+
this.pendingMessageBody = undefined // Ensure it's cleared even on error
|
|
655
|
+
|
|
656
|
+
// Drain any actions that were queued while the stream was in progress
|
|
657
|
+
await this.drainPostStreamActions()
|
|
658
|
+
|
|
659
|
+
// Continue conversation if the stream ended with a tool result (server tool completed)
|
|
660
|
+
if (streamCompletedSuccessfully) {
|
|
661
|
+
const messages = this.processor.getMessages()
|
|
662
|
+
const lastPart = messages.at(-1)?.parts.at(-1)
|
|
663
|
+
|
|
664
|
+
if (lastPart?.type === 'tool-result' && this.shouldAutoSend()) {
|
|
665
|
+
try {
|
|
666
|
+
await this.checkForContinuation()
|
|
667
|
+
} catch (error) {
|
|
668
|
+
console.error('Failed to continue flow after tool result:', error)
|
|
669
|
+
}
|
|
491
670
|
}
|
|
492
671
|
}
|
|
493
672
|
}
|
|
@@ -496,6 +675,39 @@ export class ChatClient {
|
|
|
496
675
|
return streamCompletedSuccessfully
|
|
497
676
|
}
|
|
498
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Start the client subscription loop.
|
|
680
|
+
* This controls the connection lifecycle independently from request lifecycle.
|
|
681
|
+
*/
|
|
682
|
+
subscribe(options?: { restart?: boolean }): void {
|
|
683
|
+
const restart = options?.restart === true
|
|
684
|
+
if (this.isSubscribed && !restart) {
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (this.isSubscribed && restart) {
|
|
689
|
+
this.abortSubscriptionLoop()
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
this.setIsSubscribed(true)
|
|
693
|
+
this.setConnectionStatus('connecting')
|
|
694
|
+
this.startSubscription()
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Unsubscribe and fully tear down live behavior.
|
|
699
|
+
* This aborts an in-flight request and the subscription loop.
|
|
700
|
+
*/
|
|
701
|
+
unsubscribe(): void {
|
|
702
|
+
this.cancelInFlightStream({
|
|
703
|
+
setReadyStatus: true,
|
|
704
|
+
abortSubscription: true,
|
|
705
|
+
})
|
|
706
|
+
this.resetSessionGenerating()
|
|
707
|
+
this.setIsSubscribed(false)
|
|
708
|
+
this.setConnectionStatus('disconnected')
|
|
709
|
+
}
|
|
710
|
+
|
|
499
711
|
/**
|
|
500
712
|
* Reload the last assistant message
|
|
501
713
|
*/
|
|
@@ -510,6 +722,11 @@ export class ChatClient {
|
|
|
510
722
|
|
|
511
723
|
if (lastUserMessageIndex === -1) return
|
|
512
724
|
|
|
725
|
+
// Cancel any active stream before reloading
|
|
726
|
+
if (this.isLoading) {
|
|
727
|
+
this.cancelInFlightStream()
|
|
728
|
+
}
|
|
729
|
+
|
|
513
730
|
this.events.reloaded(lastUserMessageIndex)
|
|
514
731
|
|
|
515
732
|
// Remove all messages after the last user message
|
|
@@ -523,12 +740,7 @@ export class ChatClient {
|
|
|
523
740
|
* Stop the current stream
|
|
524
741
|
*/
|
|
525
742
|
stop(): void {
|
|
526
|
-
|
|
527
|
-
this.abortController.abort()
|
|
528
|
-
this.abortController = null
|
|
529
|
-
}
|
|
530
|
-
this.setIsLoading(false)
|
|
531
|
-
this.setStatus('ready')
|
|
743
|
+
this.cancelInFlightStream({ setReadyStatus: true })
|
|
532
744
|
this.events.stopped()
|
|
533
745
|
}
|
|
534
746
|
|
|
@@ -692,6 +904,30 @@ export class ChatClient {
|
|
|
692
904
|
return this.status
|
|
693
905
|
}
|
|
694
906
|
|
|
907
|
+
/**
|
|
908
|
+
* Get whether the subscription loop is active
|
|
909
|
+
*/
|
|
910
|
+
getIsSubscribed(): boolean {
|
|
911
|
+
return this.isSubscribed
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Get current connection lifecycle status
|
|
916
|
+
*/
|
|
917
|
+
getConnectionStatus(): ConnectionStatus {
|
|
918
|
+
return this.connectionStatus
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Whether the shared session is actively generating.
|
|
923
|
+
* Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).
|
|
924
|
+
* Unlike `isLoading` (request-local), this reflects shared generation
|
|
925
|
+
* activity visible to all subscribers (e.g. across tabs/devices).
|
|
926
|
+
*/
|
|
927
|
+
getSessionGenerating(): boolean {
|
|
928
|
+
return this.sessionGenerating
|
|
929
|
+
}
|
|
930
|
+
|
|
695
931
|
/**
|
|
696
932
|
* Get current error
|
|
697
933
|
*/
|
|
@@ -717,6 +953,9 @@ export class ChatClient {
|
|
|
717
953
|
onChunk?: (chunk: StreamChunk) => void
|
|
718
954
|
onFinish?: (message: UIMessage) => void
|
|
719
955
|
onError?: (error: Error) => void
|
|
956
|
+
onSubscriptionChange?: (isSubscribed: boolean) => void
|
|
957
|
+
onConnectionStatusChange?: (status: ConnectionStatus) => void
|
|
958
|
+
onSessionGeneratingChange?: (isGenerating: boolean) => void
|
|
720
959
|
onCustomEvent?: (
|
|
721
960
|
eventType: string,
|
|
722
961
|
data: unknown,
|
|
@@ -724,7 +963,25 @@ export class ChatClient {
|
|
|
724
963
|
) => void
|
|
725
964
|
}): void {
|
|
726
965
|
if (options.connection !== undefined) {
|
|
727
|
-
|
|
966
|
+
const wasSubscribed = this.isSubscribed
|
|
967
|
+
|
|
968
|
+
if (this.isLoading) {
|
|
969
|
+
this.cancelInFlightStream({
|
|
970
|
+
setReadyStatus: true,
|
|
971
|
+
abortSubscription: true,
|
|
972
|
+
})
|
|
973
|
+
} else if (wasSubscribed) {
|
|
974
|
+
this.abortSubscriptionLoop()
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
this.resetSessionGenerating()
|
|
978
|
+
this.setIsSubscribed(false)
|
|
979
|
+
this.setConnectionStatus('disconnected')
|
|
980
|
+
this.connection = normalizeConnectionAdapter(options.connection)
|
|
981
|
+
|
|
982
|
+
if (wasSubscribed) {
|
|
983
|
+
this.subscribe()
|
|
984
|
+
}
|
|
728
985
|
}
|
|
729
986
|
if (options.body !== undefined) {
|
|
730
987
|
this.body = options.body
|
|
@@ -747,6 +1004,18 @@ export class ChatClient {
|
|
|
747
1004
|
if (options.onError !== undefined) {
|
|
748
1005
|
this.callbacksRef.current.onError = options.onError
|
|
749
1006
|
}
|
|
1007
|
+
if (options.onSubscriptionChange !== undefined) {
|
|
1008
|
+
this.callbacksRef.current.onSubscriptionChange =
|
|
1009
|
+
options.onSubscriptionChange
|
|
1010
|
+
}
|
|
1011
|
+
if (options.onConnectionStatusChange !== undefined) {
|
|
1012
|
+
this.callbacksRef.current.onConnectionStatusChange =
|
|
1013
|
+
options.onConnectionStatusChange
|
|
1014
|
+
}
|
|
1015
|
+
if (options.onSessionGeneratingChange !== undefined) {
|
|
1016
|
+
this.callbacksRef.current.onSessionGeneratingChange =
|
|
1017
|
+
options.onSessionGeneratingChange
|
|
1018
|
+
}
|
|
750
1019
|
if (options.onCustomEvent !== undefined) {
|
|
751
1020
|
this.callbacksRef.current.onCustomEvent = options.onCustomEvent
|
|
752
1021
|
}
|