@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AnyClientTool, ModelMessage, StreamChunk } from '@tanstack/ai/client';
|
|
2
2
|
import { ConnectionAdapter } from './connection-adapters.js';
|
|
3
|
-
import { ChatClientOptions, ChatClientState, ChatFetcher, ConnectionStatus, MultimodalContent, UIMessage } from './types.js';
|
|
3
|
+
import { ChatClientOptions, ChatClientState, ChatFetcher, ConnectionStatus, MultimodalContent, QueueOption, QueueStrategy, QueuedMessage, SendMessageOptions, UIMessage, WhenBusy } from './types.js';
|
|
4
4
|
type ChatClientUpdateOptionsWithoutContext<TTools extends ReadonlyArray<AnyClientTool>> = {
|
|
5
5
|
connection?: ConnectionAdapter;
|
|
6
6
|
fetcher?: ChatFetcher;
|
|
@@ -8,6 +8,7 @@ type ChatClientUpdateOptionsWithoutContext<TTools extends ReadonlyArray<AnyClien
|
|
|
8
8
|
body?: Record<string, any>;
|
|
9
9
|
forwardedProps?: Record<string, any>;
|
|
10
10
|
tools?: TTools;
|
|
11
|
+
queue?: QueueOption;
|
|
11
12
|
onResponse?: (response?: Response) => void | Promise<void>;
|
|
12
13
|
onChunk?: (chunk: StreamChunk) => void;
|
|
13
14
|
onFinish?: (message: UIMessage) => void;
|
|
@@ -15,6 +16,7 @@ type ChatClientUpdateOptionsWithoutContext<TTools extends ReadonlyArray<AnyClien
|
|
|
15
16
|
onSubscriptionChange?: (isSubscribed: boolean) => void;
|
|
16
17
|
onConnectionStatusChange?: (status: ConnectionStatus) => void;
|
|
17
18
|
onSessionGeneratingChange?: (isGenerating: boolean) => void;
|
|
19
|
+
onQueueChange?: (queue: Array<QueuedMessage>) => void;
|
|
18
20
|
onCustomEvent?: (eventType: string, data: unknown, context: {
|
|
19
21
|
toolCallId?: string;
|
|
20
22
|
}) => void;
|
|
@@ -26,6 +28,14 @@ type ClientToolResult = {
|
|
|
26
28
|
state?: 'output-available' | 'output-error';
|
|
27
29
|
errorText?: string;
|
|
28
30
|
};
|
|
31
|
+
export interface NormalizedQueueConfig {
|
|
32
|
+
whenBusy: WhenBusy;
|
|
33
|
+
drain: 'fifo' | 'batch';
|
|
34
|
+
onOverflow: 'reject' | 'drop-oldest';
|
|
35
|
+
maxSize?: number;
|
|
36
|
+
strategy?: QueueStrategy;
|
|
37
|
+
}
|
|
38
|
+
export declare function normalizeQueueOption(option: QueueOption | undefined): NormalizedQueueConfig;
|
|
29
39
|
export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = any, TContext = unknown> {
|
|
30
40
|
private readonly processor;
|
|
31
41
|
private connection;
|
|
@@ -37,6 +47,31 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
|
|
|
37
47
|
private forwardedPropsOption;
|
|
38
48
|
private context;
|
|
39
49
|
private pendingMessageBody;
|
|
50
|
+
private queueConfig;
|
|
51
|
+
private messageQueue;
|
|
52
|
+
/**
|
|
53
|
+
* True from the moment `sendMessage` claims the client until its
|
|
54
|
+
* `streamResponse` settles. Closes the race where concurrent callers both
|
|
55
|
+
* see `isLoading === false`, both append a user message, and only one stream
|
|
56
|
+
* actually runs (leaving stranded user messages with no reply).
|
|
57
|
+
*/
|
|
58
|
+
private sendInFlight;
|
|
59
|
+
/**
|
|
60
|
+
* True while `drainQueue` is delivering queued messages. Concurrent
|
|
61
|
+
* `sendMessage` calls during a drain are treated as busy and follow
|
|
62
|
+
* `whenBusy` (default: queue).
|
|
63
|
+
*/
|
|
64
|
+
private messageQueueDraining;
|
|
65
|
+
/**
|
|
66
|
+
* Set by `whenBusy: 'interrupt'` so an in-progress FIFO drain loop stops
|
|
67
|
+
* before starting the next queued item (the interrupting send owns the client).
|
|
68
|
+
*/
|
|
69
|
+
private stopMessageQueueDrain;
|
|
70
|
+
/**
|
|
71
|
+
* Sync claim held for the duration of `deliverMessage` so concurrent
|
|
72
|
+
* deliverers cannot both append a user message before only one stream runs.
|
|
73
|
+
*/
|
|
74
|
+
private deliverClaim;
|
|
40
75
|
private isLoading;
|
|
41
76
|
private isSubscribed;
|
|
42
77
|
private error;
|
|
@@ -119,6 +154,8 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
|
|
|
119
154
|
* - A MultimodalContent object with content array and optional custom ID
|
|
120
155
|
* @param body - Optional body parameters to merge with the client's base body for this request.
|
|
121
156
|
* Uses shallow merge with per-message body taking priority.
|
|
157
|
+
* @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
|
|
158
|
+
* override the configured queue policy for this one send.
|
|
122
159
|
*
|
|
123
160
|
* @example
|
|
124
161
|
* ```ts
|
|
@@ -128,6 +165,9 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
|
|
|
128
165
|
* // Text message with custom body params
|
|
129
166
|
* await client.sendMessage('Hello!', { temperature: 0.7 })
|
|
130
167
|
*
|
|
168
|
+
* // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
|
|
169
|
+
* await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
|
|
170
|
+
*
|
|
131
171
|
* // Multimodal message with image
|
|
132
172
|
* await client.sendMessage({
|
|
133
173
|
* content: [
|
|
@@ -149,7 +189,24 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
|
|
|
149
189
|
* )
|
|
150
190
|
* ```
|
|
151
191
|
*/
|
|
152
|
-
sendMessage(content: string | MultimodalContent, body?: Record<string, any
|
|
192
|
+
sendMessage(content: string | MultimodalContent, body?: Record<string, any>, sendOptions?: SendMessageOptions): Promise<void>;
|
|
193
|
+
/** True while a stream is active, a send is claiming the client, or the queue is draining. */
|
|
194
|
+
private isSendBusy;
|
|
195
|
+
private resolveBusyReason;
|
|
196
|
+
/**
|
|
197
|
+
* Append a user message and run the stream. Used by both direct sends and
|
|
198
|
+
* queue drains — callers are responsible for busy/queue policy.
|
|
199
|
+
*
|
|
200
|
+
* Claims delivery synchronously before appending so concurrent callers
|
|
201
|
+
* cannot both add a user message when only one stream can run.
|
|
202
|
+
*/
|
|
203
|
+
private deliverMessage;
|
|
204
|
+
/**
|
|
205
|
+
* Resolve the effective action for a send that arrives while busy.
|
|
206
|
+
* The returned `id` is the id that will be stored if the action is `queue`.
|
|
207
|
+
*/
|
|
208
|
+
private decideWhenBusy;
|
|
209
|
+
private enqueueMessage;
|
|
153
210
|
/**
|
|
154
211
|
* Normalize the message input to extract content and optional id.
|
|
155
212
|
* Trims string content automatically.
|
|
@@ -223,6 +280,43 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
|
|
|
223
280
|
* Get current messages
|
|
224
281
|
*/
|
|
225
282
|
getMessages(): Array<UIMessage<TTools>>;
|
|
283
|
+
/**
|
|
284
|
+
* True when an interrupt (or another direct send) claimed the client during
|
|
285
|
+
* a drain. Read via a method so cross-await mutations are not constant-folded
|
|
286
|
+
* by control-flow analysis.
|
|
287
|
+
*/
|
|
288
|
+
private shouldAbortMessageQueueDrain;
|
|
289
|
+
/**
|
|
290
|
+
* Deliver queued messages after a successful settle.
|
|
291
|
+
* - `batch`: merge everything currently queued into one send, looping so
|
|
292
|
+
* messages enqueued during that batch stream are not stranded.
|
|
293
|
+
* - `fifo`: walk the queue in a loop, one stream at a time, until empty
|
|
294
|
+
* (or until another send claims the client via interrupt).
|
|
295
|
+
*
|
|
296
|
+
* Uses `deliverMessage` directly so drains do not re-enter `sendMessage`'s
|
|
297
|
+
* busy/queue policy (which would re-queue items and strand the rest).
|
|
298
|
+
*/
|
|
299
|
+
private drainQueue;
|
|
300
|
+
/**
|
|
301
|
+
* Drop any in-flight send claim and discard pending queued messages
|
|
302
|
+
* (stop / error / clear / unsubscribe / reload).
|
|
303
|
+
*/
|
|
304
|
+
private discardPendingSends;
|
|
305
|
+
/**
|
|
306
|
+
* Get the current send queue (messages held while a stream was in flight).
|
|
307
|
+
*/
|
|
308
|
+
getQueue(): Array<QueuedMessage>;
|
|
309
|
+
private emitQueueChange;
|
|
310
|
+
/**
|
|
311
|
+
* Remove a queued message by id before it drains.
|
|
312
|
+
*/
|
|
313
|
+
cancelQueued(id: string): void;
|
|
314
|
+
/**
|
|
315
|
+
* Discard all pending queued messages (stop / error / clear / unsubscribe /
|
|
316
|
+
* reload). Does not send them. Emits `onQueueChange([])` when anything was
|
|
317
|
+
* removed.
|
|
318
|
+
*/
|
|
319
|
+
private flushQueue;
|
|
226
320
|
/**
|
|
227
321
|
* Get loading state
|
|
228
322
|
*/
|
package/dist/esm/chat-client.js
CHANGED
|
@@ -13,6 +13,60 @@ function resolveTransport(transport) {
|
|
|
13
13
|
if (fetcher) return fetcherToConnectionAdapter(fetcher);
|
|
14
14
|
throw new Error("ChatClient: either `connection` or `fetcher` is required.");
|
|
15
15
|
}
|
|
16
|
+
function normalizeQueueOption(option) {
|
|
17
|
+
const base = {
|
|
18
|
+
whenBusy: "queue",
|
|
19
|
+
drain: "fifo",
|
|
20
|
+
onOverflow: "reject"
|
|
21
|
+
};
|
|
22
|
+
if (!option) return base;
|
|
23
|
+
if (typeof option === "string") return { ...base, whenBusy: option };
|
|
24
|
+
if (typeof option === "function") return { ...base, strategy: option };
|
|
25
|
+
const maxSize = option.maxSize;
|
|
26
|
+
if (maxSize !== void 0) {
|
|
27
|
+
if (!Number.isInteger(maxSize) || maxSize < 0) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"ChatClient: queue.maxSize must be a non-negative integer"
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
whenBusy: option.whenBusy ?? "queue",
|
|
35
|
+
drain: option.drain ?? "fifo",
|
|
36
|
+
onOverflow: option.onOverflow ?? "reject",
|
|
37
|
+
...maxSize !== void 0 ? { maxSize } : {}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function mergeQueuedMessages(items) {
|
|
41
|
+
const body = items.at(-1)?.body;
|
|
42
|
+
const stringContents = [];
|
|
43
|
+
for (const item of items) {
|
|
44
|
+
if (typeof item.content !== "string") {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
stringContents.push(item.content);
|
|
48
|
+
}
|
|
49
|
+
if (stringContents.length === items.length) {
|
|
50
|
+
return {
|
|
51
|
+
content: stringContents.join("\n"),
|
|
52
|
+
...body !== void 0 ? { body } : {}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (const item of items) {
|
|
57
|
+
if (typeof item.content === "string") {
|
|
58
|
+
parts.push({ type: "text", content: item.content });
|
|
59
|
+
} else if (typeof item.content.content === "string") {
|
|
60
|
+
parts.push({ type: "text", content: item.content.content });
|
|
61
|
+
} else {
|
|
62
|
+
parts.push(...item.content.content);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
content: { content: parts },
|
|
67
|
+
...body !== void 0 ? { body } : {}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
16
70
|
class ChatClient {
|
|
17
71
|
processor;
|
|
18
72
|
connection;
|
|
@@ -31,6 +85,31 @@ class ChatClient {
|
|
|
31
85
|
forwardedPropsOption = {};
|
|
32
86
|
context = void 0;
|
|
33
87
|
pendingMessageBody = void 0;
|
|
88
|
+
queueConfig;
|
|
89
|
+
messageQueue = [];
|
|
90
|
+
/**
|
|
91
|
+
* True from the moment `sendMessage` claims the client until its
|
|
92
|
+
* `streamResponse` settles. Closes the race where concurrent callers both
|
|
93
|
+
* see `isLoading === false`, both append a user message, and only one stream
|
|
94
|
+
* actually runs (leaving stranded user messages with no reply).
|
|
95
|
+
*/
|
|
96
|
+
sendInFlight = false;
|
|
97
|
+
/**
|
|
98
|
+
* True while `drainQueue` is delivering queued messages. Concurrent
|
|
99
|
+
* `sendMessage` calls during a drain are treated as busy and follow
|
|
100
|
+
* `whenBusy` (default: queue).
|
|
101
|
+
*/
|
|
102
|
+
messageQueueDraining = false;
|
|
103
|
+
/**
|
|
104
|
+
* Set by `whenBusy: 'interrupt'` so an in-progress FIFO drain loop stops
|
|
105
|
+
* before starting the next queued item (the interrupting send owns the client).
|
|
106
|
+
*/
|
|
107
|
+
stopMessageQueueDrain = false;
|
|
108
|
+
/**
|
|
109
|
+
* Sync claim held for the duration of `deliverMessage` so concurrent
|
|
110
|
+
* deliverers cannot both append a user message before only one stream runs.
|
|
111
|
+
*/
|
|
112
|
+
deliverClaim = false;
|
|
34
113
|
isLoading = false;
|
|
35
114
|
isSubscribed = false;
|
|
36
115
|
error = void 0;
|
|
@@ -80,6 +159,7 @@ class ChatClient {
|
|
|
80
159
|
this.bodyOption = options.body || {};
|
|
81
160
|
this.forwardedPropsOption = options.forwardedProps || {};
|
|
82
161
|
this.context = options.context;
|
|
162
|
+
this.queueConfig = normalizeQueueOption(options.queue);
|
|
83
163
|
this.connection = normalizeConnectionAdapter(resolveTransport(options));
|
|
84
164
|
this.clientToolsRef = { current: /* @__PURE__ */ new Map() };
|
|
85
165
|
if (options.tools) {
|
|
@@ -113,6 +193,8 @@ class ChatClient {
|
|
|
113
193
|
}),
|
|
114
194
|
onSessionGeneratingChange: options.onSessionGeneratingChange || (() => {
|
|
115
195
|
}),
|
|
196
|
+
onQueueChange: options.onQueueChange || (() => {
|
|
197
|
+
}),
|
|
116
198
|
onCustomEvent: options.onCustomEvent || (() => {
|
|
117
199
|
})
|
|
118
200
|
}
|
|
@@ -383,6 +465,7 @@ class ChatClient {
|
|
|
383
465
|
connectionStatus: this.connectionStatus,
|
|
384
466
|
sessionGenerating: this.sessionGenerating,
|
|
385
467
|
activeRunIds: Array.from(this.activeRunIds),
|
|
468
|
+
queue: this.getQueue(),
|
|
386
469
|
...this.error ? { error: this.error.message } : {}
|
|
387
470
|
};
|
|
388
471
|
}
|
|
@@ -412,6 +495,7 @@ class ChatClient {
|
|
|
412
495
|
}
|
|
413
496
|
this.resolveProcessing();
|
|
414
497
|
this.setIsLoading(false);
|
|
498
|
+
this.deliverClaim = false;
|
|
415
499
|
if (options?.setReadyStatus) {
|
|
416
500
|
this.setStatus("ready");
|
|
417
501
|
}
|
|
@@ -513,6 +597,8 @@ class ChatClient {
|
|
|
513
597
|
* - A MultimodalContent object with content array and optional custom ID
|
|
514
598
|
* @param body - Optional body parameters to merge with the client's base body for this request.
|
|
515
599
|
* Uses shallow merge with per-message body taking priority.
|
|
600
|
+
* @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
|
|
601
|
+
* override the configured queue policy for this one send.
|
|
516
602
|
*
|
|
517
603
|
* @example
|
|
518
604
|
* ```ts
|
|
@@ -522,6 +608,9 @@ class ChatClient {
|
|
|
522
608
|
* // Text message with custom body params
|
|
523
609
|
* await client.sendMessage('Hello!', { temperature: 0.7 })
|
|
524
610
|
*
|
|
611
|
+
* // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
|
|
612
|
+
* await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
|
|
613
|
+
*
|
|
525
614
|
* // Multimodal message with image
|
|
526
615
|
* await client.sendMessage({
|
|
527
616
|
* content: [
|
|
@@ -543,20 +632,107 @@ class ChatClient {
|
|
|
543
632
|
* )
|
|
544
633
|
* ```
|
|
545
634
|
*/
|
|
546
|
-
async sendMessage(content, body) {
|
|
635
|
+
async sendMessage(content, body, sendOptions) {
|
|
547
636
|
this.mountDevtools();
|
|
548
637
|
const emptyMessage = typeof content === "string" && !content.trim();
|
|
549
|
-
if (emptyMessage
|
|
638
|
+
if (emptyMessage) {
|
|
550
639
|
return;
|
|
551
640
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
641
|
+
if (this.isSendBusy()) {
|
|
642
|
+
const { action, id } = this.decideWhenBusy(content, sendOptions);
|
|
643
|
+
if (action === "drop") {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (action === "queue") {
|
|
647
|
+
this.enqueueMessage(content, body, id);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
this.stopMessageQueueDrain = true;
|
|
651
|
+
this.sendInFlight = true;
|
|
652
|
+
this.cancelInFlightStream({ setReadyStatus: true });
|
|
653
|
+
this.resetSessionGenerating();
|
|
654
|
+
} else {
|
|
655
|
+
this.sendInFlight = true;
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
await this.deliverMessage(content, body);
|
|
659
|
+
} finally {
|
|
660
|
+
this.sendInFlight = false;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/** True while a stream is active, a send is claiming the client, or the queue is draining. */
|
|
664
|
+
isSendBusy() {
|
|
665
|
+
return this.isLoading || this.sendInFlight || this.messageQueueDraining;
|
|
666
|
+
}
|
|
667
|
+
resolveBusyReason() {
|
|
668
|
+
if (this.isLoading) return "streaming";
|
|
669
|
+
if (this.messageQueueDraining) return "draining";
|
|
670
|
+
return "sendInFlight";
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Append a user message and run the stream. Used by both direct sends and
|
|
674
|
+
* queue drains — callers are responsible for busy/queue policy.
|
|
675
|
+
*
|
|
676
|
+
* Claims delivery synchronously before appending so concurrent callers
|
|
677
|
+
* cannot both add a user message when only one stream can run.
|
|
678
|
+
*/
|
|
679
|
+
async deliverMessage(content, body) {
|
|
680
|
+
if (this.isLoading || this.deliverClaim) {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
this.deliverClaim = true;
|
|
684
|
+
try {
|
|
685
|
+
const normalizedContent = this.normalizeMessageInput(content);
|
|
686
|
+
this.pendingMessageBody = body;
|
|
687
|
+
const userMessage = this.processor.addUserMessage(
|
|
688
|
+
normalizedContent.content,
|
|
689
|
+
normalizedContent.id
|
|
690
|
+
);
|
|
691
|
+
this.events.messageSent(userMessage.id, normalizedContent.content);
|
|
692
|
+
return await this.streamResponse();
|
|
693
|
+
} finally {
|
|
694
|
+
this.deliverClaim = false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Resolve the effective action for a send that arrives while busy.
|
|
699
|
+
* The returned `id` is the id that will be stored if the action is `queue`.
|
|
700
|
+
*/
|
|
701
|
+
decideWhenBusy(content, sendOptions) {
|
|
702
|
+
const id = this.generateUniqueId("queued");
|
|
703
|
+
if (sendOptions?.whenBusy) {
|
|
704
|
+
return { action: sendOptions.whenBusy, id };
|
|
705
|
+
}
|
|
706
|
+
const { strategy, whenBusy } = this.queueConfig;
|
|
707
|
+
if (strategy) {
|
|
708
|
+
const { action } = strategy({
|
|
709
|
+
pending: {
|
|
710
|
+
id,
|
|
711
|
+
content,
|
|
712
|
+
createdAt: Date.now()
|
|
713
|
+
},
|
|
714
|
+
busyReason: this.resolveBusyReason(),
|
|
715
|
+
queued: this.getQueue()
|
|
716
|
+
});
|
|
717
|
+
return { action, id };
|
|
718
|
+
}
|
|
719
|
+
return { action: whenBusy, id };
|
|
720
|
+
}
|
|
721
|
+
enqueueMessage(content, body, id) {
|
|
722
|
+
const { maxSize, onOverflow } = this.queueConfig;
|
|
723
|
+
if (maxSize !== void 0 && this.messageQueue.length >= maxSize) {
|
|
724
|
+
if (onOverflow === "reject" || maxSize === 0) {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
this.messageQueue.shift();
|
|
728
|
+
}
|
|
729
|
+
this.messageQueue.push({
|
|
730
|
+
id: id ?? this.generateUniqueId("queued"),
|
|
731
|
+
content,
|
|
732
|
+
createdAt: Date.now(),
|
|
733
|
+
...body !== void 0 ? { body } : {}
|
|
734
|
+
});
|
|
735
|
+
this.emitQueueChange();
|
|
560
736
|
}
|
|
561
737
|
/**
|
|
562
738
|
* Normalize the message input to extract content and optional id.
|
|
@@ -602,6 +778,7 @@ class ChatClient {
|
|
|
602
778
|
const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
603
779
|
this.currentRunId = runId;
|
|
604
780
|
this.setIsLoading(true);
|
|
781
|
+
this.deliverClaim = false;
|
|
605
782
|
this.setStatus("submitted");
|
|
606
783
|
this.setError(void 0);
|
|
607
784
|
this.errorReportedGeneration = null;
|
|
@@ -740,10 +917,18 @@ class ChatClient {
|
|
|
740
917
|
await this.checkForContinuation();
|
|
741
918
|
} catch (error) {
|
|
742
919
|
console.error("Failed to continue flow after tool result:", error);
|
|
920
|
+
await this.drainQueue();
|
|
921
|
+
}
|
|
922
|
+
} else {
|
|
923
|
+
if (this.status !== "ready") {
|
|
924
|
+
this.setStatus("ready");
|
|
925
|
+
}
|
|
926
|
+
if (!this.messageQueueDraining) {
|
|
927
|
+
await this.drainQueue();
|
|
743
928
|
}
|
|
744
|
-
} else if (this.status !== "ready") {
|
|
745
|
-
this.setStatus("ready");
|
|
746
929
|
}
|
|
930
|
+
} else {
|
|
931
|
+
this.flushQueue();
|
|
747
932
|
}
|
|
748
933
|
}
|
|
749
934
|
}
|
|
@@ -774,6 +959,7 @@ class ChatClient {
|
|
|
774
959
|
setReadyStatus: true,
|
|
775
960
|
abortSubscription: true
|
|
776
961
|
});
|
|
962
|
+
this.discardPendingSends();
|
|
777
963
|
this.resetSessionGenerating();
|
|
778
964
|
this.setIsSubscribed(false);
|
|
779
965
|
this.setConnectionStatus("disconnected");
|
|
@@ -791,6 +977,7 @@ class ChatClient {
|
|
|
791
977
|
if (this.isLoading) {
|
|
792
978
|
this.cancelInFlightStream();
|
|
793
979
|
}
|
|
980
|
+
this.discardPendingSends();
|
|
794
981
|
this.events.reloaded(lastUserMessageIndex);
|
|
795
982
|
this.processor.removeMessagesAfter(lastUserMessageIndex);
|
|
796
983
|
this.devtoolsBridge.emitSnapshot();
|
|
@@ -802,6 +989,7 @@ class ChatClient {
|
|
|
802
989
|
stop() {
|
|
803
990
|
const hadLocalStream = this.abortController !== null;
|
|
804
991
|
this.cancelInFlightStream({ setReadyStatus: true });
|
|
992
|
+
this.discardPendingSends();
|
|
805
993
|
if (hadLocalStream) {
|
|
806
994
|
this.resetSessionGenerating();
|
|
807
995
|
}
|
|
@@ -826,6 +1014,7 @@ class ChatClient {
|
|
|
826
1014
|
this.persistor.beginClear();
|
|
827
1015
|
}
|
|
828
1016
|
this.processor.clearMessages();
|
|
1017
|
+
this.discardPendingSends();
|
|
829
1018
|
this.persistor?.remove();
|
|
830
1019
|
this.setError(void 0);
|
|
831
1020
|
this.events.messagesCleared();
|
|
@@ -974,6 +1163,109 @@ class ChatClient {
|
|
|
974
1163
|
getMessages() {
|
|
975
1164
|
return this.processor.getMessages();
|
|
976
1165
|
}
|
|
1166
|
+
/**
|
|
1167
|
+
* True when an interrupt (or another direct send) claimed the client during
|
|
1168
|
+
* a drain. Read via a method so cross-await mutations are not constant-folded
|
|
1169
|
+
* by control-flow analysis.
|
|
1170
|
+
*/
|
|
1171
|
+
shouldAbortMessageQueueDrain() {
|
|
1172
|
+
return this.isLoading || this.stopMessageQueueDrain;
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Deliver queued messages after a successful settle.
|
|
1176
|
+
* - `batch`: merge everything currently queued into one send, looping so
|
|
1177
|
+
* messages enqueued during that batch stream are not stranded.
|
|
1178
|
+
* - `fifo`: walk the queue in a loop, one stream at a time, until empty
|
|
1179
|
+
* (or until another send claims the client via interrupt).
|
|
1180
|
+
*
|
|
1181
|
+
* Uses `deliverMessage` directly so drains do not re-enter `sendMessage`'s
|
|
1182
|
+
* busy/queue policy (which would re-queue items and strand the rest).
|
|
1183
|
+
*/
|
|
1184
|
+
async drainQueue() {
|
|
1185
|
+
if (this.messageQueueDraining || this.isLoading || this.messageQueue.length === 0) {
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
this.messageQueueDraining = true;
|
|
1189
|
+
this.stopMessageQueueDrain = false;
|
|
1190
|
+
try {
|
|
1191
|
+
if (this.queueConfig.drain === "batch") {
|
|
1192
|
+
while (this.messageQueue.length > 0) {
|
|
1193
|
+
if (this.shouldAbortMessageQueueDrain()) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const items = this.messageQueue.splice(0);
|
|
1197
|
+
this.emitQueueChange();
|
|
1198
|
+
const merged = mergeQueuedMessages(items);
|
|
1199
|
+
const completed = await this.deliverMessage(
|
|
1200
|
+
merged.content,
|
|
1201
|
+
merged.body
|
|
1202
|
+
);
|
|
1203
|
+
if (!completed || this.shouldAbortMessageQueueDrain()) {
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
while (this.messageQueue.length > 0) {
|
|
1210
|
+
if (this.shouldAbortMessageQueueDrain()) {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const next = this.messageQueue.shift();
|
|
1214
|
+
if (next === void 0) {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
this.emitQueueChange();
|
|
1218
|
+
const completed = await this.deliverMessage(next.content, next.body);
|
|
1219
|
+
if (!completed || this.shouldAbortMessageQueueDrain()) {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
} finally {
|
|
1224
|
+
this.messageQueueDraining = false;
|
|
1225
|
+
this.stopMessageQueueDrain = false;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Drop any in-flight send claim and discard pending queued messages
|
|
1230
|
+
* (stop / error / clear / unsubscribe / reload).
|
|
1231
|
+
*/
|
|
1232
|
+
discardPendingSends() {
|
|
1233
|
+
this.sendInFlight = false;
|
|
1234
|
+
this.flushQueue();
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Get the current send queue (messages held while a stream was in flight).
|
|
1238
|
+
*/
|
|
1239
|
+
getQueue() {
|
|
1240
|
+
return this.messageQueue.map(({ id, content, createdAt }) => ({
|
|
1241
|
+
id,
|
|
1242
|
+
content,
|
|
1243
|
+
createdAt
|
|
1244
|
+
}));
|
|
1245
|
+
}
|
|
1246
|
+
emitQueueChange() {
|
|
1247
|
+
this.callbacksRef.current.onQueueChange(this.getQueue());
|
|
1248
|
+
this.devtoolsBridge.emitSnapshot();
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Remove a queued message by id before it drains.
|
|
1252
|
+
*/
|
|
1253
|
+
cancelQueued(id) {
|
|
1254
|
+
const index = this.messageQueue.findIndex((m) => m.id === id);
|
|
1255
|
+
if (index === -1) return;
|
|
1256
|
+
this.messageQueue.splice(index, 1);
|
|
1257
|
+
this.emitQueueChange();
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Discard all pending queued messages (stop / error / clear / unsubscribe /
|
|
1261
|
+
* reload). Does not send them. Emits `onQueueChange([])` when anything was
|
|
1262
|
+
* removed.
|
|
1263
|
+
*/
|
|
1264
|
+
flushQueue() {
|
|
1265
|
+
if (this.messageQueue.length === 0) return;
|
|
1266
|
+
this.messageQueue = [];
|
|
1267
|
+
this.emitQueueChange();
|
|
1268
|
+
}
|
|
977
1269
|
/**
|
|
978
1270
|
* Get loading state
|
|
979
1271
|
*/
|
|
@@ -1060,6 +1352,9 @@ class ChatClient {
|
|
|
1060
1352
|
}
|
|
1061
1353
|
this.devtoolsBridge.notifyToolsChanged();
|
|
1062
1354
|
}
|
|
1355
|
+
if (options.queue !== void 0) {
|
|
1356
|
+
this.queueConfig = normalizeQueueOption(options.queue);
|
|
1357
|
+
}
|
|
1063
1358
|
if (options.onResponse !== void 0) {
|
|
1064
1359
|
this.callbacksRef.current.onResponse = options.onResponse;
|
|
1065
1360
|
}
|
|
@@ -1081,6 +1376,9 @@ class ChatClient {
|
|
|
1081
1376
|
if (options.onSessionGeneratingChange !== void 0) {
|
|
1082
1377
|
this.callbacksRef.current.onSessionGeneratingChange = options.onSessionGeneratingChange;
|
|
1083
1378
|
}
|
|
1379
|
+
if (options.onQueueChange !== void 0) {
|
|
1380
|
+
this.callbacksRef.current.onQueueChange = options.onQueueChange;
|
|
1381
|
+
}
|
|
1084
1382
|
if (options.onCustomEvent !== void 0) {
|
|
1085
1383
|
this.callbacksRef.current.onCustomEvent = options.onCustomEvent;
|
|
1086
1384
|
}
|
|
@@ -1092,6 +1390,7 @@ class ChatClient {
|
|
|
1092
1390
|
}
|
|
1093
1391
|
}
|
|
1094
1392
|
export {
|
|
1095
|
-
ChatClient
|
|
1393
|
+
ChatClient,
|
|
1394
|
+
normalizeQueueOption
|
|
1096
1395
|
};
|
|
1097
1396
|
//# sourceMappingURL=chat-client.js.map
|