@devicai/ui 0.59.0 → 0.61.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.
Files changed (63) hide show
  1. package/README.md +10 -0
  2. package/dist/cjs/api/client.js +41 -6
  3. package/dist/cjs/api/client.js.map +1 -1
  4. package/dist/cjs/api/types.js.map +1 -1
  5. package/dist/cjs/components/ChatDrawer/ChatDrawer.js +9 -2
  6. package/dist/cjs/components/ChatDrawer/ChatDrawer.js.map +1 -1
  7. package/dist/cjs/components/ChatDrawer/ChatMessages.js +2 -1
  8. package/dist/cjs/components/ChatDrawer/ChatMessages.js.map +1 -1
  9. package/dist/cjs/components/ChatDrawer/LiveVoicePanel.js +106 -0
  10. package/dist/cjs/components/ChatDrawer/LiveVoicePanel.js.map +1 -0
  11. package/dist/cjs/components/ChatDrawer/LiveVoicePrompter.js +148 -0
  12. package/dist/cjs/components/ChatDrawer/LiveVoicePrompter.js.map +1 -0
  13. package/dist/cjs/hooks/useDevicChat.js +68 -17
  14. package/dist/cjs/hooks/useDevicChat.js.map +1 -1
  15. package/dist/cjs/hooks/useDevicLiveVoice.js +73 -0
  16. package/dist/cjs/hooks/useDevicLiveVoice.js.map +1 -0
  17. package/dist/cjs/hooks/usePolling.js +1 -1
  18. package/dist/cjs/hooks/usePolling.js.map +1 -1
  19. package/dist/cjs/index.js +4 -0
  20. package/dist/cjs/index.js.map +1 -1
  21. package/dist/cjs/styles.css +1 -1
  22. package/dist/cjs/utils/consumeChatStream.js +36 -6
  23. package/dist/cjs/utils/consumeChatStream.js.map +1 -1
  24. package/dist/cjs/voice/LiveVoiceController.js +287 -0
  25. package/dist/cjs/voice/LiveVoiceController.js.map +1 -0
  26. package/dist/esm/api/client.d.ts +12 -3
  27. package/dist/esm/api/client.js +41 -6
  28. package/dist/esm/api/client.js.map +1 -1
  29. package/dist/esm/api/liveVoice.types.d.ts +49 -0
  30. package/dist/esm/api/types.d.ts +3 -0
  31. package/dist/esm/api/types.js.map +1 -1
  32. package/dist/esm/components/ChatDrawer/ChatDrawer.js +11 -4
  33. package/dist/esm/components/ChatDrawer/ChatDrawer.js.map +1 -1
  34. package/dist/esm/components/ChatDrawer/ChatDrawer.types.d.ts +6 -0
  35. package/dist/esm/components/ChatDrawer/ChatMessages.js +2 -1
  36. package/dist/esm/components/ChatDrawer/ChatMessages.js.map +1 -1
  37. package/dist/esm/components/ChatDrawer/LiveVoicePanel.d.ts +21 -0
  38. package/dist/esm/components/ChatDrawer/LiveVoicePanel.js +102 -0
  39. package/dist/esm/components/ChatDrawer/LiveVoicePanel.js.map +1 -0
  40. package/dist/esm/components/ChatDrawer/LiveVoicePrompter.d.ts +12 -0
  41. package/dist/esm/components/ChatDrawer/LiveVoicePrompter.js +145 -0
  42. package/dist/esm/components/ChatDrawer/LiveVoicePrompter.js.map +1 -0
  43. package/dist/esm/hooks/index.d.ts +2 -0
  44. package/dist/esm/hooks/useDevicChat.d.ts +6 -0
  45. package/dist/esm/hooks/useDevicChat.js +68 -17
  46. package/dist/esm/hooks/useDevicChat.js.map +1 -1
  47. package/dist/esm/hooks/useDevicLiveVoice.d.ts +20 -0
  48. package/dist/esm/hooks/useDevicLiveVoice.js +71 -0
  49. package/dist/esm/hooks/useDevicLiveVoice.js.map +1 -0
  50. package/dist/esm/hooks/usePolling.d.ts +1 -1
  51. package/dist/esm/hooks/usePolling.js +1 -1
  52. package/dist/esm/hooks/usePolling.js.map +1 -1
  53. package/dist/esm/index.d.ts +6 -0
  54. package/dist/esm/index.js +2 -0
  55. package/dist/esm/index.js.map +1 -1
  56. package/dist/esm/styles.css +1 -1
  57. package/dist/esm/utils/consumeChatStream.d.ts +8 -4
  58. package/dist/esm/utils/consumeChatStream.js +36 -6
  59. package/dist/esm/utils/consumeChatStream.js.map +1 -1
  60. package/dist/esm/voice/LiveVoiceController.d.ts +38 -0
  61. package/dist/esm/voice/LiveVoiceController.js +284 -0
  62. package/dist/esm/voice/LiveVoiceController.js.map +1 -0
  63. package/package.json +1 -1
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ var jsxRuntime = require('react/jsx-runtime');
4
+ var React = require('react');
5
+ var useTranslations = require('../../i18n/useTranslations.js');
6
+
7
+ /** Level readings kept per wave: one bar each, spread over the whole width. */
8
+ const WAVE_HISTORY = 160;
9
+ /** A new reading this often, so 160 bars are eight seconds of conversation. */
10
+ const WAVE_TICK_MS = 50;
11
+ /** Silence draws a dotted baseline rather than nothing, so the row reads as "live, quiet". */
12
+ const WAVE_FLOOR = 0.06;
13
+ /** Loudness of the last analyser window, 0..1: RMS rather than peak, so a click does not spike. */
14
+ function waveLevel(samples) {
15
+ let sum = 0;
16
+ for (let i = 0; i < samples.length; i++) {
17
+ const value = (samples[i] - 128) / 128;
18
+ sum += value * value;
19
+ }
20
+ return Math.min(1, Math.sqrt(sum / samples.length) * 4);
21
+ }
22
+ /**
23
+ * A full-width level history for one speaker: newest at the right, sliding
24
+ * left. Drawn in the row's CSS `color`, so the user's wave takes the muted grey
25
+ * and the assistant's the accent, and the two rows in parallel show who spoke
26
+ * when without a legend. Analyses existing streams only: never acquires a
27
+ * microphone or routes audio.
28
+ */
29
+ function VoiceWave({ stream, role }) {
30
+ const t = useTranslations.useTranslations();
31
+ const canvasRef = React.useRef(null);
32
+ React.useEffect(() => {
33
+ const canvas = canvasRef.current;
34
+ if (!canvas)
35
+ return;
36
+ let drawing;
37
+ try {
38
+ drawing = canvas.getContext('2d');
39
+ }
40
+ catch {
41
+ return;
42
+ }
43
+ if (!drawing)
44
+ return;
45
+ let context;
46
+ let source;
47
+ let analyser;
48
+ let frame = 0;
49
+ let tick = -Infinity;
50
+ let color = '';
51
+ const levels = new Float32Array(WAVE_HISTORY);
52
+ const samples = new Uint8Array(256);
53
+ try {
54
+ if (stream && typeof AudioContext !== 'undefined') {
55
+ context = new AudioContext();
56
+ source = context.createMediaStreamSource(stream);
57
+ analyser = context.createAnalyser();
58
+ analyser.fftSize = 256;
59
+ source.connect(analyser);
60
+ void context.resume().catch(() => { });
61
+ }
62
+ }
63
+ catch { /* A visualisation failure must not interrupt the call. */ }
64
+ const fit = () => {
65
+ const ratio = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1;
66
+ canvas.width = Math.max(1, Math.round((canvas.clientWidth || canvas.width) * ratio));
67
+ canvas.height = Math.max(1, Math.round((canvas.clientHeight || canvas.height) * ratio));
68
+ };
69
+ const draw = (now) => {
70
+ if (now - tick >= WAVE_TICK_MS) {
71
+ tick = now;
72
+ samples.fill(128);
73
+ try {
74
+ analyser?.getByteTimeDomainData(samples);
75
+ }
76
+ catch { /* Closed stream. */ }
77
+ levels.copyWithin(0, 1);
78
+ levels[WAVE_HISTORY - 1] = waveLevel(samples);
79
+ color = typeof getComputedStyle === 'function' ? getComputedStyle(canvas).color : '';
80
+ }
81
+ const { width, height } = canvas;
82
+ drawing.clearRect(0, 0, width, height);
83
+ drawing.strokeStyle = color || '#8c8c8c';
84
+ drawing.lineCap = 'round';
85
+ const step = width / WAVE_HISTORY;
86
+ const bar = Math.max(1, step * 0.5);
87
+ drawing.lineWidth = bar;
88
+ const middle = height / 2;
89
+ for (let i = 0; i < WAVE_HISTORY; i++) {
90
+ const tall = Math.max(0, Math.max(WAVE_FLOOR, levels[i]) * (height - bar) - bar);
91
+ const x = i * step + step / 2;
92
+ drawing.globalAlpha = 0.3 + 0.7 * (i / WAVE_HISTORY);
93
+ drawing.beginPath();
94
+ drawing.moveTo(x, middle - tall / 2);
95
+ drawing.lineTo(x, middle + tall / 2);
96
+ drawing.stroke();
97
+ }
98
+ drawing.globalAlpha = 1;
99
+ frame = requestAnimationFrame(draw);
100
+ };
101
+ fit();
102
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(fit) : undefined;
103
+ observer?.observe(canvas);
104
+ frame = requestAnimationFrame(draw);
105
+ return () => {
106
+ cancelAnimationFrame(frame);
107
+ observer?.disconnect();
108
+ source?.disconnect();
109
+ analyser?.disconnect();
110
+ if (context)
111
+ void context.close().catch(() => { });
112
+ try {
113
+ drawing.clearRect(0, 0, canvas.width, canvas.height);
114
+ }
115
+ catch { /* Detached canvas. */ }
116
+ };
117
+ }, [stream, role]);
118
+ return jsxRuntime.jsxs("div", { className: `devic-voice-wave-row devic-voice-wave-row--${role}`, children: [jsxRuntime.jsx("span", { children: role === 'user' ? t('You') : t('Assistant') }), jsxRuntime.jsx("canvas", { ref: canvasRef, className: "devic-voice-wave-canvas", width: 640, height: 28, role: "img", "aria-label": role === 'user' ? t('Your audio level') : t('Assistant audio level') })] });
119
+ }
120
+ /**
121
+ * The transcript as a teleprompter, matching Active Chat: the newest words sit
122
+ * at the bottom of a fixed-height viewport and everything earlier slides up and
123
+ * fades out through the top edge; below it, one level wave per speaker.
124
+ */
125
+ function LiveVoicePrompter({ voice }) {
126
+ const t = useTranslations.useTranslations();
127
+ const viewport = React.useRef(null);
128
+ const text = React.useRef(null);
129
+ React.useLayoutEffect(() => {
130
+ const update = () => {
131
+ if (text.current && viewport.current)
132
+ text.current.style.transform = `translateY(-${Math.max(0, text.current.scrollHeight - viewport.current.clientHeight)}px)`;
133
+ };
134
+ update();
135
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(update) : undefined;
136
+ if (viewport.current)
137
+ observer?.observe(viewport.current);
138
+ if (text.current)
139
+ observer?.observe(text.current);
140
+ return () => observer?.disconnect();
141
+ }, [voice.transcript]);
142
+ const placeholder = voice.state === 'connecting' ? t('Connecting voice…') : voice.state === 'closing' ? t('Closing voice…') : voice.state === 'reconnecting' ? t('Restoring voice…') : t('Listening…');
143
+ return jsxRuntime.jsxs("div", { className: "devic-voice-prompter", "aria-label": t('Live voice conversation'), children: [jsxRuntime.jsx("div", { ref: viewport, className: "devic-voice-prompter-viewport", "aria-live": "polite", children: jsxRuntime.jsx("div", { ref: text, className: "devic-voice-prompter-text", children: voice.transcript.length ? voice.transcript.slice(-4).map((turn, i, turns) => jsxRuntime.jsxs("p", { className: `devic-voice-turn${i === turns.length - 1 ? ' devic-voice-turn--current' : ''}`, children: [jsxRuntime.jsx("span", { children: turn.role === 'user' ? t('You') : t('Assistant') }), turn.text.replace(/\s+/g, ' ')] }, i)) : jsxRuntime.jsx("p", { className: "devic-voice-turn devic-voice-turn--placeholder", children: placeholder }) }) }), jsxRuntime.jsxs("div", { className: "devic-voice-waves", children: [jsxRuntime.jsx(VoiceWave, { role: "user", stream: voice.muted ? undefined : voice.input }), jsxRuntime.jsx(VoiceWave, { role: "assistant", stream: voice.output })] })] });
144
+ }
145
+
146
+ exports.LiveVoicePrompter = LiveVoicePrompter;
147
+ exports.waveLevel = waveLevel;
148
+ //# sourceMappingURL=LiveVoicePrompter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LiveVoicePrompter.js","sources":["../../../../../src/components/ChatDrawer/LiveVoicePrompter.tsx"],"sourcesContent":["import { useEffect, useLayoutEffect, useRef } from 'react';\nimport { useTranslations } from '../../i18n';\nimport type { UseDevicLiveVoiceResult } from '../../hooks/useDevicLiveVoice';\n\nexport interface LiveVoicePrompterProps {\n voice: Pick<UseDevicLiveVoiceResult, 'transcript' | 'input' | 'output' | 'muted' | 'state'>;\n}\n\n/** Level readings kept per wave: one bar each, spread over the whole width. */\nconst WAVE_HISTORY = 160;\n/** A new reading this often, so 160 bars are eight seconds of conversation. */\nconst WAVE_TICK_MS = 50;\n/** Silence draws a dotted baseline rather than nothing, so the row reads as \"live, quiet\". */\nconst WAVE_FLOOR = 0.06;\n\n/** Loudness of the last analyser window, 0..1: RMS rather than peak, so a click does not spike. */\nexport function waveLevel(samples: Uint8Array): number {\n let sum = 0;\n for (let i = 0; i < samples.length; i++) {\n const value = (samples[i] - 128) / 128;\n sum += value * value;\n }\n return Math.min(1, Math.sqrt(sum / samples.length) * 4);\n}\n\n/**\n * A full-width level history for one speaker: newest at the right, sliding\n * left. Drawn in the row's CSS `color`, so the user's wave takes the muted grey\n * and the assistant's the accent, and the two rows in parallel show who spoke\n * when without a legend. Analyses existing streams only: never acquires a\n * microphone or routes audio.\n */\nfunction VoiceWave({ stream, role }: { stream?: MediaStream; role: 'user' | 'assistant' }) {\n const t = useTranslations();\n const canvasRef = useRef<HTMLCanvasElement>(null);\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n let drawing: CanvasRenderingContext2D | null;\n try { drawing = canvas.getContext('2d'); } catch { return; }\n if (!drawing) return;\n let context: AudioContext | undefined;\n let source: MediaStreamAudioSourceNode | undefined;\n let analyser: AnalyserNode | undefined;\n let frame = 0;\n let tick = -Infinity;\n let color = '';\n const levels = new Float32Array(WAVE_HISTORY);\n const samples = new Uint8Array(256);\n try {\n if (stream && typeof AudioContext !== 'undefined') {\n context = new AudioContext(); source = context.createMediaStreamSource(stream);\n analyser = context.createAnalyser(); analyser.fftSize = 256;\n source.connect(analyser); void context.resume().catch(() => {});\n }\n } catch { /* A visualisation failure must not interrupt the call. */ }\n const fit = () => {\n const ratio = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1;\n canvas.width = Math.max(1, Math.round((canvas.clientWidth || canvas.width) * ratio));\n canvas.height = Math.max(1, Math.round((canvas.clientHeight || canvas.height) * ratio));\n };\n const draw = (now: number) => {\n if (now - tick >= WAVE_TICK_MS) {\n tick = now; samples.fill(128);\n try { analyser?.getByteTimeDomainData(samples); } catch { /* Closed stream. */ }\n levels.copyWithin(0, 1); levels[WAVE_HISTORY - 1] = waveLevel(samples);\n color = typeof getComputedStyle === 'function' ? getComputedStyle(canvas).color : '';\n }\n const { width, height } = canvas;\n drawing!.clearRect(0, 0, width, height);\n drawing!.strokeStyle = color || '#8c8c8c';\n drawing!.lineCap = 'round';\n const step = width / WAVE_HISTORY;\n const bar = Math.max(1, step * 0.5);\n drawing!.lineWidth = bar;\n const middle = height / 2;\n for (let i = 0; i < WAVE_HISTORY; i++) {\n const tall = Math.max(0, Math.max(WAVE_FLOOR, levels[i]) * (height - bar) - bar);\n const x = i * step + step / 2;\n drawing!.globalAlpha = 0.3 + 0.7 * (i / WAVE_HISTORY);\n drawing!.beginPath(); drawing!.moveTo(x, middle - tall / 2);\n drawing!.lineTo(x, middle + tall / 2); drawing!.stroke();\n }\n drawing!.globalAlpha = 1;\n frame = requestAnimationFrame(draw);\n };\n fit();\n const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(fit) : undefined;\n observer?.observe(canvas); frame = requestAnimationFrame(draw);\n return () => {\n cancelAnimationFrame(frame); observer?.disconnect(); source?.disconnect(); analyser?.disconnect();\n if (context) void context.close().catch(() => {});\n try { drawing!.clearRect(0, 0, canvas.width, canvas.height); } catch { /* Detached canvas. */ }\n };\n }, [stream, role]);\n return <div className={`devic-voice-wave-row devic-voice-wave-row--${role}`}>\n <span>{role === 'user' ? t('You') : t('Assistant')}</span>\n <canvas ref={canvasRef} className=\"devic-voice-wave-canvas\" width={640} height={28} role=\"img\"\n aria-label={role === 'user' ? t('Your audio level') : t('Assistant audio level')} />\n </div>;\n}\n\n/**\n * The transcript as a teleprompter, matching Active Chat: the newest words sit\n * at the bottom of a fixed-height viewport and everything earlier slides up and\n * fades out through the top edge; below it, one level wave per speaker.\n */\nexport function LiveVoicePrompter({ voice }: LiveVoicePrompterProps) {\n const t = useTranslations();\n const viewport = useRef<HTMLDivElement>(null);\n const text = useRef<HTMLDivElement>(null);\n useLayoutEffect(() => {\n const update = () => {\n if (text.current && viewport.current) text.current.style.transform = `translateY(-${Math.max(0, text.current.scrollHeight - viewport.current.clientHeight)}px)`;\n };\n update();\n const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(update) : undefined;\n if (viewport.current) observer?.observe(viewport.current);\n if (text.current) observer?.observe(text.current);\n return () => observer?.disconnect();\n }, [voice.transcript]);\n const placeholder = voice.state === 'connecting' ? t('Connecting voice…') : voice.state === 'closing' ? t('Closing voice…') : voice.state === 'reconnecting' ? t('Restoring voice…') : t('Listening…');\n return <div className=\"devic-voice-prompter\" aria-label={t('Live voice conversation')}>\n <div ref={viewport} className=\"devic-voice-prompter-viewport\" aria-live=\"polite\">\n <div ref={text} className=\"devic-voice-prompter-text\">\n {voice.transcript.length ? voice.transcript.slice(-4).map((turn, i, turns) =>\n <p key={i} className={`devic-voice-turn${i === turns.length - 1 ? ' devic-voice-turn--current' : ''}`}>\n <span>{turn.role === 'user' ? t('You') : t('Assistant')}</span>{turn.text.replace(/\\s+/g, ' ')}\n </p>) : <p className=\"devic-voice-turn devic-voice-turn--placeholder\">{placeholder}</p>}\n </div>\n </div>\n <div className=\"devic-voice-waves\">\n <VoiceWave role=\"user\" stream={voice.muted ? undefined : voice.input} />\n <VoiceWave role=\"assistant\" stream={voice.output} />\n </div>\n </div>;\n}\n"],"names":["useTranslations","useRef","useEffect","_jsxs","_jsx","useLayoutEffect"],"mappings":"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"}
@@ -9,6 +9,7 @@ var usePolling = require('./usePolling.js');
9
9
  var useModelInterface = require('./useModelInterface.js');
10
10
  var assistantInfo = require('../api/assistantInfo.js');
11
11
  var useTranslations = require('../i18n/useTranslations.js');
12
+ var useDevicLiveVoice = require('./useDevicLiveVoice.js');
12
13
 
13
14
  /**
14
15
  * Cadence for the handoff watch, which only waits for the parent thread to
@@ -143,16 +144,12 @@ function useDevicChat(options) {
143
144
  onChatCreatedRef.current = onChatCreated;
144
145
  });
145
146
  // Create API client
146
- const clientRef = React.useRef(null);
147
- if (!clientRef.current && (apiKey || getTenantSession)) {
148
- clientRef.current = new client.DevicApiClient({ apiKey, baseUrl, getTenantSession, onSessionExpired });
149
- }
150
- // Update client config if it changes
151
- React.useEffect(() => {
152
- if (clientRef.current && apiKey) {
153
- clientRef.current.setConfig({ apiKey, baseUrl });
154
- }
155
- }, [apiKey, baseUrl]);
147
+ const client$1 = React.useMemo(() => apiKey || getTenantSession
148
+ ? new client.DevicApiClient({ apiKey, baseUrl, getTenantSession, onSessionExpired }) : null, [apiKey, baseUrl, getTenantSession, onSessionExpired]);
149
+ const clientRef = React.useRef(client$1);
150
+ // A voice controller keeps its original client for teardown. Mutating its
151
+ // credentials on account switch would close the old session as the new user.
152
+ clientRef.current = client$1;
156
153
  // --- Message queue --------------------------------------------------------
157
154
  /**
158
155
  * Messages accepted by this conversation that the model has not seen yet.
@@ -264,7 +261,13 @@ function useDevicChat(options) {
264
261
  // Load initial chat history if chatUid prop is provided
265
262
  // This runs once on mount (or when initialChatUid changes) to fetch existing conversation
266
263
  const initialChatLoadedRef = React.useRef(false);
264
+ const previousInitialChatRef = React.useRef(initialChatUid);
267
265
  React.useEffect(() => {
266
+ if (previousInitialChatRef.current !== initialChatUid) {
267
+ previousInitialChatRef.current = initialChatUid;
268
+ initialChatLoadedRef.current = false;
269
+ void voiceRef.current.stop();
270
+ }
268
271
  if (initialChatUid && clientRef.current && !initialChatLoadedRef.current) {
269
272
  initialChatLoadedRef.current = true;
270
273
  const loadInitialChat = async () => {
@@ -272,6 +275,8 @@ function useDevicChat(options) {
272
275
  setError(null);
273
276
  try {
274
277
  const history = await clientRef.current.getChatHistory(assistantId, initialChatUid, { tenantId: resolvedTenantId });
278
+ if (previousInitialChatRef.current !== initialChatUid)
279
+ return;
275
280
  setMessages(history.chatContent);
276
281
  mergeRecalledMemories(history.recalledMemories);
277
282
  mergeCompactions(history.compactions);
@@ -294,6 +299,34 @@ function useDevicChat(options) {
294
299
  tools: modelInterfaceTools,
295
300
  onToolExecute: onToolCall,
296
301
  });
302
+ const voice = useDevicLiveVoice.useDevicLiveVoice({
303
+ client: clientRef.current, assistantId, chatUid, enabled: options.liveVoice?.enabled,
304
+ context: { tenantId: resolvedTenantId, subtenantId: resolvedSubtenantId,
305
+ metadata: { ...resolvedTenantMetadata, ...(Object.keys(resolvedSubtenantMetadata).length ? { subtenantMetadata: resolvedSubtenantMetadata } : {}) },
306
+ tags: resolvedTags, tools: toolSchemas, enabledTools, disabledIntegrations },
307
+ onChatCreated: uid => {
308
+ if (chatUidRef.current !== uid) {
309
+ chatUidRef.current = uid;
310
+ setChatUid(uid);
311
+ onChatCreatedRef.current?.(uid);
312
+ }
313
+ setShouldPoll(true);
314
+ },
315
+ onError: error => onErrorRef.current?.(error),
316
+ });
317
+ const observingVoice = voice.active && !!chatUid && voice.chatUid === chatUid;
318
+ const observing = shouldPoll || observingVoice;
319
+ const voiceRef = React.useRef(voice);
320
+ voiceRef.current = voice;
321
+ const wasVoiceActive = React.useRef(false);
322
+ React.useEffect(() => {
323
+ if (wasVoiceActive.current && !voice.active && status === 'completed' && !queuedCount)
324
+ setShouldPoll(false);
325
+ wasVoiceActive.current = voice.active;
326
+ }, [voice.active, status, queuedCount]);
327
+ const handledClientCalls = React.useRef(new Set());
328
+ const notifiedMessages = React.useRef(new Map());
329
+ React.useEffect(() => { handledClientCalls.current.clear(); notifiedMessages.current.clear(); }, [chatUid, assistantId]);
297
330
  // Pending widget calls awaiting user interaction
298
331
  const [pendingWidgetCalls, setPendingWidgetCalls] = React.useState([]);
299
332
  const pendingWidgetCallsRef = React.useRef([]);
@@ -302,7 +335,7 @@ function useDevicChat(options) {
302
335
  }, [pendingWidgetCalls]);
303
336
  // Polling hook - uses callbacks for side effects, return value not needed
304
337
  logRef.current.log('[useDevicChat] Render - shouldPoll:', shouldPoll, 'chatUid:', chatUid);
305
- usePolling.usePolling(shouldPoll ? chatUid : null, async () => {
338
+ usePolling.usePolling(observing ? chatUid : null, async () => {
306
339
  logRef.current.log('[useDevicChat] fetchFn called, chatUid:', chatUid);
307
340
  if (!clientRef.current || !chatUid) {
308
341
  throw new Error(t('Cannot poll without client or chatUid'));
@@ -313,10 +346,10 @@ function useDevicChat(options) {
313
346
  }, {
314
347
  interval: pollingInterval,
315
348
  // Only when asked for: the poll is the default until the flag flips.
316
- streamFn: streaming
349
+ streamFn: (streaming || observingVoice)
317
350
  ? (onSnapshot, signal, onActivity) => clientRef.current.streamRealtimeHistory(assistantId, chatUid, onSnapshot, signal, onActivity)
318
351
  : undefined,
319
- enabled: shouldPoll,
352
+ enabled: observing,
320
353
  stopStatuses: [
321
354
  'completed',
322
355
  'error',
@@ -325,6 +358,13 @@ function useDevicChat(options) {
325
358
  'limit_exceeded',
326
359
  ],
327
360
  onUpdate: async (data) => {
361
+ if (observingVoice) {
362
+ setIsLoading(data.status === 'processing' || data.status === 'buffering');
363
+ setHandedOff(data.status === 'handed_off');
364
+ setHandedOffSubThreadId(data.status === 'handed_off' ? data.handedOffSubThreadId || null : null);
365
+ if (data.status === 'error' || data.status === 'limit_exceeded')
366
+ void voiceRef.current.stop();
367
+ }
328
368
  setStreamingMessage(data.status === 'processing' ? data.streamingMessage || null : null);
329
369
  logRef.current.log('[useDevicChat] onUpdate called, status:', data.status);
330
370
  // An assistant message written after something was queued from here is
@@ -415,7 +455,9 @@ function useDevicChat(options) {
415
455
  setStatus(data.status);
416
456
  // Notify about new messages
417
457
  const lastMessage = data.chatHistory[data.chatHistory.length - 1];
418
- if (lastMessage && lastMessage.role === 'assistant') {
458
+ const messageRevision = lastMessage ? JSON.stringify(lastMessage) : '';
459
+ if (lastMessage && lastMessage.role === 'assistant' && notifiedMessages.current.get(lastMessage.uid) !== messageRevision) {
460
+ notifiedMessages.current.set(lastMessage.uid, messageRevision);
419
461
  onMessageReceivedRef.current?.(lastMessage);
420
462
  }
421
463
  // Handle model interface - check for pending tool calls
@@ -424,6 +466,8 @@ function useDevicChat(options) {
424
466
  }
425
467
  },
426
468
  holdOpen: (data) => {
469
+ if (observingVoice && ['completed', 'waiting_for_tool_response', 'handed_off'].includes(data.status))
470
+ return true;
427
471
  // Only `completed` is worth waiting on. An error, a usage limit or a
428
472
  // gate mean something else is going on, and holding the poll open would
429
473
  // just be watching a conversation that is not coming back.
@@ -494,9 +538,13 @@ function useDevicChat(options) {
494
538
  if (!clientRef.current || !chatUid)
495
539
  return;
496
540
  // Get pending tool calls
497
- const pendingCalls = data.pendingToolCalls || extractPendingToolCalls(data.chatHistory);
541
+ const pendingCalls = (data.pendingToolCalls || extractPendingToolCalls(data.chatHistory))
542
+ .filter(call => !handledClientCalls.current.has(call.id));
498
543
  if (pendingCalls.length === 0)
499
544
  return;
545
+ // Reserve before awaiting callbacks: repeated snapshots must not execute
546
+ // side effects twice. An ambiguous tool-response failure is not replayed.
547
+ pendingCalls.forEach(call => handledClientCalls.current.add(call.id));
500
548
  try {
501
549
  // Execute client-side tools (partitioned into immediate responses and widget-driven)
502
550
  const { responses, widgetCalls } = await handleToolCalls(pendingCalls);
@@ -742,6 +790,7 @@ function useDevicChat(options) {
742
790
  ]);
743
791
  // Clear chat
744
792
  const clearChat = React.useCallback(() => {
793
+ void voiceRef.current.stop();
745
794
  setShouldPoll(false);
746
795
  setHandedOff(false);
747
796
  setHandedOffSubThreadId(null);
@@ -764,6 +813,7 @@ function useDevicChat(options) {
764
813
  }, [resetQueueState]);
765
814
  // Load existing chat
766
815
  const loadChat = React.useCallback(async (loadChatUid) => {
816
+ await voiceRef.current.stop();
767
817
  if (!clientRef.current) {
768
818
  const err = new Error(t('API client not configured'));
769
819
  setError(err);
@@ -814,7 +864,7 @@ function useDevicChat(options) {
814
864
  // 5s (or the configured cadence) to detect when the parent thread is no
815
865
  // longer in handed_off state.
816
866
  React.useEffect(() => {
817
- if (!handedOff || !chatUid || !clientRef.current)
867
+ if (observingVoice || !handedOff || !chatUid || !clientRef.current)
818
868
  return;
819
869
  const pollHandoff = async () => {
820
870
  try {
@@ -841,7 +891,7 @@ function useDevicChat(options) {
841
891
  handoffPollRef.current = null;
842
892
  }
843
893
  };
844
- }, [handedOff, chatUid, assistantId, handoffPollingInterval]);
894
+ }, [handedOff, chatUid, assistantId, handoffPollingInterval, observingVoice]);
845
895
  // Called by HandoffSubagentWidget when the subthread reaches a terminal state
846
896
  const onHandoffCompleted = React.useCallback(() => {
847
897
  logRef.current.log('[useDevicChat] onHandoffCompleted called');
@@ -929,6 +979,7 @@ function useDevicChat(options) {
929
979
  };
930
980
  }, [assistantId, resetQueueState]);
931
981
  return {
982
+ voice,
932
983
  messages: streamingMessage && isLoading ? [...messages, streamingMessage] : messages,
933
984
  chatUid,
934
985
  isLoading,