@devicai/ui 0.50.0 → 0.52.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/README.md +68 -1
- package/dist/cjs/api/client.js +15 -0
- package/dist/cjs/api/client.js.map +1 -1
- package/dist/cjs/api/types.js.map +1 -1
- package/dist/cjs/components/ChatDrawer/ChatDrawer.js +138 -11
- package/dist/cjs/components/ChatDrawer/ChatDrawer.js.map +1 -1
- package/dist/cjs/components/ChatDrawer/ChatInput.js +69 -13
- package/dist/cjs/components/ChatDrawer/ChatInput.js.map +1 -1
- package/dist/cjs/components/ChatDrawer/ChatMessages.js +6 -2
- package/dist/cjs/components/ChatDrawer/ChatMessages.js.map +1 -1
- package/dist/cjs/components/ChatDrawer/QueueNotice.js +46 -0
- package/dist/cjs/components/ChatDrawer/QueueNotice.js.map +1 -0
- package/dist/cjs/components/IntegrationsModal/integrationChoice.js +59 -0
- package/dist/cjs/components/IntegrationsModal/integrationChoice.js.map +1 -0
- package/dist/cjs/hooks/useDevicChat.js +277 -32
- package/dist/cjs/hooks/useDevicChat.js.map +1 -1
- package/dist/cjs/hooks/usePolling.js +5 -2
- package/dist/cjs/hooks/usePolling.js.map +1 -1
- package/dist/cjs/index.js +7 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/styles.css +1 -1
- package/dist/esm/api/client.d.ts +2 -5
- package/dist/esm/api/client.js +15 -0
- package/dist/esm/api/client.js.map +1 -1
- package/dist/esm/api/types.d.ts +69 -1
- package/dist/esm/api/types.js.map +1 -1
- package/dist/esm/components/ChatDrawer/ChatDrawer.js +139 -12
- package/dist/esm/components/ChatDrawer/ChatDrawer.js.map +1 -1
- package/dist/esm/components/ChatDrawer/ChatDrawer.types.d.ts +56 -4
- package/dist/esm/components/ChatDrawer/ChatInput.js +69 -13
- package/dist/esm/components/ChatDrawer/ChatInput.js.map +1 -1
- package/dist/esm/components/ChatDrawer/ChatMessages.js +6 -2
- package/dist/esm/components/ChatDrawer/ChatMessages.js.map +1 -1
- package/dist/esm/components/ChatDrawer/QueueNotice.d.ts +23 -0
- package/dist/esm/components/ChatDrawer/QueueNotice.js +44 -0
- package/dist/esm/components/ChatDrawer/QueueNotice.js.map +1 -0
- package/dist/esm/components/ChatDrawer/index.d.ts +2 -0
- package/dist/esm/components/IntegrationsModal/index.d.ts +1 -0
- package/dist/esm/components/IntegrationsModal/integrationChoice.d.ts +16 -0
- package/dist/esm/components/IntegrationsModal/integrationChoice.js +54 -0
- package/dist/esm/components/IntegrationsModal/integrationChoice.js.map +1 -0
- package/dist/esm/hooks/index.d.ts +1 -1
- package/dist/esm/hooks/useDevicChat.d.ts +65 -5
- package/dist/esm/hooks/useDevicChat.js +277 -32
- package/dist/esm/hooks/useDevicChat.js.map +1 -1
- package/dist/esm/hooks/usePolling.d.ts +12 -0
- package/dist/esm/hooks/usePolling.js +5 -2
- package/dist/esm/hooks/usePolling.js.map +1 -1
- package/dist/esm/index.d.ts +5 -5
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/styles.css +1 -1
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ var client = require('../api/client.js');
|
|
|
7
7
|
var logger = require('../utils/logger.js');
|
|
8
8
|
var usePolling = require('./usePolling.js');
|
|
9
9
|
var useModelInterface = require('./useModelInterface.js');
|
|
10
|
+
var assistantInfo = require('../api/assistantInfo.js');
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Cadence for the handoff watch, which only waits for the parent thread to
|
|
@@ -14,6 +15,17 @@ var useModelInterface = require('./useModelInterface.js');
|
|
|
14
15
|
* A configured `pollingInterval` still wins over it.
|
|
15
16
|
*/
|
|
16
17
|
const DEFAULT_HANDOFF_POLL_INTERVAL_MS = 5000;
|
|
18
|
+
/**
|
|
19
|
+
* How many polls the conversation may read as finished while it still owes an
|
|
20
|
+
* answer to something queued, before the wait is given up.
|
|
21
|
+
*
|
|
22
|
+
* The window is real: a run liquidates, and the follow-up run that drains the
|
|
23
|
+
* queue has not marked itself as processing yet. Bounded so a message that
|
|
24
|
+
* never comes back cannot poll forever.
|
|
25
|
+
*/
|
|
26
|
+
const QUEUE_HANDOVER_GRACE_TICKS = 60;
|
|
27
|
+
/** Messages are matched to their optimistic copies by text, so it is normalized. */
|
|
28
|
+
const normalizeText = (text) => (text ?? '').trim();
|
|
17
29
|
/**
|
|
18
30
|
* Main hook for managing chat with a Devic assistant
|
|
19
31
|
*
|
|
@@ -37,7 +49,7 @@ const DEFAULT_HANDOFF_POLL_INTERVAL_MS = 5000;
|
|
|
37
49
|
* ```
|
|
38
50
|
*/
|
|
39
51
|
function useDevicChat(options) {
|
|
40
|
-
const { assistantId, chatUid: initialChatUid, apiKey: propsApiKey, baseUrl: propsBaseUrl, tenantId, tenantMetadata, subtenantId, subtenantMetadata, tags, enabledTools, disabledIntegrations, modelInterfaceTools = [], pollingInterval: propsPollingInterval, onMessageSent, onMessageReceived, onToolCall, onError, onChatCreated, onFileUpload, debug: propsDebug, } = options;
|
|
52
|
+
const { assistantId, chatUid: initialChatUid, apiKey: propsApiKey, baseUrl: propsBaseUrl, tenantId, tenantMetadata, subtenantId, subtenantMetadata, tags, enabledTools, disabledIntegrations, modelInterfaceTools = [], pollingInterval: propsPollingInterval, onMessageSent, onMessageReceived, onToolCall, onError, onChatCreated, onFileUpload, messageQueue, debug: propsDebug, } = options;
|
|
41
53
|
// Get context (may be null if not wrapped in provider)
|
|
42
54
|
const context = DevicContext.useOptionalDevicContext();
|
|
43
55
|
// Resolve configuration
|
|
@@ -89,6 +101,13 @@ function useDevicChat(options) {
|
|
|
89
101
|
// Keep a ref to chatUid so async callbacks always read the latest value
|
|
90
102
|
const chatUidRef = React.useRef(chatUid);
|
|
91
103
|
chatUidRef.current = chatUid;
|
|
104
|
+
// Read by `sendMessage`, which must not restate the state of a run already in
|
|
105
|
+
// flight — its own callback identity would otherwise carry a stale value.
|
|
106
|
+
const isLoadingRef = React.useRef(isLoading);
|
|
107
|
+
isLoadingRef.current = isLoading;
|
|
108
|
+
// Two messages sent inside the same millisecond would otherwise share a uid,
|
|
109
|
+
// which the queue makes easy to do.
|
|
110
|
+
const optimisticSeqRef = React.useRef(0);
|
|
92
111
|
// Refs for callbacks
|
|
93
112
|
const onMessageReceivedRef = React.useRef(onMessageReceived);
|
|
94
113
|
const onErrorRef = React.useRef(onError);
|
|
@@ -109,6 +128,55 @@ function useDevicChat(options) {
|
|
|
109
128
|
clientRef.current.setConfig({ apiKey, baseUrl });
|
|
110
129
|
}
|
|
111
130
|
}, [apiKey, baseUrl]);
|
|
131
|
+
// --- Message queue --------------------------------------------------------
|
|
132
|
+
/**
|
|
133
|
+
* Messages accepted by this conversation that the model has not seen yet.
|
|
134
|
+
* Taken from the poll, so it counts whatever else queued on the conversation
|
|
135
|
+
* too — another tab, or the same person writing from another channel.
|
|
136
|
+
*/
|
|
137
|
+
const [queuedCount, setQueuedCount] = React.useState(0);
|
|
138
|
+
/**
|
|
139
|
+
* When something was queued from here that has not been answered yet.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately not a list of the texts sent: a drain can merge several queued
|
|
142
|
+
* messages into a single user turn, so what comes back is not what went out
|
|
143
|
+
* and matching them by text would wait forever. What is being waited for is an
|
|
144
|
+
* answer, and an assistant message written after the message was accepted is
|
|
145
|
+
* that answer.
|
|
146
|
+
*/
|
|
147
|
+
const awaitingAnswerSinceRef = React.useRef(null);
|
|
148
|
+
const queueGraceTicksRef = React.useRef(0);
|
|
149
|
+
const rememberAwaiting = React.useCallback(() => {
|
|
150
|
+
awaitingAnswerSinceRef.current = Date.now();
|
|
151
|
+
}, []);
|
|
152
|
+
const resetQueueState = React.useCallback(() => {
|
|
153
|
+
awaitingAnswerSinceRef.current = null;
|
|
154
|
+
queueGraceTicksRef.current = 0;
|
|
155
|
+
setQueuedCount(0);
|
|
156
|
+
}, []);
|
|
157
|
+
/**
|
|
158
|
+
* Asked for as soon as the hook is alive, rather than when the first run
|
|
159
|
+
* starts: the answer decides whether the input stays open while the assistant
|
|
160
|
+
* works, and resolving it late means the box visibly closes and reopens on the
|
|
161
|
+
* first message of every session. One request, shared with everything else
|
|
162
|
+
* that asks about this assistant. Passing `messageQueue` skips it entirely.
|
|
163
|
+
*/
|
|
164
|
+
const queueLookup = assistantInfo.useAssistantInfo({
|
|
165
|
+
assistantId,
|
|
166
|
+
client: clientRef.current,
|
|
167
|
+
baseUrl,
|
|
168
|
+
credential: apiKey || 'session',
|
|
169
|
+
enabled: messageQueue === undefined,
|
|
170
|
+
});
|
|
171
|
+
/**
|
|
172
|
+
* Absent means no. An assistant that has not answered yet, or an API too old
|
|
173
|
+
* to carry the field, leaves the input closed while it works — the same thing
|
|
174
|
+
* it did before this existed. Opening it on a maybe would promise a queue the
|
|
175
|
+
* conversation then refuses.
|
|
176
|
+
*/
|
|
177
|
+
const queueEnabled = messageQueue ??
|
|
178
|
+
(queueLookup.settled &&
|
|
179
|
+
queueLookup.assistant?.messageQueueEnabled === true);
|
|
112
180
|
// Resume chat state based on realtime status.
|
|
113
181
|
// Called after loading chat history to detect in-progress conversations.
|
|
114
182
|
const resumeFromRealtimeStatus = React.useCallback(async (targetChatUid) => {
|
|
@@ -118,11 +186,16 @@ function useDevicChat(options) {
|
|
|
118
186
|
const realtime = await clientRef.current.getRealtimeHistory(assistantId, targetChatUid);
|
|
119
187
|
logRef.current.log('[useDevicChat] resumeFromRealtimeStatus:', realtime.status);
|
|
120
188
|
// Update messages with realtime data (may be fresher than static history)
|
|
121
|
-
|
|
122
|
-
|
|
189
|
+
const queuedOnServer = (realtime.pendingUserMessages ?? []).map((m) => ({
|
|
190
|
+
...m,
|
|
191
|
+
queued: true,
|
|
192
|
+
}));
|
|
193
|
+
if (realtime.chatHistory?.length || queuedOnServer.length) {
|
|
194
|
+
setMessages([...(realtime.chatHistory ?? []), ...queuedOnServer]);
|
|
123
195
|
}
|
|
124
196
|
mergeRecalledMemories(realtime.recalledMemories);
|
|
125
197
|
setStatus(realtime.status);
|
|
198
|
+
setQueuedCount(realtime.queuedMessages ?? 0);
|
|
126
199
|
if (realtime.status === 'processing') {
|
|
127
200
|
// Chat is still processing — resume polling
|
|
128
201
|
setIsLoading(true);
|
|
@@ -143,6 +216,13 @@ function useDevicChat(options) {
|
|
|
143
216
|
setHandedOffSubThreadId(subThreadId);
|
|
144
217
|
}
|
|
145
218
|
}
|
|
219
|
+
else if ((realtime.queuedMessages ?? 0) > 0) {
|
|
220
|
+
// The run settled, but the conversation still owes an answer to
|
|
221
|
+
// something queued — reopened on a conversation whose follow-up run
|
|
222
|
+
// has not started yet. Watch it until the queue is served.
|
|
223
|
+
setIsLoading(true);
|
|
224
|
+
setShouldPoll(true);
|
|
225
|
+
}
|
|
146
226
|
else {
|
|
147
227
|
// completed or error — just stop
|
|
148
228
|
setIsLoading(false);
|
|
@@ -214,30 +294,84 @@ function useDevicChat(options) {
|
|
|
214
294
|
],
|
|
215
295
|
onUpdate: async (data) => {
|
|
216
296
|
logRef.current.log('[useDevicChat] onUpdate called, status:', data.status);
|
|
297
|
+
// An assistant message written after something was queued from here is
|
|
298
|
+
// the answer that was being waited for.
|
|
299
|
+
const awaitingSince = awaitingAnswerSinceRef.current;
|
|
300
|
+
if (awaitingSince !== null &&
|
|
301
|
+
(data.queuedMessages ?? 0) === 0 &&
|
|
302
|
+
(data.chatHistory ?? []).some((m) => m.role === 'assistant' && (m.timestamp ?? 0) > awaitingSince)) {
|
|
303
|
+
awaitingAnswerSinceRef.current = null;
|
|
304
|
+
}
|
|
217
305
|
// Merge realtime data with optimistic messages.
|
|
218
306
|
// When a server user message matches an optimistic one by text, adopt the
|
|
219
307
|
// optimistic uid so React's key stays stable (avoids unmount/remount flicker).
|
|
220
308
|
setMessages((prev) => {
|
|
221
|
-
|
|
222
|
-
|
|
309
|
+
// A queue of uids per text rather than one: two identical messages are
|
|
310
|
+
// two messages, and pairing both with the same optimistic copy would
|
|
311
|
+
// drop one of them from the conversation.
|
|
312
|
+
const optimisticUserByText = new Map();
|
|
313
|
+
prev
|
|
223
314
|
.filter((m) => m.role === 'user' && m.uid.startsWith('temp-'))
|
|
224
|
-
.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// Keep the server uid around: recall anchors reference it.
|
|
232
|
-
return { ...m, uid: tempUid, serverUid: m.uid };
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return m;
|
|
315
|
+
.forEach((m) => {
|
|
316
|
+
const key = normalizeText(m.content?.message);
|
|
317
|
+
const bucket = optimisticUserByText.get(key);
|
|
318
|
+
if (bucket)
|
|
319
|
+
bucket.push(m.uid);
|
|
320
|
+
else
|
|
321
|
+
optimisticUserByText.set(key, [m.uid]);
|
|
236
322
|
});
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
323
|
+
const adoptedTempUids = new Set();
|
|
324
|
+
const adopt = (m) => {
|
|
325
|
+
if (m.role !== 'user')
|
|
326
|
+
return m;
|
|
327
|
+
const tempUid = optimisticUserByText
|
|
328
|
+
.get(normalizeText(m.content?.message))
|
|
329
|
+
?.shift();
|
|
330
|
+
if (!tempUid)
|
|
331
|
+
return m;
|
|
332
|
+
adoptedTempUids.add(tempUid);
|
|
333
|
+
// Keep the server uid around: recall anchors reference it.
|
|
334
|
+
return { ...m, uid: tempUid, serverUid: m.uid };
|
|
335
|
+
};
|
|
336
|
+
const merged = data.chatHistory.map(adopt);
|
|
337
|
+
// Accepted, but not part of the conversation yet. Drawn between the
|
|
338
|
+
// history and the optimistic ones, which is where they land once a
|
|
339
|
+
// turn takes them. The copy is the server's, so it survives a reload —
|
|
340
|
+
// where the API does not return them, this is empty and the optimistic
|
|
341
|
+
// ones kept below stand in.
|
|
342
|
+
const queued = (data.pendingUserMessages ?? []).map((m) => ({
|
|
343
|
+
...adopt(m),
|
|
344
|
+
queued: true,
|
|
345
|
+
}));
|
|
346
|
+
// Which of this client's own queued bubbles to keep drawing.
|
|
347
|
+
//
|
|
348
|
+
// Matching them to the history by text does not work: a drain can
|
|
349
|
+
// merge several queued messages into one user turn, and an optimistic
|
|
350
|
+
// copy that never finds its pair would sit there marked as queued for
|
|
351
|
+
// the rest of the conversation. The server's queue is the authority,
|
|
352
|
+
// and these are kept only where it cannot speak for them:
|
|
353
|
+
// - too recently accepted for this poll to have seen them;
|
|
354
|
+
// - counted by `queuedMessages` but not itemised, which is what an
|
|
355
|
+
// API without `pendingUserMessages` reports.
|
|
356
|
+
const optimisticQueued = prev.filter((m) => m.queued && m.uid.startsWith('temp-'));
|
|
357
|
+
const tooRecent = Date.now() - pollingInterval;
|
|
358
|
+
const keptQueued = new Set(optimisticQueued
|
|
359
|
+
.filter((m) => (m.queuedAt ?? 0) > tooRecent)
|
|
360
|
+
.map((m) => m.uid));
|
|
361
|
+
let unitemised = (data.queuedMessages ?? queued.length) - queued.length - keptQueued.size;
|
|
362
|
+
for (let i = optimisticQueued.length - 1; i >= 0 && unitemised > 0; i--) {
|
|
363
|
+
if (keptQueued.has(optimisticQueued[i].uid))
|
|
364
|
+
continue;
|
|
365
|
+
keptQueued.add(optimisticQueued[i].uid);
|
|
366
|
+
unitemised -= 1;
|
|
367
|
+
}
|
|
368
|
+
const mergedUIDs = new Set([...merged, ...queued].map((m) => m.uid));
|
|
369
|
+
const optimistic = prev.filter((m) => !mergedUIDs.has(m.uid) &&
|
|
370
|
+
!adoptedTempUids.has(m.uid) &&
|
|
371
|
+
(!m.queued || keptQueued.has(m.uid)));
|
|
372
|
+
return [...merged, ...queued, ...optimistic];
|
|
240
373
|
});
|
|
374
|
+
setQueuedCount(data.queuedMessages ?? 0);
|
|
241
375
|
// Surface recall events while the run is still processing, so the
|
|
242
376
|
// "recalled memories" strip shows before the first response lands.
|
|
243
377
|
mergeRecalledMemories(data.recalledMemories);
|
|
@@ -252,9 +386,28 @@ function useDevicChat(options) {
|
|
|
252
386
|
await handlePendingToolCalls(data);
|
|
253
387
|
}
|
|
254
388
|
},
|
|
389
|
+
holdOpen: (data) => {
|
|
390
|
+
// Only `completed` is worth waiting on. An error, a usage limit or a
|
|
391
|
+
// gate mean something else is going on, and holding the poll open would
|
|
392
|
+
// just be watching a conversation that is not coming back.
|
|
393
|
+
const owed = (data.queuedMessages ?? 0) > 0 ||
|
|
394
|
+
awaitingAnswerSinceRef.current !== null;
|
|
395
|
+
if (!owed || data.status !== 'completed') {
|
|
396
|
+
queueGraceTicksRef.current = 0;
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
queueGraceTicksRef.current += 1;
|
|
400
|
+
if (queueGraceTicksRef.current <= QUEUE_HANDOVER_GRACE_TICKS)
|
|
401
|
+
return true;
|
|
402
|
+
// Nothing came back in time: stop pretending it will.
|
|
403
|
+
logRef.current.warn('[useDevicChat] queue handover window expired, stopping the watch');
|
|
404
|
+
awaitingAnswerSinceRef.current = null;
|
|
405
|
+
return false;
|
|
406
|
+
},
|
|
255
407
|
onStop: (data) => {
|
|
256
408
|
logRef.current.log('[useDevicChat] onStop called, status:', data?.status);
|
|
257
409
|
setShouldPoll(false);
|
|
410
|
+
queueGraceTicksRef.current = 0;
|
|
258
411
|
if (data?.status === 'limit_exceeded') {
|
|
259
412
|
// The message was blocked by a tenant/subtenant usage limit before
|
|
260
413
|
// reaching the LLM. Surface the details so the UI can show a banner.
|
|
@@ -345,15 +498,23 @@ function useDevicChat(options) {
|
|
|
345
498
|
const err = new Error('API client not configured. Please provide an API key.');
|
|
346
499
|
setError(err);
|
|
347
500
|
onErrorRef.current?.(err);
|
|
348
|
-
return;
|
|
501
|
+
return { rejected: true, reason: 'error', message: err.message, restoredText: message };
|
|
502
|
+
}
|
|
503
|
+
// Whether something was already running when this was written. If it was,
|
|
504
|
+
// this send must not restate it: turning the indicator on and off around a
|
|
505
|
+
// message that merely joined a queue would report on a run it has nothing
|
|
506
|
+
// to do with — and a refusal would then stop an indicator for a run that
|
|
507
|
+
// is still perfectly alive.
|
|
508
|
+
const wasBusy = isLoadingRef.current;
|
|
509
|
+
if (!wasBusy) {
|
|
510
|
+
setIsLoading(true);
|
|
511
|
+
setStatus('processing');
|
|
349
512
|
}
|
|
350
|
-
setIsLoading(true);
|
|
351
513
|
setError(null);
|
|
352
514
|
setLimitExceeded(null);
|
|
353
|
-
setStatus('processing');
|
|
354
515
|
// Add user message optimistically (show file names before upload)
|
|
355
516
|
const userMessage = {
|
|
356
|
-
uid: `temp-${Date.now()}`,
|
|
517
|
+
uid: `temp-${Date.now()}-${optimisticSeqRef.current++}`,
|
|
357
518
|
role: 'user',
|
|
358
519
|
content: {
|
|
359
520
|
message,
|
|
@@ -451,9 +612,53 @@ function useDevicChat(options) {
|
|
|
451
612
|
// Start polling for results
|
|
452
613
|
logRef.current.log('[useDevicChat] Setting shouldPoll to true');
|
|
453
614
|
setShouldPoll(true);
|
|
615
|
+
if (response.queued) {
|
|
616
|
+
// Accepted, but not on its way to the model yet. Draw it as such, and
|
|
617
|
+
// remember it: the poll has to keep running until it comes back inside
|
|
618
|
+
// the conversation, however long the run in flight takes.
|
|
619
|
+
rememberAwaiting();
|
|
620
|
+
const queuedAt = Date.now();
|
|
621
|
+
setMessages((prev) => prev.map((m) => m.uid === userMessage.uid ? { ...m, queued: true, queuedAt } : m));
|
|
622
|
+
setQueuedCount(response.queuePosition || 1);
|
|
623
|
+
return {
|
|
624
|
+
queued: true,
|
|
625
|
+
queuePosition: response.queuePosition || 1,
|
|
626
|
+
willProcess: response.willProcess || 'next_turn',
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
return { queued: false };
|
|
454
630
|
}
|
|
455
631
|
catch (err) {
|
|
456
632
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
633
|
+
// Nothing was accepted either way, so the bubble goes.
|
|
634
|
+
setMessages((prev) => prev.filter((m) => m.uid !== userMessage.uid));
|
|
635
|
+
// The conversation turning a message down is not the conversation
|
|
636
|
+
// breaking. Two shapes of the same 409: an assistant that does not queue
|
|
637
|
+
// says so by name, and a full queue comes back as a plain conflict.
|
|
638
|
+
const refusal = err instanceof client.DevicApiError
|
|
639
|
+
? err.errorType === 'CHAT_BUSY'
|
|
640
|
+
? 'chat_busy'
|
|
641
|
+
: err.statusCode === 409
|
|
642
|
+
? 'queue_full'
|
|
643
|
+
: null
|
|
644
|
+
: null;
|
|
645
|
+
if (refusal) {
|
|
646
|
+
logRef.current.log('[useDevicChat] send refused:', refusal, error.message);
|
|
647
|
+
// Deliberately leaves `isLoading`, `status` and `error` alone: the run
|
|
648
|
+
// this message was written into is still going, and reporting the
|
|
649
|
+
// refusal by stopping its indicator — or by painting the conversation
|
|
650
|
+
// as failed — would be a lie about the run, not about the send.
|
|
651
|
+
if (!wasBusy) {
|
|
652
|
+
setIsLoading(false);
|
|
653
|
+
setStatus('idle');
|
|
654
|
+
}
|
|
655
|
+
return {
|
|
656
|
+
rejected: true,
|
|
657
|
+
reason: refusal,
|
|
658
|
+
message: error.message,
|
|
659
|
+
restoredText: message,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
457
662
|
// A synchronous usage-limit block surfaces as HTTP 429 /
|
|
458
663
|
// TENANT_LIMIT_EXCEEDED (sync send path). Async sends surface it via the
|
|
459
664
|
// realtime `limit_exceeded` status instead — both are handled.
|
|
@@ -465,11 +670,20 @@ function useDevicChat(options) {
|
|
|
465
670
|
setLimitExceeded(details);
|
|
466
671
|
}
|
|
467
672
|
setError(error);
|
|
468
|
-
|
|
469
|
-
|
|
673
|
+
// Same reasoning as the refusal above: a send that failed while a run
|
|
674
|
+
// was already going says nothing about that run, which the poll is still
|
|
675
|
+
// watching. The error is reported either way.
|
|
676
|
+
if (!wasBusy) {
|
|
677
|
+
setIsLoading(false);
|
|
678
|
+
setStatus('error');
|
|
679
|
+
}
|
|
470
680
|
onErrorRef.current?.(error);
|
|
471
|
-
|
|
472
|
-
|
|
681
|
+
return {
|
|
682
|
+
rejected: true,
|
|
683
|
+
reason: 'error',
|
|
684
|
+
message: error.message,
|
|
685
|
+
restoredText: message,
|
|
686
|
+
};
|
|
473
687
|
}
|
|
474
688
|
}, [
|
|
475
689
|
chatUid,
|
|
@@ -487,6 +701,7 @@ function useDevicChat(options) {
|
|
|
487
701
|
toolSchemas,
|
|
488
702
|
onMessageSent,
|
|
489
703
|
onFileUpload,
|
|
704
|
+
rememberAwaiting,
|
|
490
705
|
]);
|
|
491
706
|
// Clear chat
|
|
492
707
|
const clearChat = React.useCallback(() => {
|
|
@@ -504,9 +719,10 @@ function useDevicChat(options) {
|
|
|
504
719
|
setError(null);
|
|
505
720
|
setLimitExceeded(null);
|
|
506
721
|
setRecalledMemories([]);
|
|
722
|
+
resetQueueState();
|
|
507
723
|
pendingWidgetCallsRef.current = [];
|
|
508
724
|
setPendingWidgetCalls([]);
|
|
509
|
-
}, []);
|
|
725
|
+
}, [resetQueueState]);
|
|
510
726
|
// Load existing chat
|
|
511
727
|
const loadChat = React.useCallback(async (loadChatUid) => {
|
|
512
728
|
if (!clientRef.current) {
|
|
@@ -528,6 +744,8 @@ function useDevicChat(options) {
|
|
|
528
744
|
setIsLoading(true);
|
|
529
745
|
setError(null);
|
|
530
746
|
setRecalledMemories([]);
|
|
747
|
+
// The queue belongs to the conversation being left behind.
|
|
748
|
+
resetQueueState();
|
|
531
749
|
try {
|
|
532
750
|
const history = await clientRef.current.getChatHistory(assistantId, loadChatUid, { tenantId: resolvedTenantId });
|
|
533
751
|
setMessages(history.chatContent);
|
|
@@ -542,7 +760,13 @@ function useDevicChat(options) {
|
|
|
542
760
|
onErrorRef.current?.(error);
|
|
543
761
|
setIsLoading(false);
|
|
544
762
|
}
|
|
545
|
-
}, [
|
|
763
|
+
}, [
|
|
764
|
+
assistantId,
|
|
765
|
+
resolvedTenantId,
|
|
766
|
+
resumeFromRealtimeStatus,
|
|
767
|
+
mergeRecalledMemories,
|
|
768
|
+
resetQueueState,
|
|
769
|
+
]);
|
|
546
770
|
// Handoff polling: while handedOff is true, poll the realtime endpoint every
|
|
547
771
|
// 5s (or the configured cadence) to detect when the parent thread is no
|
|
548
772
|
// longer in handed_off state.
|
|
@@ -629,19 +853,38 @@ function useDevicChat(options) {
|
|
|
629
853
|
const stopChat = React.useCallback(async () => {
|
|
630
854
|
const uid = chatUidRef.current;
|
|
631
855
|
logRef.current.log('[useDevicChat] stopChat called, chatUid:', uid);
|
|
856
|
+
let discarded = [];
|
|
632
857
|
if (clientRef.current && uid) {
|
|
633
858
|
try {
|
|
634
|
-
await clientRef.current.stopChat(assistantId, uid);
|
|
859
|
+
const result = await clientRef.current.stopChat(assistantId, uid);
|
|
860
|
+
discarded = result?.discardedMessages ?? [];
|
|
635
861
|
logRef.current.log('[useDevicChat] stopChat API call succeeded');
|
|
636
862
|
}
|
|
637
863
|
catch (err) {
|
|
638
864
|
logRef.current.warn('[useDevicChat] stopChat API call failed:', err);
|
|
639
865
|
}
|
|
640
866
|
}
|
|
867
|
+
// Stopping throws away whatever was queued behind the run — answering it
|
|
868
|
+
// would be the opposite of what was just asked for. The bubbles go with it,
|
|
869
|
+
// and the text is handed back so it can return to the box instead of
|
|
870
|
+
// disappearing. An API that does not report what it discarded leaves the
|
|
871
|
+
// bubbles alone, and the next poll has the last word.
|
|
872
|
+
if (discarded.length) {
|
|
873
|
+
setMessages((prev) => prev.filter((m) => !m.queued));
|
|
874
|
+
}
|
|
875
|
+
resetQueueState();
|
|
641
876
|
setShouldPoll(false);
|
|
642
877
|
setIsLoading(false);
|
|
643
878
|
setStatus('idle');
|
|
644
|
-
|
|
879
|
+
const restoredText = discarded
|
|
880
|
+
.map((m) => m.content?.message)
|
|
881
|
+
.filter(Boolean)
|
|
882
|
+
.join('\n');
|
|
883
|
+
return {
|
|
884
|
+
discarded: discarded.length,
|
|
885
|
+
...(restoredText ? { restoredText } : {}),
|
|
886
|
+
};
|
|
887
|
+
}, [assistantId, resetQueueState]);
|
|
645
888
|
return {
|
|
646
889
|
messages,
|
|
647
890
|
chatUid,
|
|
@@ -652,6 +895,8 @@ function useDevicChat(options) {
|
|
|
652
895
|
recalledMemories,
|
|
653
896
|
handedOff,
|
|
654
897
|
handedOffSubThreadId,
|
|
898
|
+
queuedCount,
|
|
899
|
+
queueEnabled,
|
|
655
900
|
sendMessage,
|
|
656
901
|
clearChat,
|
|
657
902
|
loadChat,
|