@ews-admin/global-design-system 1.37.0 → 1.38.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/dist/components/AssistantChat/ActionTooltip.d.ts +12 -0
- package/dist/components/AssistantChat/ActionTooltip.d.ts.map +1 -0
- package/dist/components/AssistantChat/AssistantChat.d.ts.map +1 -1
- package/dist/components/AssistantChat/AssistantChat.types.d.ts +35 -1
- package/dist/components/AssistantChat/AssistantChat.types.d.ts.map +1 -1
- package/dist/components/AssistantChat/AssistantComposer.d.ts +16 -3
- package/dist/components/AssistantChat/AssistantComposer.d.ts.map +1 -1
- package/dist/components/AssistantChat/AssistantDictationBar.d.ts +28 -0
- package/dist/components/AssistantChat/AssistantDictationBar.d.ts.map +1 -0
- package/dist/components/AssistantChat/AssistantSuggestionCard.d.ts +1 -1
- package/dist/components/AssistantChat/AssistantSuggestionCard.d.ts.map +1 -1
- package/dist/components/AssistantChat/AssistantVoiceMode.d.ts +37 -0
- package/dist/components/AssistantChat/AssistantVoiceMode.d.ts.map +1 -0
- package/dist/components/AssistantChat/assistantMarkdown.d.ts.map +1 -1
- package/dist/components/AssistantChat/index.d.ts +4 -0
- package/dist/components/AssistantChat/index.d.ts.map +1 -1
- package/dist/components/AssistantLauncher/AssistantLauncher.d.ts.map +1 -1
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useSpeechRecognition.d.ts +2 -0
- package/dist/hooks/useSpeechRecognition.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +116 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.css +1 -1
- package/dist/index.esm.js +431 -57
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +433 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/AssistantChat/ActionTooltip.tsx +31 -0
- package/src/components/AssistantChat/AssistantChat.tsx +36 -4
- package/src/components/AssistantChat/AssistantChat.types.ts +25 -1
- package/src/components/AssistantChat/AssistantComposer.tsx +106 -68
- package/src/components/AssistantChat/AssistantDictationBar.tsx +268 -0
- package/src/components/AssistantChat/AssistantSuggestionCard.tsx +7 -6
- package/src/components/AssistantChat/AssistantVoiceMode.tsx +304 -0
- package/src/components/AssistantChat/assistantMarkdown.tsx +4 -1
- package/src/components/AssistantChat/index.ts +4 -0
- package/src/components/AssistantLauncher/AssistantLauncher.tsx +106 -77
- package/src/hooks/index.ts +1 -1
- package/src/hooks/useSpeechRecognition.ts +9 -0
- package/src/index.ts +8 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { ArrowUp, Check, X } from "lucide-react";
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { getSpeechRecognition } from "../../hooks/useSpeechRecognition";
|
|
4
|
+
import { cn } from "../../utils";
|
|
5
|
+
import type { AssistantLanguage } from "./AssistantChat.types";
|
|
6
|
+
|
|
7
|
+
/** Texts of the dictation bar; French defaults. */
|
|
8
|
+
export interface AssistantDictationTexts {
|
|
9
|
+
cancel?: string;
|
|
10
|
+
done?: string;
|
|
11
|
+
send?: string;
|
|
12
|
+
unavailable?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TEXTS: Required<AssistantDictationTexts> = {
|
|
16
|
+
cancel: "Annuler la dictée",
|
|
17
|
+
done: "Terminer et modifier le texte",
|
|
18
|
+
send: "Envoyer",
|
|
19
|
+
unavailable: "Micro indisponible : autorisez-le (page https requise).",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const BCP47: Record<AssistantLanguage, string> = { fr: "fr-FR", en: "en-US", ar: "ar-SA" };
|
|
23
|
+
const BARS = 28;
|
|
24
|
+
const BLOCKING_ERRORS = new Set(["not-allowed", "service-not-allowed", "audio-capture"]);
|
|
25
|
+
|
|
26
|
+
interface DictationRecognition {
|
|
27
|
+
lang: string;
|
|
28
|
+
interimResults: boolean;
|
|
29
|
+
continuous: boolean;
|
|
30
|
+
onresult: ((event: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null;
|
|
31
|
+
onerror: ((event: { error?: string }) => void) | null;
|
|
32
|
+
onend: (() => void) | null;
|
|
33
|
+
start(): void;
|
|
34
|
+
stop(): void;
|
|
35
|
+
abort(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AssistantDictationBarProps {
|
|
39
|
+
language: AssistantLanguage;
|
|
40
|
+
/** ✕: nothing is kept. */
|
|
41
|
+
onCancel: () => void;
|
|
42
|
+
/** ✓: the transcript goes into the field to be edited. */
|
|
43
|
+
onDone: (transcript: string) => void;
|
|
44
|
+
/** ↑: the transcript is sent at once. */
|
|
45
|
+
onSend: (transcript: string) => void;
|
|
46
|
+
texts?: AssistantDictationTexts;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const formatElapsed = (seconds: number) => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The recording bar of the mockup (audio3): cancel, a live waveform, the
|
|
54
|
+
* elapsed time, done and send. Recording starts as soon as it shows. The
|
|
55
|
+
* waveform follows the mic level when the browser gives it, and moves while
|
|
56
|
+
* words come in otherwise.
|
|
57
|
+
*/
|
|
58
|
+
export const AssistantDictationBar: React.FC<AssistantDictationBarProps> = ({
|
|
59
|
+
language,
|
|
60
|
+
onCancel,
|
|
61
|
+
onDone,
|
|
62
|
+
onSend,
|
|
63
|
+
texts,
|
|
64
|
+
className,
|
|
65
|
+
}) => {
|
|
66
|
+
const t = { ...DEFAULT_TEXTS, ...texts };
|
|
67
|
+
const [levels, setLevels] = useState<number[]>(() => Array(BARS).fill(0.15));
|
|
68
|
+
const [elapsed, setElapsed] = useState(0);
|
|
69
|
+
const [unavailable, setUnavailable] = useState(
|
|
70
|
+
() => typeof window !== "undefined" && window.isSecureContext === false
|
|
71
|
+
);
|
|
72
|
+
const transcriptRef = useRef("");
|
|
73
|
+
const recognitionRef = useRef<DictationRecognition | null>(null);
|
|
74
|
+
const actionRef = useRef<"done" | "send" | "cancel" | null>(null);
|
|
75
|
+
const callbacks = useRef({ onCancel, onDone, onSend });
|
|
76
|
+
callbacks.current = { onCancel, onDone, onSend };
|
|
77
|
+
|
|
78
|
+
const pushLevel = useCallback((level: number) => {
|
|
79
|
+
setLevels((current) => [...current.slice(1), Math.max(0.12, Math.min(1, level))]);
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const finish = useCallback(() => {
|
|
83
|
+
const text = transcriptRef.current.trim();
|
|
84
|
+
const action = actionRef.current;
|
|
85
|
+
if (action === "send" && text) callbacks.current.onSend(text);
|
|
86
|
+
else if (action === "done") callbacks.current.onDone(text);
|
|
87
|
+
else callbacks.current.onCancel();
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
// Speech recognition, from the first render.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (unavailable) return;
|
|
93
|
+
const Ctor = getSpeechRecognition() as unknown as (new () => DictationRecognition) | undefined;
|
|
94
|
+
if (!Ctor) {
|
|
95
|
+
setUnavailable(true);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const recognition = new Ctor();
|
|
99
|
+
recognition.lang = BCP47[language] ?? language;
|
|
100
|
+
recognition.interimResults = true;
|
|
101
|
+
recognition.continuous = true;
|
|
102
|
+
recognition.onresult = (event) => {
|
|
103
|
+
let text = "";
|
|
104
|
+
for (let i = 0; i < event.results.length; i++) text += event.results[i][0]?.transcript ?? "";
|
|
105
|
+
if (text.length !== transcriptRef.current.length) pushLevel(0.4 + Math.random() * 0.6);
|
|
106
|
+
transcriptRef.current = text;
|
|
107
|
+
};
|
|
108
|
+
recognition.onerror = (event) => {
|
|
109
|
+
if (event.error && BLOCKING_ERRORS.has(event.error)) {
|
|
110
|
+
recognition.onend = null;
|
|
111
|
+
recognitionRef.current = null;
|
|
112
|
+
setUnavailable(true);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
recognition.onend = () => {
|
|
116
|
+
if (recognitionRef.current !== recognition) return;
|
|
117
|
+
recognitionRef.current = null;
|
|
118
|
+
if (actionRef.current) finish();
|
|
119
|
+
else {
|
|
120
|
+
// The browser stopped after a silence: keep recording.
|
|
121
|
+
try {
|
|
122
|
+
recognition.start();
|
|
123
|
+
recognitionRef.current = recognition;
|
|
124
|
+
} catch {
|
|
125
|
+
/* ignore: the user can still press done */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
recognitionRef.current = recognition;
|
|
130
|
+
recognition.start();
|
|
131
|
+
return () => {
|
|
132
|
+
recognition.onend = null;
|
|
133
|
+
recognitionRef.current = null;
|
|
134
|
+
recognition.abort();
|
|
135
|
+
};
|
|
136
|
+
}, [finish, language, pushLevel, unavailable]);
|
|
137
|
+
|
|
138
|
+
// Live mic level for the waveform, where the browser allows it.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (unavailable || typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) return;
|
|
141
|
+
const w = window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };
|
|
142
|
+
const AudioCtx = w.AudioContext ?? w.webkitAudioContext;
|
|
143
|
+
if (!AudioCtx) return;
|
|
144
|
+
let stream: MediaStream | null = null;
|
|
145
|
+
let context: AudioContext | null = null;
|
|
146
|
+
let frame = 0;
|
|
147
|
+
let cancelled = false;
|
|
148
|
+
navigator.mediaDevices
|
|
149
|
+
.getUserMedia({ audio: true })
|
|
150
|
+
.then((media) => {
|
|
151
|
+
if (cancelled) {
|
|
152
|
+
media.getTracks().forEach((track) => track.stop());
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
stream = media;
|
|
156
|
+
context = new AudioCtx();
|
|
157
|
+
const analyser = context.createAnalyser();
|
|
158
|
+
analyser.fftSize = 256;
|
|
159
|
+
context.createMediaStreamSource(media).connect(analyser);
|
|
160
|
+
const data = new Uint8Array(analyser.frequencyBinCount);
|
|
161
|
+
let last = 0;
|
|
162
|
+
const tick = (now: number) => {
|
|
163
|
+
if (now - last > 80) {
|
|
164
|
+
analyser.getByteTimeDomainData(data);
|
|
165
|
+
let peak = 0;
|
|
166
|
+
for (const value of data) peak = Math.max(peak, Math.abs(value - 128));
|
|
167
|
+
pushLevel(peak / 64);
|
|
168
|
+
last = now;
|
|
169
|
+
}
|
|
170
|
+
frame = requestAnimationFrame(tick);
|
|
171
|
+
};
|
|
172
|
+
frame = requestAnimationFrame(tick);
|
|
173
|
+
return undefined;
|
|
174
|
+
})
|
|
175
|
+
.catch(() => undefined);
|
|
176
|
+
return () => {
|
|
177
|
+
cancelled = true;
|
|
178
|
+
cancelAnimationFrame(frame);
|
|
179
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
180
|
+
void context?.close();
|
|
181
|
+
};
|
|
182
|
+
}, [pushLevel, unavailable]);
|
|
183
|
+
|
|
184
|
+
// Elapsed time.
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (unavailable) return;
|
|
187
|
+
const timer = setInterval(() => setElapsed((value) => value + 1), 1000);
|
|
188
|
+
return () => clearInterval(timer);
|
|
189
|
+
}, [unavailable]);
|
|
190
|
+
|
|
191
|
+
const end = (action: "done" | "send" | "cancel") => {
|
|
192
|
+
actionRef.current = action;
|
|
193
|
+
const recognition = recognitionRef.current;
|
|
194
|
+
if (!recognition || action === "cancel") {
|
|
195
|
+
if (recognition) {
|
|
196
|
+
recognition.onend = null;
|
|
197
|
+
recognitionRef.current = null;
|
|
198
|
+
recognition.abort();
|
|
199
|
+
}
|
|
200
|
+
finish();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// stop() delivers the last words, then onend calls finish().
|
|
204
|
+
recognition.stop();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div
|
|
209
|
+
className={cn("flex w-full items-center gap-2 rounded-full bg-ews-gray-100 px-3 py-1.5", className)}
|
|
210
|
+
data-testid="assistant-dictation"
|
|
211
|
+
>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={() => end("cancel")}
|
|
215
|
+
aria-label={t.cancel}
|
|
216
|
+
title={t.cancel}
|
|
217
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-ews-gray-600 hover:bg-ews-gray-200 focus:outline-none focus:ring-2 focus:ring-ews-primary"
|
|
218
|
+
data-testid="assistant-dictation-cancel"
|
|
219
|
+
>
|
|
220
|
+
<X className="h-5 w-5" aria-hidden="true" />
|
|
221
|
+
</button>
|
|
222
|
+
|
|
223
|
+
{unavailable ? (
|
|
224
|
+
<p className="flex-1 truncate text-sm text-ews-error" role="alert" data-testid="assistant-dictation-unavailable">
|
|
225
|
+
{t.unavailable}
|
|
226
|
+
</p>
|
|
227
|
+
) : (
|
|
228
|
+
<>
|
|
229
|
+
<div className="flex h-8 flex-1 items-center justify-center gap-[3px]" aria-hidden="true" data-testid="assistant-dictation-wave">
|
|
230
|
+
{levels.map((level, index) => (
|
|
231
|
+
<span
|
|
232
|
+
key={index}
|
|
233
|
+
className="w-[3px] rounded-full bg-ews-gray-700 transition-[height] duration-100"
|
|
234
|
+
style={{ height: `${Math.round(level * 100)}%` }}
|
|
235
|
+
/>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
<span className="w-10 shrink-0 text-end text-sm tabular-nums text-ews-gray-600" data-testid="assistant-dictation-time">
|
|
239
|
+
{formatElapsed(elapsed)}
|
|
240
|
+
</span>
|
|
241
|
+
</>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => end("done")}
|
|
247
|
+
disabled={unavailable}
|
|
248
|
+
aria-label={t.done}
|
|
249
|
+
title={t.done}
|
|
250
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-ews-gray-700 hover:bg-ews-gray-200 focus:outline-none focus:ring-2 focus:ring-ews-primary disabled:opacity-40"
|
|
251
|
+
data-testid="assistant-dictation-done"
|
|
252
|
+
>
|
|
253
|
+
<Check className="h-5 w-5" aria-hidden="true" />
|
|
254
|
+
</button>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={() => end("send")}
|
|
258
|
+
disabled={unavailable}
|
|
259
|
+
aria-label={t.send}
|
|
260
|
+
title={t.send}
|
|
261
|
+
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-ews-primary text-white shadow-sm hover:bg-ews-primary-hover focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1 disabled:opacity-40"
|
|
262
|
+
data-testid="assistant-dictation-send"
|
|
263
|
+
>
|
|
264
|
+
<ArrowUp className="h-5 w-5" aria-hidden="true" />
|
|
265
|
+
</button>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
};
|
|
@@ -11,7 +11,7 @@ export interface AssistantSuggestionCardProps {
|
|
|
11
11
|
className?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/** One of the cards under the hero (
|
|
14
|
+
/** One of the cards under the hero (mockups med1 and tools): icon, title, description, sent as a prompt on click. */
|
|
15
15
|
export const AssistantSuggestionCard: React.FC<AssistantSuggestionCardProps> = ({
|
|
16
16
|
suggestion,
|
|
17
17
|
onSelect,
|
|
@@ -26,19 +26,20 @@ export const AssistantSuggestionCard: React.FC<AssistantSuggestionCardProps> = (
|
|
|
26
26
|
disabled={disabled}
|
|
27
27
|
onClick={() => onSelect(suggestion)}
|
|
28
28
|
className={cn(
|
|
29
|
-
|
|
30
|
-
"
|
|
29
|
+
// A soft grey card without border (mockup tools); it lifts with a shadow on hover.
|
|
30
|
+
"flex h-full w-full flex-col items-start gap-2 rounded-2xl bg-ews-gray-100 p-4 text-start transition-[background-color,box-shadow]",
|
|
31
|
+
"hover:bg-ews-gray-50 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1",
|
|
31
32
|
"disabled:cursor-not-allowed disabled:opacity-60",
|
|
32
33
|
className
|
|
33
34
|
)}
|
|
34
35
|
data-testid={`assistant-suggestion-${suggestion.id}`}
|
|
35
36
|
>
|
|
36
|
-
<span className="text-ews-gray-
|
|
37
|
+
<span className="mb-2 text-ews-gray-600" aria-hidden="true">
|
|
37
38
|
{suggestion.icon ?? (isExample ? <Sparkles className="h-4 w-4" /> : <ArrowRight className="h-4 w-4" />)}
|
|
38
39
|
</span>
|
|
39
|
-
<span className="font-semibold text-ews-gray-900">{suggestion.title}</span>
|
|
40
|
+
<span className="font-semibold leading-snug text-ews-gray-900">{suggestion.title}</span>
|
|
40
41
|
{suggestion.description && (
|
|
41
|
-
<span className="text-sm text-ews-gray-
|
|
42
|
+
<span className="text-sm leading-snug text-ews-gray-400">{suggestion.description}</span>
|
|
42
43
|
)}
|
|
43
44
|
{isExample && (
|
|
44
45
|
<span className="mt-auto text-xs uppercase tracking-wide text-ews-gray-400">{exampleLabel}</span>
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { Mic, MicOff } from "lucide-react";
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { getSpeechRecognition } from "../../hooks/useSpeechRecognition";
|
|
4
|
+
import { cn } from "../../utils";
|
|
5
|
+
import type { AssistantLanguage, AssistantMessage } from "./AssistantChat.types";
|
|
6
|
+
|
|
7
|
+
/** Texts of the voice mode; every field has a French default. */
|
|
8
|
+
export interface AssistantVoiceModeTexts {
|
|
9
|
+
title?: string;
|
|
10
|
+
listening?: string;
|
|
11
|
+
thinking?: string;
|
|
12
|
+
speaking?: string;
|
|
13
|
+
muted?: string;
|
|
14
|
+
mute?: string;
|
|
15
|
+
unmute?: string;
|
|
16
|
+
end?: string;
|
|
17
|
+
/** Shown when the browser refuses the mic (permission denied, or the page is not https). */
|
|
18
|
+
unavailable?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TEXTS: Required<AssistantVoiceModeTexts> = {
|
|
22
|
+
title: "Conversation vocale avec Medecine360",
|
|
23
|
+
listening: "À l'écoute…",
|
|
24
|
+
thinking: "Réflexion…",
|
|
25
|
+
speaking: "Réponse…",
|
|
26
|
+
muted: "Micro coupé",
|
|
27
|
+
mute: "Couper le micro",
|
|
28
|
+
unmute: "Réactiver le micro",
|
|
29
|
+
end: "Terminer",
|
|
30
|
+
unavailable:
|
|
31
|
+
"Le micro n'est pas accessible. Autorisez-le dans le navigateur ; il ne fonctionne que sur une page sécurisée (https).",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Errors after which listening again cannot work until the user acts. */
|
|
35
|
+
const BLOCKING_ERRORS = new Set(["not-allowed", "service-not-allowed", "audio-capture"]);
|
|
36
|
+
|
|
37
|
+
const BCP47: Record<AssistantLanguage, string> = { fr: "fr-FR", en: "en-US", ar: "ar-SA" };
|
|
38
|
+
|
|
39
|
+
/** The recognition events the voice mode reads (final vs interim results). */
|
|
40
|
+
interface VoiceRecognition {
|
|
41
|
+
lang: string;
|
|
42
|
+
interimResults: boolean;
|
|
43
|
+
continuous: boolean;
|
|
44
|
+
onresult:
|
|
45
|
+
| ((event: { resultIndex?: number; results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal?: boolean }> }) => void)
|
|
46
|
+
| null;
|
|
47
|
+
onerror: ((event: { error?: string }) => void) | null;
|
|
48
|
+
onend: (() => void) | null;
|
|
49
|
+
start(): void;
|
|
50
|
+
stop(): void;
|
|
51
|
+
abort(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Markdown and links read aloud as plain words. */
|
|
55
|
+
export function toSpokenText(markdown: string): string {
|
|
56
|
+
return markdown
|
|
57
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
58
|
+
.replace(/https?:\/\/\S+/g, "")
|
|
59
|
+
.replace(/\*\*/g, "")
|
|
60
|
+
.replace(/^\s*[-*•]\s+/gm, "")
|
|
61
|
+
.replace(/\s+/g, " ")
|
|
62
|
+
.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AssistantVoiceModeProps {
|
|
66
|
+
language: AssistantLanguage;
|
|
67
|
+
/** The conversation: the screen shows the turns spoken since it opened. */
|
|
68
|
+
messages: AssistantMessage[];
|
|
69
|
+
/** True while the assistant is answering: the mic waits. */
|
|
70
|
+
busy: boolean;
|
|
71
|
+
onSend: (content: string) => void;
|
|
72
|
+
onClose: () => void;
|
|
73
|
+
texts?: AssistantVoiceModeTexts;
|
|
74
|
+
/** Read answers aloud with the browser speech synthesis. Default true where available. */
|
|
75
|
+
speak?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Phase = "listening" | "thinking" | "speaking" | "muted" | "unavailable";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* MED-1801 (voice): the voice conversation screen of the mockup. Listening
|
|
82
|
+
* starts as soon as it opens; a finished sentence is sent to the same
|
|
83
|
+
* conversation; the mic pauses while the assistant answers (read aloud when
|
|
84
|
+
* the browser can) and resumes after. Mute stops the mic; Terminer closes.
|
|
85
|
+
*/
|
|
86
|
+
export const AssistantVoiceMode: React.FC<AssistantVoiceModeProps> = ({
|
|
87
|
+
language,
|
|
88
|
+
messages,
|
|
89
|
+
busy,
|
|
90
|
+
onSend,
|
|
91
|
+
onClose,
|
|
92
|
+
texts,
|
|
93
|
+
speak = true,
|
|
94
|
+
}) => {
|
|
95
|
+
const t = { ...DEFAULT_TEXTS, ...texts };
|
|
96
|
+
const [muted, setMuted] = useState(false);
|
|
97
|
+
const [speaking, setSpeaking] = useState(false);
|
|
98
|
+
const [interim, setInterim] = useState("");
|
|
99
|
+
// The mic cannot work: insecure page (http) or permission refused.
|
|
100
|
+
const [unavailable, setUnavailable] = useState(
|
|
101
|
+
() => typeof window !== "undefined" && window.isSecureContext === false
|
|
102
|
+
);
|
|
103
|
+
const recognitionRef = useRef<VoiceRecognition | null>(null);
|
|
104
|
+
const restartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
105
|
+
const sentRef = useRef(false);
|
|
106
|
+
// Only the turns of this voice session are shown.
|
|
107
|
+
const [firstIndex] = useState(messages.length);
|
|
108
|
+
const spokenRef = useRef<string | null>(null);
|
|
109
|
+
const callbacks = useRef({ onSend });
|
|
110
|
+
callbacks.current = { onSend };
|
|
111
|
+
|
|
112
|
+
const stopListening = useCallback(() => {
|
|
113
|
+
if (restartTimerRef.current) clearTimeout(restartTimerRef.current);
|
|
114
|
+
restartTimerRef.current = null;
|
|
115
|
+
const recognition = recognitionRef.current;
|
|
116
|
+
recognitionRef.current = null;
|
|
117
|
+
if (recognition) {
|
|
118
|
+
recognition.onend = null;
|
|
119
|
+
recognition.abort();
|
|
120
|
+
}
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
const startListening = useCallback(() => {
|
|
124
|
+
const Ctor = getSpeechRecognition() as unknown as (new () => VoiceRecognition) | undefined;
|
|
125
|
+
if (!Ctor || recognitionRef.current) return;
|
|
126
|
+
const recognition = new Ctor();
|
|
127
|
+
recognition.lang = BCP47[language] ?? language;
|
|
128
|
+
recognition.interimResults = true;
|
|
129
|
+
recognition.continuous = true;
|
|
130
|
+
sentRef.current = false;
|
|
131
|
+
recognition.onresult = (event) => {
|
|
132
|
+
let text = "";
|
|
133
|
+
let final = false;
|
|
134
|
+
for (let i = 0; i < event.results.length; i++) {
|
|
135
|
+
text += event.results[i][0]?.transcript ?? "";
|
|
136
|
+
if (event.results[i].isFinal) final = true;
|
|
137
|
+
}
|
|
138
|
+
const trimmed = text.trim();
|
|
139
|
+
setInterim(trimmed);
|
|
140
|
+
if (final && trimmed && !sentRef.current) {
|
|
141
|
+
sentRef.current = true;
|
|
142
|
+
setInterim("");
|
|
143
|
+
recognitionRef.current = null;
|
|
144
|
+
recognition.onend = null;
|
|
145
|
+
recognition.stop();
|
|
146
|
+
callbacks.current.onSend(trimmed);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
recognition.onerror = (event) => {
|
|
150
|
+
// Permission refused or no mic: stop here and say so, never retry in a loop.
|
|
151
|
+
if (event.error && BLOCKING_ERRORS.has(event.error)) {
|
|
152
|
+
recognitionRef.current = null;
|
|
153
|
+
recognition.onend = null;
|
|
154
|
+
setUnavailable(true);
|
|
155
|
+
}
|
|
156
|
+
/* other errors (no-speech, network): onend follows and listens again */
|
|
157
|
+
};
|
|
158
|
+
recognition.onend = () => {
|
|
159
|
+
// The browser ends recognition after a silence: listen again, a moment later.
|
|
160
|
+
if (recognitionRef.current === recognition) {
|
|
161
|
+
recognitionRef.current = null;
|
|
162
|
+
restartTimerRef.current = setTimeout(() => startListeningRef.current(), 250);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
recognitionRef.current = recognition;
|
|
166
|
+
recognition.start();
|
|
167
|
+
}, [language]);
|
|
168
|
+
const startListeningRef = useRef(startListening);
|
|
169
|
+
startListeningRef.current = startListening;
|
|
170
|
+
|
|
171
|
+
const last = messages[messages.length - 1];
|
|
172
|
+
const canSpeak = speak && typeof window !== "undefined" && "speechSynthesis" in window;
|
|
173
|
+
|
|
174
|
+
// Read the finished answer aloud, once.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (busy || !last || last.role !== "assistant" || last.status === "streaming") return;
|
|
177
|
+
if (messages.length <= firstIndex || spokenRef.current === last.id) return;
|
|
178
|
+
spokenRef.current = last.id;
|
|
179
|
+
const text = toSpokenText(last.content);
|
|
180
|
+
if (!canSpeak || !text) return;
|
|
181
|
+
const utterance = new SpeechSynthesisUtterance(text);
|
|
182
|
+
utterance.lang = BCP47[language] ?? language;
|
|
183
|
+
utterance.onend = () => setSpeaking(false);
|
|
184
|
+
utterance.onerror = () => setSpeaking(false);
|
|
185
|
+
setSpeaking(true);
|
|
186
|
+
window.speechSynthesis.speak(utterance);
|
|
187
|
+
}, [busy, canSpeak, firstIndex, language, last, messages.length]);
|
|
188
|
+
|
|
189
|
+
// Listen whenever nobody else is talking.
|
|
190
|
+
const shouldListen = !muted && !busy && !speaking && !unavailable;
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (shouldListen) startListening();
|
|
193
|
+
else stopListening();
|
|
194
|
+
}, [shouldListen, startListening, stopListening]);
|
|
195
|
+
|
|
196
|
+
useEffect(
|
|
197
|
+
() => () => {
|
|
198
|
+
stopListening();
|
|
199
|
+
if (typeof window !== "undefined" && "speechSynthesis" in window) window.speechSynthesis.cancel();
|
|
200
|
+
},
|
|
201
|
+
[stopListening]
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const phase: Phase = unavailable
|
|
205
|
+
? "unavailable"
|
|
206
|
+
: muted
|
|
207
|
+
? "muted"
|
|
208
|
+
: busy
|
|
209
|
+
? "thinking"
|
|
210
|
+
: speaking
|
|
211
|
+
? "speaking"
|
|
212
|
+
: "listening";
|
|
213
|
+
const status = {
|
|
214
|
+
listening: t.listening,
|
|
215
|
+
thinking: t.thinking,
|
|
216
|
+
speaking: t.speaking,
|
|
217
|
+
muted: t.muted,
|
|
218
|
+
unavailable: t.unavailable,
|
|
219
|
+
}[phase];
|
|
220
|
+
const turns = messages.slice(firstIndex).filter((message) => message.content.trim().length > 0);
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div
|
|
224
|
+
dir={language === "ar" ? "rtl" : "ltr"}
|
|
225
|
+
className="absolute inset-0 z-30 flex flex-col bg-white"
|
|
226
|
+
role="dialog"
|
|
227
|
+
aria-label={t.title}
|
|
228
|
+
data-phase={phase}
|
|
229
|
+
data-testid="assistant-voice-mode"
|
|
230
|
+
>
|
|
231
|
+
<div className="flex items-center gap-2 px-5 pt-4 text-sm text-ews-gray-600">
|
|
232
|
+
<span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
|
|
233
|
+
{t.title}
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div className="flex flex-col items-center gap-3 pt-8">
|
|
237
|
+
<div
|
|
238
|
+
className={cn(
|
|
239
|
+
"h-32 w-32 rounded-full shadow-[0_0_60px_rgba(33,89,108,0.35)] transition-transform duration-700",
|
|
240
|
+
"bg-[radial-gradient(circle_at_30%_25%,#ffffff_0%,#c0d0d4_35%,#21596c_75%,#1a4756_100%)]",
|
|
241
|
+
phase === "listening" && "animate-pulse",
|
|
242
|
+
phase === "speaking" && "scale-110",
|
|
243
|
+
(phase === "muted" || phase === "unavailable") && "opacity-50"
|
|
244
|
+
)}
|
|
245
|
+
aria-hidden="true"
|
|
246
|
+
data-testid="assistant-voice-orb"
|
|
247
|
+
/>
|
|
248
|
+
<p
|
|
249
|
+
className={cn("max-w-xs px-4 text-center text-sm", phase === "unavailable" ? "text-ews-error" : "text-ews-gray-500")}
|
|
250
|
+
role={phase === "unavailable" ? "alert" : undefined}
|
|
251
|
+
aria-live="polite"
|
|
252
|
+
data-testid="assistant-voice-status"
|
|
253
|
+
>
|
|
254
|
+
{status}
|
|
255
|
+
</p>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<div className="flex-1 space-y-3 overflow-y-auto px-5 py-6" data-testid="assistant-voice-transcript">
|
|
259
|
+
{turns.map((message) => (
|
|
260
|
+
<div key={message.id} className={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
|
|
261
|
+
<p
|
|
262
|
+
className={cn(
|
|
263
|
+
"max-w-[85%] rounded-2xl px-4 py-2 text-sm",
|
|
264
|
+
message.role === "user" ? "bg-ews-gray-100 text-ews-gray-800" : "bg-ews-primary-light/40 text-ews-gray-900"
|
|
265
|
+
)}
|
|
266
|
+
>
|
|
267
|
+
{message.role === "assistant" ? toSpokenText(message.content) : message.content}
|
|
268
|
+
</p>
|
|
269
|
+
</div>
|
|
270
|
+
))}
|
|
271
|
+
{interim && (
|
|
272
|
+
<div className="flex justify-end">
|
|
273
|
+
<p className="max-w-[85%] rounded-2xl bg-ews-gray-100 px-4 py-2 text-sm italic text-ews-gray-500" data-testid="assistant-voice-interim">
|
|
274
|
+
{interim}
|
|
275
|
+
</p>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<div className="flex items-center justify-center gap-3 pb-6">
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
disabled={unavailable}
|
|
284
|
+
onClick={() => setMuted((value) => !value)}
|
|
285
|
+
aria-pressed={muted}
|
|
286
|
+
aria-label={muted ? t.unmute : t.mute}
|
|
287
|
+
title={muted ? t.unmute : t.mute}
|
|
288
|
+
className="flex h-11 w-11 items-center justify-center rounded-full border border-ews-gray-200 text-ews-gray-600 hover:bg-ews-gray-50 disabled:opacity-40"
|
|
289
|
+
data-testid="assistant-voice-mute"
|
|
290
|
+
>
|
|
291
|
+
{muted ? <MicOff className="h-5 w-5" aria-hidden="true" /> : <Mic className="h-5 w-5" aria-hidden="true" />}
|
|
292
|
+
</button>
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
onClick={onClose}
|
|
296
|
+
className="rounded-full bg-red-600 px-6 py-2.5 text-sm font-semibold text-white shadow hover:bg-red-700"
|
|
297
|
+
data-testid="assistant-voice-end"
|
|
298
|
+
>
|
|
299
|
+
{t.end}
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
};
|
|
@@ -40,8 +40,11 @@ function renderInline(text: string, keyPrefix: string, options: AssistantMarkdow
|
|
|
40
40
|
if (start > last) nodes.push(text.slice(last, start));
|
|
41
41
|
const token = match[0];
|
|
42
42
|
if (token.startsWith("**")) {
|
|
43
|
+
// MED-1801: a bold run can hold a link, e.g. **[Dr Ba](/doctors/8)**.
|
|
43
44
|
nodes.push(
|
|
44
|
-
<strong key={`${keyPrefix}-b${index}`}>
|
|
45
|
+
<strong key={`${keyPrefix}-b${index}`}>
|
|
46
|
+
{renderInline(token.slice(2, -2), `${keyPrefix}-b${index}`, options)}
|
|
47
|
+
</strong>
|
|
45
48
|
);
|
|
46
49
|
} else {
|
|
47
50
|
const close = token.indexOf("](");
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export { AssistantChat, conversationTitle } from "./AssistantChat";
|
|
2
2
|
export { AssistantComposer, MAX_MESSAGE_LENGTH } from "./AssistantComposer";
|
|
3
|
+
export { AssistantVoiceMode, toSpokenText } from "./AssistantVoiceMode";
|
|
4
|
+
export { AssistantDictationBar } from "./AssistantDictationBar";
|
|
5
|
+
export type { AssistantDictationBarProps, AssistantDictationTexts } from "./AssistantDictationBar";
|
|
6
|
+
export type { AssistantVoiceModeProps, AssistantVoiceModeTexts } from "./AssistantVoiceMode";
|
|
3
7
|
export type { AssistantComposerProps } from "./AssistantComposer";
|
|
4
8
|
export { AssistantConfirmationCard } from "./AssistantConfirmationCard";
|
|
5
9
|
export type { AssistantConfirmationCardProps } from "./AssistantConfirmationCard";
|