@personaliai/react-widget 0.1.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/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/chunk-PR4QN5HX.js +43 -0
- package/dist/chunk-PR4QN5HX.js.map +1 -0
- package/dist/emoji-picker-react.esm-JCK7JWXK.js +26071 -0
- package/dist/emoji-picker-react.esm-JCK7JWXK.js.map +1 -0
- package/dist/index.cjs +98050 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +28 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +71952 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +259 -0
- package/package.json +57 -0
- package/src/ChatWidgetCore.tsx +1919 -0
- package/src/attach-menu.tsx +60 -0
- package/src/color-contrast.ts +186 -0
- package/src/index.ts +1 -0
- package/src/quick-emoji-picker.tsx +75 -0
- package/src/safe-markdown-link.tsx +44 -0
- package/src/voice-call-widget.tsx +564 -0
- package/src/widget-presets.css +259 -0
- package/src/widget-style.ts +88 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { motion, AnimatePresence, useSpring } from "framer-motion";
|
|
5
|
+
import {
|
|
6
|
+
Room,
|
|
7
|
+
RoomEvent,
|
|
8
|
+
Track,
|
|
9
|
+
RemoteTrack,
|
|
10
|
+
RemoteParticipant,
|
|
11
|
+
ConnectionState,
|
|
12
|
+
TranscriptionSegment,
|
|
13
|
+
Participant,
|
|
14
|
+
} from "livekit-client";
|
|
15
|
+
import { Mic, MicOff, PhoneOff, Loader2, AlertCircle } from "lucide-react";
|
|
16
|
+
import ReactMarkdown, { type Components } from "react-markdown";
|
|
17
|
+
import remarkGfm from "remark-gfm";
|
|
18
|
+
import remarkMath from "remark-math";
|
|
19
|
+
import rehypeKatex from "rehype-katex";
|
|
20
|
+
import { SafeMarkdownLink } from "./safe-markdown-link";
|
|
21
|
+
|
|
22
|
+
const WAVE_BAR_COUNT = 14;
|
|
23
|
+
|
|
24
|
+
type CallStatus = "connecting" | "requesting-mic" | "connected" | "listening" | "agent-speaking" | "error" | "ended";
|
|
25
|
+
|
|
26
|
+
interface TranscriptEntry {
|
|
27
|
+
id: string;
|
|
28
|
+
speaker: "visitor" | "agent";
|
|
29
|
+
text: string;
|
|
30
|
+
final: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface VoiceCallWidgetProps {
|
|
34
|
+
botId: string;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
backendUrl: string;
|
|
37
|
+
originToken: string | null;
|
|
38
|
+
visitorTimezone: string;
|
|
39
|
+
primaryColor: string;
|
|
40
|
+
onClose: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function VoiceCallWidget({
|
|
44
|
+
botId,
|
|
45
|
+
sessionId,
|
|
46
|
+
backendUrl,
|
|
47
|
+
originToken,
|
|
48
|
+
visitorTimezone,
|
|
49
|
+
primaryColor,
|
|
50
|
+
onClose,
|
|
51
|
+
}: VoiceCallWidgetProps) {
|
|
52
|
+
const [status, setStatus] = useState<CallStatus>("connecting");
|
|
53
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
54
|
+
const [muted, setMuted] = useState(false);
|
|
55
|
+
const [duration, setDuration] = useState(0);
|
|
56
|
+
const [localLevels, setLocalLevels] = useState<number[]>(() => Array(WAVE_BAR_COUNT).fill(0));
|
|
57
|
+
const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);
|
|
58
|
+
|
|
59
|
+
const roomRef = useRef<Room | null>(null);
|
|
60
|
+
const audioElRef = useRef<HTMLMediaElement | null>(null);
|
|
61
|
+
const durationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
62
|
+
const localLevelFrameRef = useRef<number | null>(null);
|
|
63
|
+
const mountedRef = useRef(true);
|
|
64
|
+
const transcriptEndRef = useRef<HTMLDivElement | null>(null);
|
|
65
|
+
// Real mic analyser (not a fake random waveform) — lets us tell, just by
|
|
66
|
+
// watching the bars while talking, whether the browser is actually
|
|
67
|
+
// capturing audio from the mic at all, independent of whether the voice
|
|
68
|
+
// pipeline downstream (VAD/STT) picks it up.
|
|
69
|
+
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
70
|
+
const analyserCtxRef = useRef<AudioContext | null>(null);
|
|
71
|
+
|
|
72
|
+
// Smoothed orb scale/glow driven by the agent's remote audio level. Same
|
|
73
|
+
// spring feel used for the rest of the widget's motion (bouncy overshoot).
|
|
74
|
+
const orbLevel = useSpring(0, { stiffness: 220, damping: 18, mass: 0.6 });
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
mountedRef.current = true;
|
|
78
|
+
|
|
79
|
+
const widgetTokenHeader: Record<string, string> = originToken ? { "X-Widget-Token": originToken } : {};
|
|
80
|
+
let cancelled = false;
|
|
81
|
+
|
|
82
|
+
async function start() {
|
|
83
|
+
let room: Room | null = null;
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`${backendUrl}/api/widget/voice/token`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "Content-Type": "application/json", ...widgetTokenHeader },
|
|
88
|
+
body: JSON.stringify({ bot_id: botId, session_id: sessionId, visitor_timezone: visitorTimezone }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
let detail = "Couldn't start the call, please try again.";
|
|
93
|
+
if (res.status === 403) detail = "Voice chat isn't available right now.";
|
|
94
|
+
else if (res.status === 402) detail = "This assistant has reached its usage limit.";
|
|
95
|
+
else if (res.status === 429) detail = "Too many requests — please wait a moment and try again.";
|
|
96
|
+
else {
|
|
97
|
+
try {
|
|
98
|
+
const b = await res.json();
|
|
99
|
+
if (b?.detail) detail = b.detail;
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
if (!cancelled) {
|
|
103
|
+
setErrorMessage(detail);
|
|
104
|
+
setStatus("error");
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const data = await res.json();
|
|
110
|
+
const { token, livekit_url } = data;
|
|
111
|
+
|
|
112
|
+
room = new Room();
|
|
113
|
+
roomRef.current = room;
|
|
114
|
+
|
|
115
|
+
room.on(RoomEvent.Disconnected, () => {
|
|
116
|
+
if (!cancelled && mountedRef.current) setStatus((s) => (s === "error" ? s : "ended"));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
room.on(RoomEvent.ConnectionStateChanged, (state: ConnectionState) => {
|
|
120
|
+
if (cancelled || !mountedRef.current) return;
|
|
121
|
+
if (state === ConnectionState.Connected) {
|
|
122
|
+
setStatus((s) => (s === "agent-speaking" ? s : "connected"));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
room.on(RoomEvent.TrackSubscribed, (track: RemoteTrack, _pub, participant: RemoteParticipant) => {
|
|
127
|
+
if (track.kind === Track.Kind.Audio) {
|
|
128
|
+
const el = track.attach();
|
|
129
|
+
el.autoplay = true;
|
|
130
|
+
audioElRef.current = el;
|
|
131
|
+
document.body.appendChild(el);
|
|
132
|
+
void participant;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
room.on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {
|
|
137
|
+
track.detach().forEach((el) => el.remove());
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Live transcript — the agent worker already publishes STT/reply text
|
|
141
|
+
// over LiveKit's built-in transcription stream; each segment updates
|
|
142
|
+
// in place (by id) while interim, then locks in once `final`. Segments
|
|
143
|
+
// carry no explicit role, so attribute by participant: no `participant`
|
|
144
|
+
// (or the local one) means it's the visitor's own speech-to-text.
|
|
145
|
+
room.on(
|
|
146
|
+
RoomEvent.TranscriptionReceived,
|
|
147
|
+
(segments: TranscriptionSegment[], participant?: Participant) => {
|
|
148
|
+
if (cancelled || !mountedRef.current) return;
|
|
149
|
+
const speaker: "visitor" | "agent" =
|
|
150
|
+
!participant || participant.identity === room?.localParticipant?.identity ? "visitor" : "agent";
|
|
151
|
+
setTranscript((prev) => {
|
|
152
|
+
const next = [...prev];
|
|
153
|
+
for (const seg of segments) {
|
|
154
|
+
const idx = next.findIndex((e) => e.id === seg.id);
|
|
155
|
+
const entry: TranscriptEntry = { id: seg.id, speaker, text: seg.text, final: seg.final };
|
|
156
|
+
if (idx >= 0) next[idx] = entry;
|
|
157
|
+
else next.push(entry);
|
|
158
|
+
}
|
|
159
|
+
return next;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Drive the orb glow from whichever remote participant (the agent) is
|
|
165
|
+
// actively speaking; drive the "listening" bars from the visitor's own
|
|
166
|
+
// local audio level.
|
|
167
|
+
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
|
168
|
+
if (cancelled || !mountedRef.current) return;
|
|
169
|
+
const localIdentity = room?.localParticipant?.identity;
|
|
170
|
+
let remoteLevel = 0;
|
|
171
|
+
let localSpeaking = false;
|
|
172
|
+
for (const p of speakers) {
|
|
173
|
+
if (p.identity === localIdentity) {
|
|
174
|
+
localSpeaking = true;
|
|
175
|
+
} else {
|
|
176
|
+
remoteLevel = Math.max(remoteLevel, p.audioLevel ?? 0);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
orbLevel.set(Math.min(1, remoteLevel * 3.5));
|
|
180
|
+
setStatus((prev) => {
|
|
181
|
+
if (prev === "connecting" || prev === "requesting-mic" || prev === "error" || prev === "ended") return prev;
|
|
182
|
+
if (remoteLevel > 0.01) return "agent-speaking";
|
|
183
|
+
if (localSpeaking) return "listening";
|
|
184
|
+
return "connected";
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await room.connect(livekit_url, token);
|
|
189
|
+
if (cancelled) {
|
|
190
|
+
room.disconnect();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// getUserMedia can sit pending for a while if the visitor hasn't
|
|
194
|
+
// noticed/responded to the browser's permission prompt yet (easy to
|
|
195
|
+
// miss inside an embedded iframe) — show an explicit state for this
|
|
196
|
+
// rather than a generic "Connecting…" that looks stuck.
|
|
197
|
+
if (!cancelled && mountedRef.current) setStatus("requesting-mic");
|
|
198
|
+
try {
|
|
199
|
+
await room.localParticipant.setMicrophoneEnabled(true);
|
|
200
|
+
const pub = Array.from(room.localParticipant.audioTrackPublications.values())[0];
|
|
201
|
+
const mediaTrack = pub?.track?.mediaStreamTrack;
|
|
202
|
+
if (mediaTrack) {
|
|
203
|
+
const ctx = new AudioContext();
|
|
204
|
+
const source = ctx.createMediaStreamSource(new MediaStream([mediaTrack]));
|
|
205
|
+
const analyser = ctx.createAnalyser();
|
|
206
|
+
analyser.fftSize = 256;
|
|
207
|
+
analyser.smoothingTimeConstant = 0.6;
|
|
208
|
+
source.connect(analyser);
|
|
209
|
+
analyserCtxRef.current = ctx;
|
|
210
|
+
analyserRef.current = analyser;
|
|
211
|
+
}
|
|
212
|
+
} catch (micErr) {
|
|
213
|
+
console.error("Microphone permission failed:", micErr);
|
|
214
|
+
if (!cancelled && mountedRef.current) {
|
|
215
|
+
setErrorMessage(
|
|
216
|
+
"Microphone access is required for voice calls. Please allow microphone access in your browser and try again."
|
|
217
|
+
);
|
|
218
|
+
setStatus("error");
|
|
219
|
+
}
|
|
220
|
+
room.disconnect();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!cancelled && mountedRef.current) setStatus("connected");
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error("Voice call failed to start:", err);
|
|
226
|
+
if (!cancelled && mountedRef.current) {
|
|
227
|
+
setErrorMessage("Couldn't start the call, please try again.");
|
|
228
|
+
setStatus("error");
|
|
229
|
+
}
|
|
230
|
+
room?.disconnect();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
start();
|
|
235
|
+
|
|
236
|
+
return () => {
|
|
237
|
+
cancelled = true;
|
|
238
|
+
mountedRef.current = false;
|
|
239
|
+
const room = roomRef.current;
|
|
240
|
+
roomRef.current = null;
|
|
241
|
+
if (room) {
|
|
242
|
+
room.localParticipant.setMicrophoneEnabled(false).catch(() => {});
|
|
243
|
+
room.disconnect();
|
|
244
|
+
}
|
|
245
|
+
if (audioElRef.current) {
|
|
246
|
+
audioElRef.current.remove();
|
|
247
|
+
audioElRef.current = null;
|
|
248
|
+
}
|
|
249
|
+
analyserRef.current = null;
|
|
250
|
+
if (analyserCtxRef.current) {
|
|
251
|
+
analyserCtxRef.current.close().catch(() => {});
|
|
252
|
+
analyserCtxRef.current = null;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
// Auto-scroll the transcript to the newest line as it streams in.
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
261
|
+
}, [transcript]);
|
|
262
|
+
|
|
263
|
+
// Call duration timer, starts once connected.
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (status === "connecting" || status === "requesting-mic" || status === "error") return;
|
|
266
|
+
if (status === "ended") {
|
|
267
|
+
if (durationIntervalRef.current) clearInterval(durationIntervalRef.current);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!durationIntervalRef.current) {
|
|
271
|
+
durationIntervalRef.current = setInterval(() => setDuration((d) => d + 1), 1000);
|
|
272
|
+
}
|
|
273
|
+
return () => {
|
|
274
|
+
if (durationIntervalRef.current) {
|
|
275
|
+
clearInterval(durationIntervalRef.current);
|
|
276
|
+
durationIntervalRef.current = null;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}, [status]);
|
|
280
|
+
|
|
281
|
+
// Local mic level animation for the 14-bar "listening" waveform — now
|
|
282
|
+
// driven by a real AnalyserNode on the mic track (see analyserRef above)
|
|
283
|
+
// instead of a fake random animation. The "listening" transition itself
|
|
284
|
+
// comes from LiveKit's client-side local audioLevel (ActiveSpeakersChanged
|
|
285
|
+
// below), computed in-browser independent of the server VAD/STT pipeline —
|
|
286
|
+
// so whether this state is ever reached at all is itself diagnostic: if it
|
|
287
|
+
// never fires while you're actually talking, the browser isn't capturing
|
|
288
|
+
// usable mic audio in the first place.
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
if (status !== "listening") {
|
|
291
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
292
|
+
setLocalLevels(Array(WAVE_BAR_COUNT).fill(0));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
let stopped = false;
|
|
296
|
+
const bins = new Uint8Array(analyserRef.current?.frequencyBinCount ?? 128);
|
|
297
|
+
const tick = () => {
|
|
298
|
+
if (stopped) return;
|
|
299
|
+
const analyser = analyserRef.current;
|
|
300
|
+
if (analyser) {
|
|
301
|
+
analyser.getByteTimeDomainData(bins);
|
|
302
|
+
// RMS of the time-domain signal around its 128 midpoint — a real
|
|
303
|
+
// amplitude reading, not a synthetic animation.
|
|
304
|
+
let sumSquares = 0;
|
|
305
|
+
for (let i = 0; i < bins.length; i++) {
|
|
306
|
+
const centered = (bins[i] - 128) / 128;
|
|
307
|
+
sumSquares += centered * centered;
|
|
308
|
+
}
|
|
309
|
+
const rms = Math.sqrt(sumSquares / bins.length);
|
|
310
|
+
const boosted = Math.min(1, rms * 6);
|
|
311
|
+
const levels = Array.from({ length: WAVE_BAR_COUNT }, () => Math.min(1, boosted * (0.7 + Math.random() * 0.3)));
|
|
312
|
+
setLocalLevels(levels);
|
|
313
|
+
} else {
|
|
314
|
+
setLocalLevels(Array(WAVE_BAR_COUNT).fill(0));
|
|
315
|
+
}
|
|
316
|
+
localLevelFrameRef.current = requestAnimationFrame(tick);
|
|
317
|
+
};
|
|
318
|
+
tick();
|
|
319
|
+
return () => {
|
|
320
|
+
stopped = true;
|
|
321
|
+
if (localLevelFrameRef.current) cancelAnimationFrame(localLevelFrameRef.current);
|
|
322
|
+
};
|
|
323
|
+
}, [status]);
|
|
324
|
+
|
|
325
|
+
const toggleMute = async () => {
|
|
326
|
+
const room = roomRef.current;
|
|
327
|
+
if (!room) return;
|
|
328
|
+
const next = !muted;
|
|
329
|
+
await room.localParticipant.setMicrophoneEnabled(!next);
|
|
330
|
+
setMuted(next);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const handleHangup = () => {
|
|
334
|
+
const room = roomRef.current;
|
|
335
|
+
roomRef.current = null;
|
|
336
|
+
if (room) {
|
|
337
|
+
room.localParticipant.setMicrophoneEnabled(false).catch(() => {});
|
|
338
|
+
room.disconnect();
|
|
339
|
+
}
|
|
340
|
+
onClose();
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const transcriptMdComponents: Components = {
|
|
344
|
+
p: ({ children }) => <p className="mb-1 last:mb-0">{children}</p>,
|
|
345
|
+
ul: ({ children }) => <ul className="list-disc pl-4 mb-1 space-y-0.5">{children}</ul>,
|
|
346
|
+
ol: ({ children }) => <ol className="list-decimal pl-4 mb-1 space-y-0.5">{children}</ol>,
|
|
347
|
+
a: ({ href, children }) => (
|
|
348
|
+
<SafeMarkdownLink href={href} className="underline break-all" style={{ color: "currentColor" }}>
|
|
349
|
+
{children}
|
|
350
|
+
</SafeMarkdownLink>
|
|
351
|
+
),
|
|
352
|
+
code: ({ className, children, ...rest }) => {
|
|
353
|
+
const isBlock = className?.startsWith("language-");
|
|
354
|
+
if (!isBlock) return <code className="bg-black/10 dark:bg-white/10 px-1 py-0.5 rounded text-[10px] font-mono" {...rest}>{children}</code>;
|
|
355
|
+
return (
|
|
356
|
+
<pre className="bg-black/10 dark:bg-white/10 rounded-lg p-2 my-1 overflow-x-auto text-[10px] font-mono">
|
|
357
|
+
<code {...rest}>{children}</code>
|
|
358
|
+
</pre>
|
|
359
|
+
);
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const fmtDuration = (s: number) => {
|
|
364
|
+
const m = Math.floor(s / 60).toString().padStart(2, "0");
|
|
365
|
+
const sec = (s % 60).toString().padStart(2, "0");
|
|
366
|
+
return `${m}:${sec}`;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const statusLabel = (() => {
|
|
370
|
+
switch (status) {
|
|
371
|
+
case "connecting": return "Connecting…";
|
|
372
|
+
case "requesting-mic": return "Please allow microphone access…";
|
|
373
|
+
case "connected": return fmtDuration(duration);
|
|
374
|
+
case "listening": return "Listening…";
|
|
375
|
+
case "agent-speaking": return "Speaking…";
|
|
376
|
+
case "ended": return "Call ended";
|
|
377
|
+
case "error": return errorMessage || "Something went wrong";
|
|
378
|
+
default: return "";
|
|
379
|
+
}
|
|
380
|
+
})();
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<div className="flex-1 flex flex-col p-4 bg-card h-full min-h-0">
|
|
384
|
+
{status === "error" ? (
|
|
385
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-4">
|
|
386
|
+
<div className="size-12 rounded-full flex items-center justify-center bg-red-50 dark:bg-red-950/40">
|
|
387
|
+
<AlertCircle className="size-6 text-red-500" />
|
|
388
|
+
</div>
|
|
389
|
+
<p className="text-xs text-neutral-500 dark:text-neutral-400 max-w-[220px] leading-relaxed">{errorMessage}</p>
|
|
390
|
+
<motion.button
|
|
391
|
+
type="button"
|
|
392
|
+
whileTap={{ scale: 0.85 }}
|
|
393
|
+
transition={{ duration: 0.32, ease: [0.34, 1.56, 0.64, 1] }}
|
|
394
|
+
onClick={onClose}
|
|
395
|
+
className="px-4 py-2 rounded-full text-xs font-semibold text-white"
|
|
396
|
+
style={{ background: primaryColor }}
|
|
397
|
+
>
|
|
398
|
+
Close
|
|
399
|
+
</motion.button>
|
|
400
|
+
</div>
|
|
401
|
+
) : (
|
|
402
|
+
<>
|
|
403
|
+
{/* Compact status row — small orb + state text, replacing what used
|
|
404
|
+
to be a full-height centered orb, since the transcript below is
|
|
405
|
+
now the primary focus of the call view. */}
|
|
406
|
+
<div className="flex items-center gap-3 w-full pb-3 border-b border-neutral-100 dark:border-neutral-850 shrink-0">
|
|
407
|
+
<Orb status={status} level={orbLevel} primaryColor={primaryColor} compact />
|
|
408
|
+
<div className="flex-1 min-w-0">
|
|
409
|
+
{status === "listening" ? (
|
|
410
|
+
<div className="flex items-center gap-[3px] h-4" aria-hidden>
|
|
411
|
+
{localLevels.map((level, i) => (
|
|
412
|
+
<span
|
|
413
|
+
key={i}
|
|
414
|
+
className="w-0.5 rounded-full transition-[height] duration-[50ms] ease-out"
|
|
415
|
+
style={{ height: `${Math.max(3, level * 16)}px`, background: primaryColor }}
|
|
416
|
+
/>
|
|
417
|
+
))}
|
|
418
|
+
</div>
|
|
419
|
+
) : (
|
|
420
|
+
<p className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 tracking-wide truncate">
|
|
421
|
+
{(status === "connecting" || status === "requesting-mic") && (
|
|
422
|
+
<Loader2 className="inline size-3.5 animate-spin mr-1.5 -mt-0.5" />
|
|
423
|
+
)}
|
|
424
|
+
{statusLabel}
|
|
425
|
+
</p>
|
|
426
|
+
)}
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
{/* Live transcript — auto-scrolls to the newest line; interim
|
|
431
|
+
(not-yet-final) segments render with a bouncy typing indicator
|
|
432
|
+
instead of raw text jitter, then settle into place once final. */}
|
|
433
|
+
<div className="flex-1 min-h-0 w-full overflow-y-auto scrollbar-thin py-3 space-y-2.5">
|
|
434
|
+
{transcript.length === 0 ? (
|
|
435
|
+
<div className="h-full flex items-center justify-center">
|
|
436
|
+
<p className="text-[11px] text-neutral-400 dark:text-neutral-500 text-center px-6">
|
|
437
|
+
{status === "agent-speaking" || status === "listening" || status === "connected"
|
|
438
|
+
? "Say something — your conversation will appear here."
|
|
439
|
+
: ""}
|
|
440
|
+
</p>
|
|
441
|
+
</div>
|
|
442
|
+
) : (
|
|
443
|
+
<AnimatePresence initial={false}>
|
|
444
|
+
{transcript.map((entry) => (
|
|
445
|
+
<motion.div
|
|
446
|
+
key={entry.id}
|
|
447
|
+
initial={{ opacity: 0, y: 8, scale: 0.96 }}
|
|
448
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
449
|
+
transition={{ duration: 0.32, ease: [0.34, 1.56, 0.64, 1] }}
|
|
450
|
+
className={`flex ${entry.speaker === "visitor" ? "justify-end" : "justify-start"}`}
|
|
451
|
+
>
|
|
452
|
+
<div
|
|
453
|
+
className={`max-w-[80%] rounded-2xl px-3 py-2 text-xs leading-relaxed ${
|
|
454
|
+
entry.speaker === "visitor"
|
|
455
|
+
? "text-white rounded-br-md"
|
|
456
|
+
: "bg-neutral-100 dark:bg-neutral-850 text-neutral-800 dark:text-neutral-200 rounded-bl-md"
|
|
457
|
+
}`}
|
|
458
|
+
style={entry.speaker === "visitor" ? { background: primaryColor } : undefined}
|
|
459
|
+
>
|
|
460
|
+
{entry.text.trim() ? (
|
|
461
|
+
<>
|
|
462
|
+
<ReactMarkdown
|
|
463
|
+
remarkPlugins={[remarkGfm, remarkMath]}
|
|
464
|
+
rehypePlugins={[rehypeKatex]}
|
|
465
|
+
components={transcriptMdComponents}
|
|
466
|
+
>
|
|
467
|
+
{entry.text}
|
|
468
|
+
</ReactMarkdown>
|
|
469
|
+
{!entry.final && (
|
|
470
|
+
<span className="inline-block w-1 h-3 ml-0.5 -mb-0.5 bg-current opacity-60 animate-pulse" />
|
|
471
|
+
)}
|
|
472
|
+
</>
|
|
473
|
+
) : (
|
|
474
|
+
<span className="flex items-center gap-1 py-0.5" aria-label="typing">
|
|
475
|
+
<span className="size-1.5 rounded-full bg-current opacity-60 animate-bounce" />
|
|
476
|
+
<span className="size-1.5 rounded-full bg-current opacity-60 animate-bounce [animation-delay:150ms]" />
|
|
477
|
+
<span className="size-1.5 rounded-full bg-current opacity-60 animate-bounce [animation-delay:300ms]" />
|
|
478
|
+
</span>
|
|
479
|
+
)}
|
|
480
|
+
</div>
|
|
481
|
+
</motion.div>
|
|
482
|
+
))}
|
|
483
|
+
</AnimatePresence>
|
|
484
|
+
)}
|
|
485
|
+
<div ref={transcriptEndRef} />
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
<div className="flex items-center gap-4 pb-2 pt-1 shrink-0">
|
|
489
|
+
<motion.button
|
|
490
|
+
type="button"
|
|
491
|
+
whileTap={{ scale: 0.85 }}
|
|
492
|
+
transition={{ duration: 0.32, ease: [0.34, 1.56, 0.64, 1] }}
|
|
493
|
+
onClick={toggleMute}
|
|
494
|
+
disabled={status === "connecting" || status === "requesting-mic" || status === "ended"}
|
|
495
|
+
aria-label={muted ? "Unmute microphone" : "Mute microphone"}
|
|
496
|
+
className="size-12 rounded-full flex items-center justify-center border border-neutral-200 dark:border-neutral-800 text-neutral-600 dark:text-neutral-300 disabled:opacity-40"
|
|
497
|
+
>
|
|
498
|
+
{muted ? <MicOff className="size-5" /> : <Mic className="size-5" />}
|
|
499
|
+
</motion.button>
|
|
500
|
+
<motion.button
|
|
501
|
+
type="button"
|
|
502
|
+
whileTap={{ scale: 0.85 }}
|
|
503
|
+
transition={{ duration: 0.32, ease: [0.34, 1.56, 0.64, 1] }}
|
|
504
|
+
onClick={handleHangup}
|
|
505
|
+
aria-label="End call"
|
|
506
|
+
className="size-14 rounded-full flex items-center justify-center bg-red-500 text-white shadow-lg"
|
|
507
|
+
>
|
|
508
|
+
<PhoneOff className="size-6" />
|
|
509
|
+
</motion.button>
|
|
510
|
+
</div>
|
|
511
|
+
</>
|
|
512
|
+
)}
|
|
513
|
+
</div>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function Orb({
|
|
518
|
+
status,
|
|
519
|
+
level,
|
|
520
|
+
primaryColor,
|
|
521
|
+
compact = false,
|
|
522
|
+
}: {
|
|
523
|
+
status: CallStatus;
|
|
524
|
+
level: ReturnType<typeof useSpring>;
|
|
525
|
+
primaryColor: string;
|
|
526
|
+
compact?: boolean;
|
|
527
|
+
}) {
|
|
528
|
+
const [scale, setScale] = useState(1);
|
|
529
|
+
const [glow, setGlow] = useState(0);
|
|
530
|
+
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
const unsub = level.on("change", (v) => {
|
|
533
|
+
setScale(1 + v * 0.28);
|
|
534
|
+
setGlow(v);
|
|
535
|
+
});
|
|
536
|
+
return () => unsub();
|
|
537
|
+
}, [level]);
|
|
538
|
+
|
|
539
|
+
const isActive = status === "agent-speaking";
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<motion.div
|
|
543
|
+
animate={
|
|
544
|
+
isActive
|
|
545
|
+
? { scale }
|
|
546
|
+
: status === "connecting" || status === "requesting-mic"
|
|
547
|
+
? { scale: [1, 1.06, 1] }
|
|
548
|
+
: { scale: 1 }
|
|
549
|
+
}
|
|
550
|
+
transition={
|
|
551
|
+
isActive
|
|
552
|
+
? { duration: 0.32, ease: [0.34, 1.56, 0.64, 1] }
|
|
553
|
+
: { duration: 1.8, repeat: Infinity, ease: "easeInOut" }
|
|
554
|
+
}
|
|
555
|
+
className={`shrink-0 rounded-full flex items-center justify-center ${compact ? "size-9" : "size-28"}`}
|
|
556
|
+
style={{
|
|
557
|
+
background: `radial-gradient(circle at 35% 30%, ${primaryColor}dd, ${primaryColor}88)`,
|
|
558
|
+
boxShadow: `0 0 ${(compact ? 8 : 20) + (isActive ? glow * (compact ? 20 : 60) : compact ? 4 : 10)}px ${primaryColor}${isActive ? "aa" : "55"}`,
|
|
559
|
+
}}
|
|
560
|
+
>
|
|
561
|
+
<div className={`rounded-full bg-white/25 backdrop-blur-sm ${compact ? "size-5" : "size-16"}`} />
|
|
562
|
+
</motion.div>
|
|
563
|
+
);
|
|
564
|
+
}
|