@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.
- package/README.md +10 -0
- package/dist/cjs/api/client.js +41 -6
- 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 +9 -2
- package/dist/cjs/components/ChatDrawer/ChatDrawer.js.map +1 -1
- package/dist/cjs/components/ChatDrawer/ChatMessages.js +2 -1
- package/dist/cjs/components/ChatDrawer/ChatMessages.js.map +1 -1
- package/dist/cjs/components/ChatDrawer/LiveVoicePanel.js +106 -0
- package/dist/cjs/components/ChatDrawer/LiveVoicePanel.js.map +1 -0
- package/dist/cjs/components/ChatDrawer/LiveVoicePrompter.js +148 -0
- package/dist/cjs/components/ChatDrawer/LiveVoicePrompter.js.map +1 -0
- package/dist/cjs/hooks/useDevicChat.js +68 -17
- package/dist/cjs/hooks/useDevicChat.js.map +1 -1
- package/dist/cjs/hooks/useDevicLiveVoice.js +73 -0
- package/dist/cjs/hooks/useDevicLiveVoice.js.map +1 -0
- package/dist/cjs/hooks/usePolling.js +1 -1
- 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/cjs/utils/consumeChatStream.js +36 -6
- package/dist/cjs/utils/consumeChatStream.js.map +1 -1
- package/dist/cjs/voice/LiveVoiceController.js +287 -0
- package/dist/cjs/voice/LiveVoiceController.js.map +1 -0
- package/dist/esm/api/client.d.ts +12 -3
- package/dist/esm/api/client.js +41 -6
- package/dist/esm/api/client.js.map +1 -1
- package/dist/esm/api/liveVoice.types.d.ts +49 -0
- package/dist/esm/api/types.d.ts +3 -0
- package/dist/esm/api/types.js.map +1 -1
- package/dist/esm/components/ChatDrawer/ChatDrawer.js +11 -4
- package/dist/esm/components/ChatDrawer/ChatDrawer.js.map +1 -1
- package/dist/esm/components/ChatDrawer/ChatDrawer.types.d.ts +6 -0
- package/dist/esm/components/ChatDrawer/ChatMessages.js +2 -1
- package/dist/esm/components/ChatDrawer/ChatMessages.js.map +1 -1
- package/dist/esm/components/ChatDrawer/LiveVoicePanel.d.ts +21 -0
- package/dist/esm/components/ChatDrawer/LiveVoicePanel.js +102 -0
- package/dist/esm/components/ChatDrawer/LiveVoicePanel.js.map +1 -0
- package/dist/esm/components/ChatDrawer/LiveVoicePrompter.d.ts +12 -0
- package/dist/esm/components/ChatDrawer/LiveVoicePrompter.js +145 -0
- package/dist/esm/components/ChatDrawer/LiveVoicePrompter.js.map +1 -0
- package/dist/esm/hooks/index.d.ts +2 -0
- package/dist/esm/hooks/useDevicChat.d.ts +6 -0
- package/dist/esm/hooks/useDevicChat.js +68 -17
- package/dist/esm/hooks/useDevicChat.js.map +1 -1
- package/dist/esm/hooks/useDevicLiveVoice.d.ts +20 -0
- package/dist/esm/hooks/useDevicLiveVoice.js +71 -0
- package/dist/esm/hooks/useDevicLiveVoice.js.map +1 -0
- package/dist/esm/hooks/usePolling.d.ts +1 -1
- package/dist/esm/hooks/usePolling.js +1 -1
- package/dist/esm/hooks/usePolling.js.map +1 -1
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/styles.css +1 -1
- package/dist/esm/utils/consumeChatStream.d.ts +8 -4
- package/dist/esm/utils/consumeChatStream.js +36 -6
- package/dist/esm/utils/consumeChatStream.js.map +1 -1
- package/dist/esm/voice/LiveVoiceController.d.ts +38 -0
- package/dist/esm/voice/LiveVoiceController.js +284 -0
- package/dist/esm/voice/LiveVoiceController.js.map +1 -0
- 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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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,
|