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