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