@tanstack/ai-client 0.21.0 → 0.22.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/dist/esm/chat-client.d.ts +96 -2
- package/dist/esm/chat-client.js +312 -13
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/devtools.d.ts +2 -1
- package/dist/esm/devtools.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/types.d.ts +92 -0
- package/dist/esm/types.js.map +1 -1
- package/package.json +1 -1
- package/src/chat-client.ts +407 -17
- package/src/devtools.ts +2 -0
- package/src/index.ts +7 -0
- package/src/types.ts +99 -0
package/src/chat-client.ts
CHANGED
|
@@ -39,10 +39,21 @@ import type {
|
|
|
39
39
|
ConnectionStatus,
|
|
40
40
|
MessagePart,
|
|
41
41
|
MultimodalContent,
|
|
42
|
+
QueueBusyReason,
|
|
43
|
+
QueueOption,
|
|
44
|
+
QueueStrategy,
|
|
45
|
+
QueuedMessage,
|
|
46
|
+
SendMessageOptions,
|
|
42
47
|
ToolCallPart,
|
|
43
48
|
UIMessage,
|
|
49
|
+
WhenBusy,
|
|
44
50
|
} from './types'
|
|
45
51
|
|
|
52
|
+
/** Internal queue entry — public {@link QueuedMessage} plus optional per-send body. */
|
|
53
|
+
interface InternalQueuedMessage extends QueuedMessage {
|
|
54
|
+
body?: Record<string, any>
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
type ChatClientUpdateOptionsWithoutContext<
|
|
47
58
|
TTools extends ReadonlyArray<AnyClientTool>,
|
|
48
59
|
> = {
|
|
@@ -52,6 +63,7 @@ type ChatClientUpdateOptionsWithoutContext<
|
|
|
52
63
|
body?: Record<string, any>
|
|
53
64
|
forwardedProps?: Record<string, any>
|
|
54
65
|
tools?: TTools
|
|
66
|
+
queue?: QueueOption
|
|
55
67
|
onResponse?: (response?: Response) => void | Promise<void>
|
|
56
68
|
onChunk?: (chunk: StreamChunk) => void
|
|
57
69
|
onFinish?: (message: UIMessage) => void
|
|
@@ -59,6 +71,7 @@ type ChatClientUpdateOptionsWithoutContext<
|
|
|
59
71
|
onSubscriptionChange?: (isSubscribed: boolean) => void
|
|
60
72
|
onConnectionStatusChange?: (status: ConnectionStatus) => void
|
|
61
73
|
onSessionGeneratingChange?: (isGenerating: boolean) => void
|
|
74
|
+
onQueueChange?: (queue: Array<QueuedMessage>) => void
|
|
62
75
|
onCustomEvent?: (
|
|
63
76
|
eventType: string,
|
|
64
77
|
data: unknown,
|
|
@@ -89,6 +102,82 @@ function resolveTransport(transport: {
|
|
|
89
102
|
throw new Error('ChatClient: either `connection` or `fetcher` is required.')
|
|
90
103
|
}
|
|
91
104
|
|
|
105
|
+
export interface NormalizedQueueConfig {
|
|
106
|
+
whenBusy: WhenBusy
|
|
107
|
+
drain: 'fifo' | 'batch'
|
|
108
|
+
onOverflow: 'reject' | 'drop-oldest'
|
|
109
|
+
maxSize?: number
|
|
110
|
+
strategy?: QueueStrategy
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function normalizeQueueOption(
|
|
114
|
+
option: QueueOption | undefined,
|
|
115
|
+
): NormalizedQueueConfig {
|
|
116
|
+
const base: NormalizedQueueConfig = {
|
|
117
|
+
whenBusy: 'queue',
|
|
118
|
+
drain: 'fifo',
|
|
119
|
+
onOverflow: 'reject',
|
|
120
|
+
}
|
|
121
|
+
if (!option) return base
|
|
122
|
+
if (typeof option === 'string') return { ...base, whenBusy: option }
|
|
123
|
+
if (typeof option === 'function') return { ...base, strategy: option }
|
|
124
|
+
|
|
125
|
+
const maxSize = option.maxSize
|
|
126
|
+
if (maxSize !== undefined) {
|
|
127
|
+
if (!Number.isInteger(maxSize) || maxSize < 0) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
'ChatClient: queue.maxSize must be a non-negative integer',
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
whenBusy: option.whenBusy ?? 'queue',
|
|
136
|
+
drain: option.drain ?? 'fifo',
|
|
137
|
+
onOverflow: option.onOverflow ?? 'reject',
|
|
138
|
+
...(maxSize !== undefined ? { maxSize } : {}),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Merge a run of queued messages into a single send for `drain: 'batch'`.
|
|
144
|
+
* All-string content is joined with newlines; mixed/multimodal content is
|
|
145
|
+
* flattened into a single `ContentPart` array. The last item's `body` wins.
|
|
146
|
+
*/
|
|
147
|
+
function mergeQueuedMessages(items: Array<InternalQueuedMessage>): {
|
|
148
|
+
content: string | MultimodalContent
|
|
149
|
+
body?: Record<string, any>
|
|
150
|
+
} {
|
|
151
|
+
const body = items.at(-1)?.body
|
|
152
|
+
const stringContents: Array<string> = []
|
|
153
|
+
for (const item of items) {
|
|
154
|
+
if (typeof item.content !== 'string') {
|
|
155
|
+
break
|
|
156
|
+
}
|
|
157
|
+
stringContents.push(item.content)
|
|
158
|
+
}
|
|
159
|
+
if (stringContents.length === items.length) {
|
|
160
|
+
return {
|
|
161
|
+
content: stringContents.join('\n'),
|
|
162
|
+
...(body !== undefined ? { body } : {}),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const parts: Array<ContentPart> = []
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
if (typeof item.content === 'string') {
|
|
168
|
+
parts.push({ type: 'text', content: item.content })
|
|
169
|
+
} else if (typeof item.content.content === 'string') {
|
|
170
|
+
parts.push({ type: 'text', content: item.content.content })
|
|
171
|
+
} else {
|
|
172
|
+
parts.push(...item.content.content)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
content: { content: parts },
|
|
177
|
+
...(body !== undefined ? { body } : {}),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
92
181
|
export class ChatClient<
|
|
93
182
|
TTools extends ReadonlyArray<AnyClientTool> = any,
|
|
94
183
|
TContext = unknown,
|
|
@@ -110,6 +199,31 @@ export class ChatClient<
|
|
|
110
199
|
private forwardedPropsOption: Record<string, any> = {}
|
|
111
200
|
private context: TContext | undefined = undefined
|
|
112
201
|
private pendingMessageBody: Record<string, any> | undefined = undefined
|
|
202
|
+
private queueConfig: NormalizedQueueConfig
|
|
203
|
+
private messageQueue: Array<InternalQueuedMessage> = []
|
|
204
|
+
/**
|
|
205
|
+
* True from the moment `sendMessage` claims the client until its
|
|
206
|
+
* `streamResponse` settles. Closes the race where concurrent callers both
|
|
207
|
+
* see `isLoading === false`, both append a user message, and only one stream
|
|
208
|
+
* actually runs (leaving stranded user messages with no reply).
|
|
209
|
+
*/
|
|
210
|
+
private sendInFlight = false
|
|
211
|
+
/**
|
|
212
|
+
* True while `drainQueue` is delivering queued messages. Concurrent
|
|
213
|
+
* `sendMessage` calls during a drain are treated as busy and follow
|
|
214
|
+
* `whenBusy` (default: queue).
|
|
215
|
+
*/
|
|
216
|
+
private messageQueueDraining = false
|
|
217
|
+
/**
|
|
218
|
+
* Set by `whenBusy: 'interrupt'` so an in-progress FIFO drain loop stops
|
|
219
|
+
* before starting the next queued item (the interrupting send owns the client).
|
|
220
|
+
*/
|
|
221
|
+
private stopMessageQueueDrain = false
|
|
222
|
+
/**
|
|
223
|
+
* Sync claim held for the duration of `deliverMessage` so concurrent
|
|
224
|
+
* deliverers cannot both append a user message before only one stream runs.
|
|
225
|
+
*/
|
|
226
|
+
private deliverClaim = false
|
|
113
227
|
private isLoading = false
|
|
114
228
|
private isSubscribed = false
|
|
115
229
|
private error: Error | undefined = undefined
|
|
@@ -159,6 +273,7 @@ export class ChatClient<
|
|
|
159
273
|
onSubscriptionChange: (isSubscribed: boolean) => void
|
|
160
274
|
onConnectionStatusChange: (status: ConnectionStatus) => void
|
|
161
275
|
onSessionGeneratingChange: (isGenerating: boolean) => void
|
|
276
|
+
onQueueChange: (queue: Array<QueuedMessage>) => void
|
|
162
277
|
onCustomEvent: (
|
|
163
278
|
eventType: string,
|
|
164
279
|
data: unknown,
|
|
@@ -185,6 +300,7 @@ export class ChatClient<
|
|
|
185
300
|
this.bodyOption = options.body || {}
|
|
186
301
|
this.forwardedPropsOption = options.forwardedProps || {}
|
|
187
302
|
this.context = options.context
|
|
303
|
+
this.queueConfig = normalizeQueueOption(options.queue)
|
|
188
304
|
this.connection = normalizeConnectionAdapter(resolveTransport(options))
|
|
189
305
|
|
|
190
306
|
// Build client tools map
|
|
@@ -215,6 +331,7 @@ export class ChatClient<
|
|
|
215
331
|
options.onConnectionStatusChange || (() => {}),
|
|
216
332
|
onSessionGeneratingChange:
|
|
217
333
|
options.onSessionGeneratingChange || (() => {}),
|
|
334
|
+
onQueueChange: options.onQueueChange || (() => {}),
|
|
218
335
|
onCustomEvent: options.onCustomEvent || (() => {}),
|
|
219
336
|
},
|
|
220
337
|
}
|
|
@@ -571,6 +688,7 @@ export class ChatClient<
|
|
|
571
688
|
connectionStatus: this.connectionStatus,
|
|
572
689
|
sessionGenerating: this.sessionGenerating,
|
|
573
690
|
activeRunIds: Array.from(this.activeRunIds),
|
|
691
|
+
queue: this.getQueue(),
|
|
574
692
|
...(this.error ? { error: this.error.message } : {}),
|
|
575
693
|
}
|
|
576
694
|
}
|
|
@@ -608,6 +726,9 @@ export class ChatClient<
|
|
|
608
726
|
}
|
|
609
727
|
this.resolveProcessing()
|
|
610
728
|
this.setIsLoading(false)
|
|
729
|
+
// Release deliver claim so an interrupting `deliverMessage` can append
|
|
730
|
+
// after abort (the superseded deliver's finally also clears the claim).
|
|
731
|
+
this.deliverClaim = false
|
|
611
732
|
if (options?.setReadyStatus) {
|
|
612
733
|
this.setStatus('ready')
|
|
613
734
|
}
|
|
@@ -738,6 +859,8 @@ export class ChatClient<
|
|
|
738
859
|
* - A MultimodalContent object with content array and optional custom ID
|
|
739
860
|
* @param body - Optional body parameters to merge with the client's base body for this request.
|
|
740
861
|
* Uses shallow merge with per-message body taking priority.
|
|
862
|
+
* @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
|
|
863
|
+
* override the configured queue policy for this one send.
|
|
741
864
|
*
|
|
742
865
|
* @example
|
|
743
866
|
* ```ts
|
|
@@ -747,6 +870,9 @@ export class ChatClient<
|
|
|
747
870
|
* // Text message with custom body params
|
|
748
871
|
* await client.sendMessage('Hello!', { temperature: 0.7 })
|
|
749
872
|
*
|
|
873
|
+
* // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
|
|
874
|
+
* await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
|
|
875
|
+
*
|
|
750
876
|
* // Multimodal message with image
|
|
751
877
|
* await client.sendMessage({
|
|
752
878
|
* content: [
|
|
@@ -771,26 +897,131 @@ export class ChatClient<
|
|
|
771
897
|
async sendMessage(
|
|
772
898
|
content: string | MultimodalContent,
|
|
773
899
|
body?: Record<string, any>,
|
|
900
|
+
sendOptions?: SendMessageOptions,
|
|
774
901
|
): Promise<void> {
|
|
775
902
|
this.mountDevtools()
|
|
776
903
|
const emptyMessage = typeof content === 'string' && !content.trim()
|
|
777
|
-
if (emptyMessage
|
|
904
|
+
if (emptyMessage) {
|
|
778
905
|
return
|
|
779
906
|
}
|
|
780
|
-
// Normalize input to extract content, id, and validate
|
|
781
|
-
const normalizedContent = this.normalizeMessageInput(content)
|
|
782
907
|
|
|
783
|
-
|
|
784
|
-
|
|
908
|
+
if (this.isSendBusy()) {
|
|
909
|
+
const { action, id } = this.decideWhenBusy(content, sendOptions)
|
|
910
|
+
if (action === 'drop') {
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
if (action === 'queue') {
|
|
914
|
+
this.enqueueMessage(content, body, id)
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
// 'interrupt': abort the current stream, then send now.
|
|
918
|
+
// Unlike stop(), does not flush already-queued messages — they drain
|
|
919
|
+
// after this interrupting send settles successfully.
|
|
920
|
+
// Claim sendInFlight *before* cancelling so a concurrent send cannot
|
|
921
|
+
// slip in between cancel and the deliver below.
|
|
922
|
+
this.stopMessageQueueDrain = true
|
|
923
|
+
this.sendInFlight = true
|
|
924
|
+
this.cancelInFlightStream({ setReadyStatus: true })
|
|
925
|
+
this.resetSessionGenerating()
|
|
926
|
+
} else {
|
|
927
|
+
this.sendInFlight = true
|
|
928
|
+
}
|
|
785
929
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
930
|
+
try {
|
|
931
|
+
await this.deliverMessage(content, body)
|
|
932
|
+
} finally {
|
|
933
|
+
this.sendInFlight = false
|
|
934
|
+
}
|
|
935
|
+
}
|
|
792
936
|
|
|
793
|
-
|
|
937
|
+
/** True while a stream is active, a send is claiming the client, or the queue is draining. */
|
|
938
|
+
private isSendBusy(): boolean {
|
|
939
|
+
return this.isLoading || this.sendInFlight || this.messageQueueDraining
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
private resolveBusyReason(): QueueBusyReason {
|
|
943
|
+
if (this.isLoading) return 'streaming'
|
|
944
|
+
if (this.messageQueueDraining) return 'draining'
|
|
945
|
+
return 'sendInFlight'
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Append a user message and run the stream. Used by both direct sends and
|
|
950
|
+
* queue drains — callers are responsible for busy/queue policy.
|
|
951
|
+
*
|
|
952
|
+
* Claims delivery synchronously before appending so concurrent callers
|
|
953
|
+
* cannot both add a user message when only one stream can run.
|
|
954
|
+
*/
|
|
955
|
+
private async deliverMessage(
|
|
956
|
+
content: string | MultimodalContent,
|
|
957
|
+
body?: Record<string, any>,
|
|
958
|
+
): Promise<boolean> {
|
|
959
|
+
if (this.isLoading || this.deliverClaim) {
|
|
960
|
+
return false
|
|
961
|
+
}
|
|
962
|
+
this.deliverClaim = true
|
|
963
|
+
try {
|
|
964
|
+
const normalizedContent = this.normalizeMessageInput(content)
|
|
965
|
+
this.pendingMessageBody = body
|
|
966
|
+
const userMessage = this.processor.addUserMessage(
|
|
967
|
+
normalizedContent.content,
|
|
968
|
+
normalizedContent.id,
|
|
969
|
+
)
|
|
970
|
+
this.events.messageSent(userMessage.id, normalizedContent.content)
|
|
971
|
+
return await this.streamResponse()
|
|
972
|
+
} finally {
|
|
973
|
+
this.deliverClaim = false
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Resolve the effective action for a send that arrives while busy.
|
|
979
|
+
* The returned `id` is the id that will be stored if the action is `queue`.
|
|
980
|
+
*/
|
|
981
|
+
private decideWhenBusy(
|
|
982
|
+
content: string | MultimodalContent,
|
|
983
|
+
sendOptions?: SendMessageOptions,
|
|
984
|
+
): { action: WhenBusy; id: string } {
|
|
985
|
+
const id = this.generateUniqueId('queued')
|
|
986
|
+
if (sendOptions?.whenBusy) {
|
|
987
|
+
return { action: sendOptions.whenBusy, id }
|
|
988
|
+
}
|
|
989
|
+
const { strategy, whenBusy } = this.queueConfig
|
|
990
|
+
if (strategy) {
|
|
991
|
+
const { action } = strategy({
|
|
992
|
+
pending: {
|
|
993
|
+
id,
|
|
994
|
+
content,
|
|
995
|
+
createdAt: Date.now(),
|
|
996
|
+
},
|
|
997
|
+
busyReason: this.resolveBusyReason(),
|
|
998
|
+
queued: this.getQueue(),
|
|
999
|
+
})
|
|
1000
|
+
return { action, id }
|
|
1001
|
+
}
|
|
1002
|
+
return { action: whenBusy, id }
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
private enqueueMessage(
|
|
1006
|
+
content: string | MultimodalContent,
|
|
1007
|
+
body?: Record<string, any>,
|
|
1008
|
+
id?: string,
|
|
1009
|
+
): void {
|
|
1010
|
+
const { maxSize, onOverflow } = this.queueConfig
|
|
1011
|
+
if (maxSize !== undefined && this.messageQueue.length >= maxSize) {
|
|
1012
|
+
// maxSize 0 is a hard cap (never queue). drop-oldest cannot make room.
|
|
1013
|
+
if (onOverflow === 'reject' || maxSize === 0) {
|
|
1014
|
+
return
|
|
1015
|
+
}
|
|
1016
|
+
this.messageQueue.shift() // drop-oldest
|
|
1017
|
+
}
|
|
1018
|
+
this.messageQueue.push({
|
|
1019
|
+
id: id ?? this.generateUniqueId('queued'),
|
|
1020
|
+
content,
|
|
1021
|
+
createdAt: Date.now(),
|
|
1022
|
+
...(body !== undefined ? { body } : {}),
|
|
1023
|
+
})
|
|
1024
|
+
this.emitQueueChange()
|
|
794
1025
|
}
|
|
795
1026
|
|
|
796
1027
|
/**
|
|
@@ -858,6 +1089,10 @@ export class ChatClient<
|
|
|
858
1089
|
this.currentRunId = runId
|
|
859
1090
|
|
|
860
1091
|
this.setIsLoading(true)
|
|
1092
|
+
// Hand off from deliverClaim to isLoading so nested drain can call
|
|
1093
|
+
// deliverMessage after this stream settles (while the outer deliver
|
|
1094
|
+
// is still on the stack).
|
|
1095
|
+
this.deliverClaim = false
|
|
861
1096
|
this.setStatus('submitted')
|
|
862
1097
|
this.setError(undefined)
|
|
863
1098
|
this.errorReportedGeneration = null
|
|
@@ -1072,13 +1307,33 @@ export class ChatClient<
|
|
|
1072
1307
|
await this.checkForContinuation()
|
|
1073
1308
|
} catch (error) {
|
|
1074
1309
|
console.error('Failed to continue flow after tool result:', error)
|
|
1310
|
+
// Continuation failed without starting a new stream — don't
|
|
1311
|
+
// leave queued user messages stranded forever. (isLoading is
|
|
1312
|
+
// already false in this finally block.)
|
|
1313
|
+
await this.drainQueue()
|
|
1314
|
+
}
|
|
1315
|
+
} else {
|
|
1316
|
+
if (this.status !== 'ready') {
|
|
1317
|
+
// Terminal run, but onStreamEnd never fired: the processor had
|
|
1318
|
+
// no assistant message to emit it for (e.g. a bare
|
|
1319
|
+
// RUN_FINISHED{stop}, #421). The normal path already set
|
|
1320
|
+
// 'ready', so this is a no-op.
|
|
1321
|
+
this.setStatus('ready')
|
|
1322
|
+
}
|
|
1323
|
+
// Auto-send queued messages once the run fully settles. When a
|
|
1324
|
+
// continuation runs instead (tool-result branch above), that
|
|
1325
|
+
// continuation's own finally drains the queue. Skip if a drain
|
|
1326
|
+
// loop is already walking the queue (avoids nested re-entry).
|
|
1327
|
+
if (!this.messageQueueDraining) {
|
|
1328
|
+
await this.drainQueue()
|
|
1075
1329
|
}
|
|
1076
|
-
} else if (this.status !== 'ready') {
|
|
1077
|
-
// Terminal run, but onStreamEnd never fired: the processor had no
|
|
1078
|
-
// assistant message to emit it for (e.g. a bare RUN_FINISHED{stop},
|
|
1079
|
-
// #421). The normal path already set 'ready', so this is a no-op.
|
|
1080
|
-
this.setStatus('ready')
|
|
1081
1330
|
}
|
|
1331
|
+
} else {
|
|
1332
|
+
// Error/abort settle for the active generation: don't strand or
|
|
1333
|
+
// later mis-order queued messages. A failed turn flushes the queue
|
|
1334
|
+
// (consistent with stop()); it must NOT auto-drain into a likely
|
|
1335
|
+
// broken endpoint.
|
|
1336
|
+
this.flushQueue()
|
|
1082
1337
|
}
|
|
1083
1338
|
}
|
|
1084
1339
|
}
|
|
@@ -1114,6 +1369,7 @@ export class ChatClient<
|
|
|
1114
1369
|
setReadyStatus: true,
|
|
1115
1370
|
abortSubscription: true,
|
|
1116
1371
|
})
|
|
1372
|
+
this.discardPendingSends()
|
|
1117
1373
|
this.resetSessionGenerating()
|
|
1118
1374
|
this.setIsSubscribed(false)
|
|
1119
1375
|
this.setConnectionStatus('disconnected')
|
|
@@ -1137,6 +1393,9 @@ export class ChatClient<
|
|
|
1137
1393
|
if (this.isLoading) {
|
|
1138
1394
|
this.cancelInFlightStream()
|
|
1139
1395
|
}
|
|
1396
|
+
// Discard pending follow-ups so "regenerate last answer" does not also
|
|
1397
|
+
// auto-send messages that were typed during the previous stream.
|
|
1398
|
+
this.discardPendingSends()
|
|
1140
1399
|
|
|
1141
1400
|
this.events.reloaded(lastUserMessageIndex)
|
|
1142
1401
|
|
|
@@ -1154,6 +1413,7 @@ export class ChatClient<
|
|
|
1154
1413
|
stop(): void {
|
|
1155
1414
|
const hadLocalStream = this.abortController !== null
|
|
1156
1415
|
this.cancelInFlightStream({ setReadyStatus: true })
|
|
1416
|
+
this.discardPendingSends()
|
|
1157
1417
|
if (hadLocalStream) {
|
|
1158
1418
|
this.resetSessionGenerating()
|
|
1159
1419
|
}
|
|
@@ -1181,6 +1441,7 @@ export class ChatClient<
|
|
|
1181
1441
|
this.persistor.beginClear()
|
|
1182
1442
|
}
|
|
1183
1443
|
this.processor.clearMessages()
|
|
1444
|
+
this.discardPendingSends()
|
|
1184
1445
|
this.persistor?.remove()
|
|
1185
1446
|
this.setError(undefined)
|
|
1186
1447
|
this.events.messagesCleared()
|
|
@@ -1376,6 +1637,129 @@ export class ChatClient<
|
|
|
1376
1637
|
return this.processor.getMessages() as Array<UIMessage<TTools>>
|
|
1377
1638
|
}
|
|
1378
1639
|
|
|
1640
|
+
/**
|
|
1641
|
+
* True when an interrupt (or another direct send) claimed the client during
|
|
1642
|
+
* a drain. Read via a method so cross-await mutations are not constant-folded
|
|
1643
|
+
* by control-flow analysis.
|
|
1644
|
+
*/
|
|
1645
|
+
private shouldAbortMessageQueueDrain(): boolean {
|
|
1646
|
+
return this.isLoading || this.stopMessageQueueDrain
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Deliver queued messages after a successful settle.
|
|
1651
|
+
* - `batch`: merge everything currently queued into one send, looping so
|
|
1652
|
+
* messages enqueued during that batch stream are not stranded.
|
|
1653
|
+
* - `fifo`: walk the queue in a loop, one stream at a time, until empty
|
|
1654
|
+
* (or until another send claims the client via interrupt).
|
|
1655
|
+
*
|
|
1656
|
+
* Uses `deliverMessage` directly so drains do not re-enter `sendMessage`'s
|
|
1657
|
+
* busy/queue policy (which would re-queue items and strand the rest).
|
|
1658
|
+
*/
|
|
1659
|
+
private async drainQueue(): Promise<void> {
|
|
1660
|
+
// Note: do not gate on `sendInFlight`. Normal sends still hold
|
|
1661
|
+
// `sendInFlight` while `streamResponse`'s finally invokes drain; blocking
|
|
1662
|
+
// on it would permanently strand the queue.
|
|
1663
|
+
if (
|
|
1664
|
+
this.messageQueueDraining ||
|
|
1665
|
+
this.isLoading ||
|
|
1666
|
+
this.messageQueue.length === 0
|
|
1667
|
+
) {
|
|
1668
|
+
return
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
this.messageQueueDraining = true
|
|
1672
|
+
this.stopMessageQueueDrain = false
|
|
1673
|
+
try {
|
|
1674
|
+
if (this.queueConfig.drain === 'batch') {
|
|
1675
|
+
while (this.messageQueue.length > 0) {
|
|
1676
|
+
if (this.shouldAbortMessageQueueDrain()) {
|
|
1677
|
+
return
|
|
1678
|
+
}
|
|
1679
|
+
const items = this.messageQueue.splice(0)
|
|
1680
|
+
this.emitQueueChange()
|
|
1681
|
+
const merged = mergeQueuedMessages(items)
|
|
1682
|
+
const completed = await this.deliverMessage(
|
|
1683
|
+
merged.content,
|
|
1684
|
+
merged.body,
|
|
1685
|
+
)
|
|
1686
|
+
// Failed/aborted deliver flushes the rest of the queue in streamResponse.
|
|
1687
|
+
if (!completed || this.shouldAbortMessageQueueDrain()) {
|
|
1688
|
+
return
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
return
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
while (this.messageQueue.length > 0) {
|
|
1695
|
+
// Interrupt (or a new direct send) claimed the client — stop draining;
|
|
1696
|
+
// remaining items stay queued and will drain after that send settles.
|
|
1697
|
+
if (this.shouldAbortMessageQueueDrain()) {
|
|
1698
|
+
return
|
|
1699
|
+
}
|
|
1700
|
+
const next = this.messageQueue.shift()
|
|
1701
|
+
if (next === undefined) {
|
|
1702
|
+
return
|
|
1703
|
+
}
|
|
1704
|
+
this.emitQueueChange()
|
|
1705
|
+
const completed = await this.deliverMessage(next.content, next.body)
|
|
1706
|
+
// Failed/aborted deliver flushes the rest of the queue in streamResponse.
|
|
1707
|
+
if (!completed || this.shouldAbortMessageQueueDrain()) {
|
|
1708
|
+
return
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
} finally {
|
|
1712
|
+
this.messageQueueDraining = false
|
|
1713
|
+
this.stopMessageQueueDrain = false
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Drop any in-flight send claim and discard pending queued messages
|
|
1719
|
+
* (stop / error / clear / unsubscribe / reload).
|
|
1720
|
+
*/
|
|
1721
|
+
private discardPendingSends(): void {
|
|
1722
|
+
this.sendInFlight = false
|
|
1723
|
+
this.flushQueue()
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* Get the current send queue (messages held while a stream was in flight).
|
|
1728
|
+
*/
|
|
1729
|
+
getQueue(): Array<QueuedMessage> {
|
|
1730
|
+
return this.messageQueue.map(({ id, content, createdAt }) => ({
|
|
1731
|
+
id,
|
|
1732
|
+
content,
|
|
1733
|
+
createdAt,
|
|
1734
|
+
}))
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
private emitQueueChange(): void {
|
|
1738
|
+
this.callbacksRef.current.onQueueChange(this.getQueue())
|
|
1739
|
+
this.devtoolsBridge.emitSnapshot()
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Remove a queued message by id before it drains.
|
|
1744
|
+
*/
|
|
1745
|
+
cancelQueued(id: string): void {
|
|
1746
|
+
const index = this.messageQueue.findIndex((m) => m.id === id)
|
|
1747
|
+
if (index === -1) return
|
|
1748
|
+
this.messageQueue.splice(index, 1)
|
|
1749
|
+
this.emitQueueChange()
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* Discard all pending queued messages (stop / error / clear / unsubscribe /
|
|
1754
|
+
* reload). Does not send them. Emits `onQueueChange([])` when anything was
|
|
1755
|
+
* removed.
|
|
1756
|
+
*/
|
|
1757
|
+
private flushQueue(): void {
|
|
1758
|
+
if (this.messageQueue.length === 0) return
|
|
1759
|
+
this.messageQueue = []
|
|
1760
|
+
this.emitQueueChange()
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1379
1763
|
/**
|
|
1380
1764
|
* Get loading state
|
|
1381
1765
|
*/
|
|
@@ -1488,6 +1872,9 @@ export class ChatClient<
|
|
|
1488
1872
|
}
|
|
1489
1873
|
this.devtoolsBridge.notifyToolsChanged()
|
|
1490
1874
|
}
|
|
1875
|
+
if (options.queue !== undefined) {
|
|
1876
|
+
this.queueConfig = normalizeQueueOption(options.queue)
|
|
1877
|
+
}
|
|
1491
1878
|
if (options.onResponse !== undefined) {
|
|
1492
1879
|
this.callbacksRef.current.onResponse = options.onResponse
|
|
1493
1880
|
}
|
|
@@ -1512,6 +1899,9 @@ export class ChatClient<
|
|
|
1512
1899
|
this.callbacksRef.current.onSessionGeneratingChange =
|
|
1513
1900
|
options.onSessionGeneratingChange
|
|
1514
1901
|
}
|
|
1902
|
+
if (options.onQueueChange !== undefined) {
|
|
1903
|
+
this.callbacksRef.current.onQueueChange = options.onQueueChange
|
|
1904
|
+
}
|
|
1515
1905
|
if (options.onCustomEvent !== undefined) {
|
|
1516
1906
|
this.callbacksRef.current.onCustomEvent = options.onCustomEvent
|
|
1517
1907
|
}
|
package/src/devtools.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
ChatClientState,
|
|
17
17
|
ConnectionStatus,
|
|
18
18
|
MessagePart,
|
|
19
|
+
QueuedMessage,
|
|
19
20
|
ToolCallPart,
|
|
20
21
|
UIMessage,
|
|
21
22
|
} from './types'
|
|
@@ -114,6 +115,7 @@ export interface AIDevtoolsChatSnapshot {
|
|
|
114
115
|
connectionStatus: ConnectionStatus
|
|
115
116
|
sessionGenerating: boolean
|
|
116
117
|
activeRunIds: Array<string>
|
|
118
|
+
queue?: Array<QueuedMessage>
|
|
117
119
|
error?: string
|
|
118
120
|
}
|
|
119
121
|
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,13 @@ export type {
|
|
|
35
35
|
ChatTransport,
|
|
36
36
|
DistributedOmit,
|
|
37
37
|
MultimodalContent,
|
|
38
|
+
QueuedMessage,
|
|
39
|
+
WhenBusy,
|
|
40
|
+
QueueBusyReason,
|
|
41
|
+
QueueConfig,
|
|
42
|
+
QueueStrategy,
|
|
43
|
+
QueueOption,
|
|
44
|
+
SendMessageOptions,
|
|
38
45
|
} from './types'
|
|
39
46
|
// Generation client types
|
|
40
47
|
export type {
|