@convai/web-sdk 1.7.0-beta.2 → 1.8.0-beta.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/core/ConvaiClient.d.ts +40 -1
- package/dist/core/ConvaiClient.d.ts.map +1 -1
- package/dist/core/ConvaiClient.js +276 -167
- package/dist/core/ConvaiClient.js.map +1 -1
- package/dist/core/connectRequest.d.ts +1 -1
- package/dist/core/connectRequest.d.ts.map +1 -1
- package/dist/core/connectRequest.js.map +1 -1
- package/dist/core/types.d.ts +50 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/react/components/rtc-widget/components/AudioVisualizer.d.ts.map +1 -1
- package/dist/react/components/rtc-widget/components/AudioVisualizer.js +28 -1
- package/dist/react/components/rtc-widget/components/AudioVisualizer.js.map +1 -1
- package/dist/react/components/rtc-widget/components/conviComponents/VoiceModeOverlay.d.ts.map +1 -1
- package/dist/react/components/rtc-widget/components/conviComponents/VoiceModeOverlay.js +93 -49
- package/dist/react/components/rtc-widget/components/conviComponents/VoiceModeOverlay.js.map +1 -1
- package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
- package/dist/react/hooks/useConvaiClient.js +1 -0
- package/dist/react/hooks/useConvaiClient.js.map +1 -1
- package/dist/vanilla/AudioRenderer.d.ts +3 -1
- package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
- package/dist/vanilla/AudioRenderer.js +2 -2
- package/dist/vanilla/AudioRenderer.js.map +1 -1
- package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
- package/dist/vanilla/ConvaiWidget.js +575 -111
- package/dist/vanilla/ConvaiWidget.js.map +1 -1
- package/dist/vanilla/icons.d.ts.map +1 -1
- package/dist/vanilla/icons.js +62 -15
- package/dist/vanilla/icons.js.map +1 -1
- package/dist/vanilla/index.d.ts +1 -1
- package/dist/vanilla/index.d.ts.map +1 -1
- package/dist/vanilla/index.js.map +1 -1
- package/dist/vanilla/styles.d.ts +18 -1
- package/dist/vanilla/styles.d.ts.map +1 -1
- package/dist/vanilla/styles.js +105 -29
- package/dist/vanilla/styles.js.map +1 -1
- package/dist/vanilla/types.d.ts +74 -0
- package/dist/vanilla/types.d.ts.map +1 -1
- package/dist/vanilla/types.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +14 -7
|
@@ -3,9 +3,104 @@
|
|
|
3
3
|
* Ports the React ConvaiWidget to vanilla TypeScript with DOM manipulation
|
|
4
4
|
*/
|
|
5
5
|
import { AudioRenderer } from "./AudioRenderer";
|
|
6
|
-
import { aeroTheme, injectGlobalStyles } from "./styles";
|
|
6
|
+
import { aeroTheme, injectGlobalStyles, injectKeyframes, } from "./styles";
|
|
7
7
|
import { Icons } from "./icons";
|
|
8
8
|
import { ConvaiClient } from "../core/ConvaiClient";
|
|
9
|
+
/**
|
|
10
|
+
* Positioning declarations for the widget root, keyed by `placement`. The
|
|
11
|
+
* `bottom-right` entry must stay byte-identical to the literals it replaces --
|
|
12
|
+
* the inline-style parity suite snapshots the resolved `cssText` and compares
|
|
13
|
+
* it to a committed fixture.
|
|
14
|
+
*/
|
|
15
|
+
/** Gap between a fixed-placement widget and the viewport edge it anchors to. */
|
|
16
|
+
const PLACEMENT_INSET_PX = 24;
|
|
17
|
+
const PLACEMENT_CSS = {
|
|
18
|
+
"bottom-right": `position: fixed; bottom: ${PLACEMENT_INSET_PX}px; right: ${PLACEMENT_INSET_PX}px;`,
|
|
19
|
+
"bottom-left": `position: fixed; bottom: ${PLACEMENT_INSET_PX}px; left: ${PLACEMENT_INSET_PX}px;`,
|
|
20
|
+
"top-right": `position: fixed; top: ${PLACEMENT_INSET_PX}px; right: ${PLACEMENT_INSET_PX}px;`,
|
|
21
|
+
"top-left": `position: fixed; top: ${PLACEMENT_INSET_PX}px; left: ${PLACEMENT_INSET_PX}px;`,
|
|
22
|
+
inline: "position: relative;",
|
|
23
|
+
};
|
|
24
|
+
/** The open panel's preferred size. Capped by `PANEL_MAX_*` — see below. */
|
|
25
|
+
const PANEL_W_PX = 400;
|
|
26
|
+
const PANEL_H_PX = 600;
|
|
27
|
+
/**
|
|
28
|
+
* A ceiling on the open panel, so it can never be clipped by the viewport.
|
|
29
|
+
*
|
|
30
|
+
* The panel used to be a flat 400x600. The widget is `position: fixed`
|
|
31
|
+
* anchored `PLACEMENT_INSET_PX` from one edge, so on any viewport narrower
|
|
32
|
+
* than 448px — or shorter than 648px — the panel ran past the opposite edge
|
|
33
|
+
* and was silently cut off, with no scrollbar to reveal it, because fixed
|
|
34
|
+
* elements do not contribute to scrollable overflow. That is most phones: at
|
|
35
|
+
* 393px (iPhone 15 Pro) 30px of the panel sat off-screen, at 360px 63px did,
|
|
36
|
+
* and in landscape 233px of it — the entire header, including the close
|
|
37
|
+
* button — sat above the viewport, leaving nothing on screen to dismiss it.
|
|
38
|
+
*
|
|
39
|
+
* Expressed as `max-width`/`max-height` rather than folding the cap into the
|
|
40
|
+
* width with `min()`. Both compute the same thing, but they fail differently:
|
|
41
|
+
* a parser that does not know `min()` drops the whole declaration and the
|
|
42
|
+
* panel is left with no width at all, which is worse than the bug. An unknown
|
|
43
|
+
* `max-width` drops only the ceiling and leaves the previous behaviour intact.
|
|
44
|
+
* (Not hypothetical — happy-dom, which this package's own tests run in, parses
|
|
45
|
+
* the `max-width` form and discards the `min()` one.)
|
|
46
|
+
*
|
|
47
|
+
* Dynamic viewport units so mobile browser chrome is accounted for as it shows
|
|
48
|
+
* and hides, rather than sizing against a viewport the user cannot fully see.
|
|
49
|
+
* Applied to the container permanently rather than per state: the collapsed
|
|
50
|
+
* bubble is far below the ceiling, so it is unaffected.
|
|
51
|
+
*/
|
|
52
|
+
const PANEL_MAX_W = `calc(100dvw - ${PLACEMENT_INSET_PX * 2}px)`;
|
|
53
|
+
const PANEL_MAX_H = `calc(100dvh - ${PLACEMENT_INSET_PX * 2}px)`;
|
|
54
|
+
/** `convai-error{code:'mic-permission'}` per the embed's error-handling contract. */
|
|
55
|
+
const MIC_PERMISSION_ERROR_CODE = "mic-permission";
|
|
56
|
+
/**
|
|
57
|
+
* Distinguishes a microphone-permission denial from any other audio failure.
|
|
58
|
+
*
|
|
59
|
+
* Traced (not assumed): `client.audioControls.unmuteAudio()` delegates to
|
|
60
|
+
* `AudioManager.enableAudio()`, which calls
|
|
61
|
+
* `room.localParticipant.setMicrophoneEnabled(true)`. That's LiveKit's
|
|
62
|
+
* `setTrackEnabled()`, which on failure does `throw e` unchanged, from a
|
|
63
|
+
* `catch` around `createLocalTracks()`, which itself does `throw e` unchanged
|
|
64
|
+
* around `navigator.mediaDevices.getUserMedia()`. `AudioManager.enableAudio()`
|
|
65
|
+
* also just does `throw error` in its own catch. Nothing on that path wraps or
|
|
66
|
+
* replaces the error, so a user's permission denial reaches here as the exact
|
|
67
|
+
* `DOMException` `getUserMedia()` produced, with `.name === 'NotAllowedError'`
|
|
68
|
+
* (`'PermissionDeniedError'` is the old pre-spec name some browsers used).
|
|
69
|
+
*/
|
|
70
|
+
function isMicPermissionDenial(error) {
|
|
71
|
+
const name = error?.name;
|
|
72
|
+
return name === "NotAllowedError" || name === "PermissionDeniedError";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Wraps a mic-permission denial so a consumer of the client's `error`
|
|
76
|
+
* channel can tell it apart from a generic failure without knowing anything
|
|
77
|
+
* about `DOMException` internals -- it only has to check `error.code`. The
|
|
78
|
+
* original error is preserved on `.cause` for anyone who wants the detail.
|
|
79
|
+
*/
|
|
80
|
+
function toMicPermissionError(cause) {
|
|
81
|
+
const err = new Error("Microphone access was denied. Continuing in text-only mode.");
|
|
82
|
+
err.name = "ConvaiMicPermissionError";
|
|
83
|
+
err.code = MIC_PERMISSION_ERROR_CODE;
|
|
84
|
+
err.cause = cause;
|
|
85
|
+
return err;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Routes a failed `enableAudio()`/`unmuteAudio()` call onto the client's
|
|
89
|
+
* *existing* `error` channel -- the same one `ConvaiClient` already uses --
|
|
90
|
+
* instead of letting it become an unhandled rejection (the click-handler
|
|
91
|
+
* site) or vanish into a bare `console.error` (the auto-voice-mode sites).
|
|
92
|
+
* `console.error` is kept alongside for local debugging; this just adds the
|
|
93
|
+
* event emission next to it. `client.emit` is called defensively since it
|
|
94
|
+
* isn't part of the public `IConvaiClient` surface -- see the `emit?` field
|
|
95
|
+
* on `VanillaWidgetOptions.convaiClient` in `./types.ts`.
|
|
96
|
+
*/
|
|
97
|
+
function reportMicFailure(client, logLabel, error) {
|
|
98
|
+
console.error(logLabel, error);
|
|
99
|
+
const payload = isMicPermissionDenial(error)
|
|
100
|
+
? toMicPermissionError(error)
|
|
101
|
+
: error;
|
|
102
|
+
client.emit?.("error", payload);
|
|
103
|
+
}
|
|
9
104
|
/**
|
|
10
105
|
* Create a Convai chat widget in the specified container
|
|
11
106
|
*
|
|
@@ -42,6 +137,21 @@ import { ConvaiClient } from "../core/ConvaiClient";
|
|
|
42
137
|
* ```
|
|
43
138
|
*/
|
|
44
139
|
export function createConvaiWidget(container, options) {
|
|
140
|
+
/**
|
|
141
|
+
* Where global styles and `@keyframes` are injected.
|
|
142
|
+
*
|
|
143
|
+
* An explicit `options.styleTarget` wins and is used verbatim. Otherwise we
|
|
144
|
+
* fall back to deriving it from the container's current root — which is what
|
|
145
|
+
* every existing caller relies on, but only works when the container is
|
|
146
|
+
* already attached to its final root. A caller that builds a detached
|
|
147
|
+
* subtree and appends it to a shadow root afterwards must pass
|
|
148
|
+
* `styleTarget` explicitly, or the styles land in `document.head` and the
|
|
149
|
+
* shadow tree renders unstyled with no error.
|
|
150
|
+
*/
|
|
151
|
+
const styleTarget = options.styleTarget ??
|
|
152
|
+
(container.getRootNode() instanceof ShadowRoot
|
|
153
|
+
? container.getRootNode()
|
|
154
|
+
: document);
|
|
45
155
|
// Create client if not provided - guarantee it's defined
|
|
46
156
|
const client = options.convaiClient ||
|
|
47
157
|
(() => {
|
|
@@ -61,8 +171,14 @@ export function createConvaiWidget(container, options) {
|
|
|
61
171
|
});
|
|
62
172
|
})();
|
|
63
173
|
const { showVideo = true, showScreenShare = true, defaultVoiceMode = true, onConnect, onDisconnect, onMessage, } = options;
|
|
174
|
+
// Unknown/typo'd values fall back to the default rather than throwing --
|
|
175
|
+
// a bad `placement` attribute on a customer's page must not blank the
|
|
176
|
+
// widget.
|
|
177
|
+
const placement = options.placement && options.placement in PLACEMENT_CSS
|
|
178
|
+
? options.placement
|
|
179
|
+
: "bottom-right";
|
|
64
180
|
// Inject global styles
|
|
65
|
-
injectGlobalStyles();
|
|
181
|
+
injectGlobalStyles(styleTarget);
|
|
66
182
|
// State
|
|
67
183
|
let isOpen = false;
|
|
68
184
|
let isSettingsOpen = false;
|
|
@@ -76,6 +192,22 @@ export function createConvaiWidget(container, options) {
|
|
|
76
192
|
let hasEnteredDefaultVoiceMode = false;
|
|
77
193
|
/** True after we have connected at least once; used to call reconnect() instead of connect() when opening after disconnect */
|
|
78
194
|
let hasConnectedBefore = false;
|
|
195
|
+
/**
|
|
196
|
+
* True from the moment an open is requested on a *disconnected* client
|
|
197
|
+
* until that open either completes or is cancelled.
|
|
198
|
+
*
|
|
199
|
+
* `isOpen` alone cannot express this: the first open awaits a real
|
|
200
|
+
* `connect()` (0.5-3s in production) and only flips `isOpen` afterwards, so
|
|
201
|
+
* for that whole window the widget is neither open nor idle. Without this
|
|
202
|
+
* flag `close()` sees `isOpen === false` and no-ops -- and the connect then
|
|
203
|
+
* pops the panel open behind the user -- while `toggle()` sees the same
|
|
204
|
+
* `false`, starts a *second* open, and lands on the already-connecting
|
|
205
|
+
* branch that opens immediately. Every entry point below treats
|
|
206
|
+
* `pendingOpen` as "open" for the purpose of deciding what a close/toggle
|
|
207
|
+
* means, and `handleToggle` re-checks it before committing `setIsOpen(true)`
|
|
208
|
+
* so a cancellation during the await is honoured.
|
|
209
|
+
*/
|
|
210
|
+
let pendingOpen = false;
|
|
79
211
|
// DOM elements (will be created below)
|
|
80
212
|
let rootElement;
|
|
81
213
|
let morphingContainer;
|
|
@@ -89,6 +221,13 @@ export function createConvaiWidget(container, options) {
|
|
|
89
221
|
let settingsTray;
|
|
90
222
|
let floatingVideo;
|
|
91
223
|
let voiceModeOverlay;
|
|
224
|
+
let connectingOverlay;
|
|
225
|
+
/**
|
|
226
|
+
* Elements captured at creation. IDs are scoped per DOM tree, so
|
|
227
|
+
* document.getElementById() returns null when the widget is mounted
|
|
228
|
+
* inside a shadow root. Every element here is created in this file.
|
|
229
|
+
*/
|
|
230
|
+
const refs = {};
|
|
92
231
|
// Audio Analysis State
|
|
93
232
|
let audioContext = null;
|
|
94
233
|
let analyzer = null;
|
|
@@ -97,14 +236,18 @@ export function createConvaiWidget(container, options) {
|
|
|
97
236
|
let source = null;
|
|
98
237
|
// Fetch character info - matches React useCharacterInfo hook
|
|
99
238
|
const fetchCharacterInfo = async () => {
|
|
100
|
-
|
|
239
|
+
// Prefer authToken over apiKey, matching the precedence ConvaiClient
|
|
240
|
+
// uses when building /connect headers -- a client carrying both must
|
|
241
|
+
// behave identically in both places.
|
|
242
|
+
const credential = client.authToken ?? client.apiKey;
|
|
243
|
+
if (!credential || !client.characterId)
|
|
101
244
|
return;
|
|
102
245
|
try {
|
|
103
246
|
const response = await fetch("https://api.convai.com/character/get", {
|
|
104
247
|
method: "POST",
|
|
105
248
|
headers: {
|
|
106
249
|
"Content-Type": "application/json",
|
|
107
|
-
"API-AUTH-TOKEN":
|
|
250
|
+
"API-AUTH-TOKEN": credential,
|
|
108
251
|
},
|
|
109
252
|
body: JSON.stringify({ charID: client.characterId }),
|
|
110
253
|
});
|
|
@@ -129,23 +272,34 @@ export function createConvaiWidget(container, options) {
|
|
|
129
272
|
rootElement = document.createElement("div");
|
|
130
273
|
rootElement.className = "convai-widget";
|
|
131
274
|
rootElement.style.cssText = `
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
z-index: ${aeroTheme.zIndex.modal};
|
|
136
|
-
font-family: ${aeroTheme.typography.fontFamily.primary};
|
|
275
|
+
${PLACEMENT_CSS[placement]}
|
|
276
|
+
z-index: var(--convai-z-index, ${aeroTheme.zIndex.modal});
|
|
277
|
+
font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.primary});
|
|
137
278
|
`;
|
|
138
279
|
// Morphing container
|
|
139
280
|
morphingContainer = document.createElement("div");
|
|
281
|
+
// `part="launcher"` is precise only while collapsed: this same element
|
|
282
|
+
// morphs into the open panel (400x600, capped to the viewport — see PANEL_MAX_W)
|
|
283
|
+
// and carries that panel's background,
|
|
284
|
+
// radius and shadow. See the `part="panel"` call site below for the full
|
|
285
|
+
// semantics a host needs to know.
|
|
286
|
+
morphingContainer.setAttribute("part", "launcher");
|
|
287
|
+
// NOTE on border-radius: deliberately NOT themable. `--convai-radius`
|
|
288
|
+
// drives the open panel only. One token cannot serve both geometries --
|
|
289
|
+
// `--convai-radius: 8px` would squircle the circular launcher, and `50%`
|
|
290
|
+
// would turn the panel into an ellipse. A dedicated
|
|
291
|
+
// `--convai-launcher-radius` can be added additively later if wanted.
|
|
140
292
|
morphingContainer.style.cssText = `
|
|
141
293
|
position: relative;
|
|
142
|
-
width:
|
|
143
|
-
height:
|
|
144
|
-
|
|
294
|
+
width: var(--convai-launcher-size, 64px);
|
|
295
|
+
height: var(--convai-launcher-size, 64px);
|
|
296
|
+
max-width: ${PANEL_MAX_W};
|
|
297
|
+
max-height: ${PANEL_MAX_H};
|
|
298
|
+
background: var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop});
|
|
145
299
|
backdrop-filter: ${aeroTheme.glass.backdrop};
|
|
146
300
|
border: ${aeroTheme.glass.border};
|
|
147
301
|
border-radius: 50%;
|
|
148
|
-
box-shadow: ${aeroTheme.shadows.glass};
|
|
302
|
+
box-shadow: var(--convai-shadow, ${aeroTheme.shadows.glass});
|
|
149
303
|
transition: all 0.3s ease-in-out;
|
|
150
304
|
overflow: hidden;
|
|
151
305
|
display: flex;
|
|
@@ -166,10 +320,39 @@ export function createConvaiWidget(container, options) {
|
|
|
166
320
|
transform: scale(1);
|
|
167
321
|
`;
|
|
168
322
|
const convaiLogo = Icons.ConvaiLogo("xl", "idle");
|
|
169
|
-
convaiLogo.style.color = aeroTheme.colors.convai.light
|
|
323
|
+
convaiLogo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
|
|
170
324
|
buttonContent.appendChild(convaiLogo);
|
|
171
325
|
// Chat content
|
|
172
326
|
chatContent = document.createElement("div");
|
|
327
|
+
// ----------------------------------------------------------------------
|
|
328
|
+
// `launcher` / `panel` part semantics. Read before writing theming docs.
|
|
329
|
+
//
|
|
330
|
+
// This widget morphs rather than swapping trees: `morphingContainer` IS
|
|
331
|
+
// the 64px circle AND the open panel, animating between them (see
|
|
332
|
+
// setIsOpen). It therefore owns every painted surface property of both
|
|
333
|
+
// states -- background, border, border-radius, box-shadow. `buttonContent`
|
|
334
|
+
// and `chatContent` are transparent `inset: 0` overlays that only
|
|
335
|
+
// cross-fade their contents.
|
|
336
|
+
//
|
|
337
|
+
// So, precisely:
|
|
338
|
+
// ::part(launcher) -> morphingContainer. Paints the collapsed circle
|
|
339
|
+
// AND the open panel; there is no rule that hits
|
|
340
|
+
// only one. This is the surface part.
|
|
341
|
+
// ::part(panel) -> chatContent. The open state's *content layer*:
|
|
342
|
+
// useful for padding, colour, font, opacity. It has
|
|
343
|
+
// no background, no border and no radius of its
|
|
344
|
+
// own, so `::part(panel){border-radius}` does
|
|
345
|
+
// nothing -- set radius via ::part(launcher) or
|
|
346
|
+
// `--convai-radius` (which is panel-scoped).
|
|
347
|
+
//
|
|
348
|
+
// Assessed and deliberately not "fixed": moving `launcher` onto
|
|
349
|
+
// buttonContent would make `::part(launcher){background}` state-specific
|
|
350
|
+
// but would silently break `border-radius` and `box-shadow` on it, since
|
|
351
|
+
// buttonContent is clipped by the container's own radius + overflow:
|
|
352
|
+
// hidden. Correcting this properly needs a separate launcher element
|
|
353
|
+
// instead of a morph -- a rendering change, out of scope here.
|
|
354
|
+
// ----------------------------------------------------------------------
|
|
355
|
+
chatContent.setAttribute("part", "panel");
|
|
173
356
|
chatContent.style.cssText = `
|
|
174
357
|
position: absolute;
|
|
175
358
|
inset: 0;
|
|
@@ -190,11 +373,19 @@ export function createConvaiWidget(container, options) {
|
|
|
190
373
|
contentElement.style.cssText = `
|
|
191
374
|
flex: 1;
|
|
192
375
|
overflow-y: auto;
|
|
193
|
-
padding:
|
|
376
|
+
padding: 16px;
|
|
194
377
|
background: transparent;
|
|
378
|
+
position: relative;
|
|
195
379
|
`;
|
|
196
380
|
messageListElement = createMessageList();
|
|
197
381
|
contentElement.appendChild(messageListElement);
|
|
382
|
+
// Connecting overlay -- shown until the bot is ready. Appended last, not
|
|
383
|
+
// to match DOM order (position: absolute + z-index: 100, set inside
|
|
384
|
+
// createConnectingOverlay, is what keeps it on top regardless of source
|
|
385
|
+
// order) but so inserting it doesn't shift every existing sibling's
|
|
386
|
+
// index-based path in the inline-style-parity fixture.
|
|
387
|
+
connectingOverlay = createConnectingOverlay();
|
|
388
|
+
contentElement.appendChild(connectingOverlay);
|
|
198
389
|
// Footer
|
|
199
390
|
footerElement = createFooter();
|
|
200
391
|
chatContent.appendChild(headerElement);
|
|
@@ -211,7 +402,7 @@ export function createConvaiWidget(container, options) {
|
|
|
211
402
|
container.appendChild(floatingVideo);
|
|
212
403
|
container.appendChild(rootElement);
|
|
213
404
|
// Event listeners
|
|
214
|
-
morphingContainer.addEventListener("click",
|
|
405
|
+
morphingContainer.addEventListener("click", handleLauncherClick);
|
|
215
406
|
};
|
|
216
407
|
// Create Voice Mode Overlay
|
|
217
408
|
const createVoiceModeOverlay = () => {
|
|
@@ -222,13 +413,13 @@ export function createConvaiWidget(container, options) {
|
|
|
222
413
|
left: 50%;
|
|
223
414
|
transform: translate(-50%, -50%);
|
|
224
415
|
text-align: center;
|
|
225
|
-
padding:
|
|
416
|
+
padding: 16px;
|
|
226
417
|
z-index: 10;
|
|
227
418
|
pointer-events: auto;
|
|
228
419
|
display: none;
|
|
229
420
|
flex-direction: column;
|
|
230
421
|
align-items: center;
|
|
231
|
-
gap:
|
|
422
|
+
gap: 24px;
|
|
232
423
|
`;
|
|
233
424
|
// Bars Container
|
|
234
425
|
const barsContainer = document.createElement("div");
|
|
@@ -260,15 +451,17 @@ export function createConvaiWidget(container, options) {
|
|
|
260
451
|
const statusContainer = document.createElement("div");
|
|
261
452
|
const statusTitle = document.createElement("div");
|
|
262
453
|
statusTitle.id = "voice-mode-title";
|
|
454
|
+
refs.voiceModeTitle = statusTitle;
|
|
263
455
|
statusTitle.style.cssText = `
|
|
264
456
|
font-size: 14px;
|
|
265
457
|
font-weight: 500;
|
|
266
|
-
color: ${aeroTheme.colors.text.primary};
|
|
267
|
-
margin-bottom:
|
|
458
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
459
|
+
margin-bottom: 8px;
|
|
268
460
|
`;
|
|
269
461
|
statusTitle.textContent = "Voice Only Mode";
|
|
270
462
|
const statusSubtitle = document.createElement("div");
|
|
271
463
|
statusSubtitle.id = "voice-mode-subtitle";
|
|
464
|
+
refs.voiceModeSubtitle = statusSubtitle;
|
|
272
465
|
statusSubtitle.style.cssText = `
|
|
273
466
|
font-size: 12px;
|
|
274
467
|
color: ${aeroTheme.colors.text.secondary};
|
|
@@ -279,6 +472,79 @@ export function createConvaiWidget(container, options) {
|
|
|
279
472
|
overlay.appendChild(statusContainer);
|
|
280
473
|
return overlay;
|
|
281
474
|
};
|
|
475
|
+
// Create Connecting Overlay -- matches React's inline AnimatePresence
|
|
476
|
+
// block exactly (ConvaiWidget.tsx:681-737): shown whenever
|
|
477
|
+
// `isConnected && !isBotReady`, covering just the content area (not the
|
|
478
|
+
// header/footer) so the chrome stays interactive while the character
|
|
479
|
+
// joins. Persistent in the DOM like voiceModeOverlay above -- toggled via
|
|
480
|
+
// opacity/pointer-events rather than mounted/unmounted, since this file has
|
|
481
|
+
// no AnimatePresence-style exit-animation machinery. Visibility is driven
|
|
482
|
+
// by updateConnectingOverlay().
|
|
483
|
+
const createConnectingOverlay = () => {
|
|
484
|
+
const overlay = document.createElement("div");
|
|
485
|
+
overlay.id = "convai-connecting-overlay";
|
|
486
|
+
overlay.style.cssText = `
|
|
487
|
+
position: absolute;
|
|
488
|
+
inset: 0;
|
|
489
|
+
z-index: 100;
|
|
490
|
+
display: flex;
|
|
491
|
+
flex-direction: column;
|
|
492
|
+
align-items: center;
|
|
493
|
+
justify-content: center;
|
|
494
|
+
gap: 16px;
|
|
495
|
+
background: rgba(255, 255, 255, 0.95);
|
|
496
|
+
opacity: 0;
|
|
497
|
+
pointer-events: none;
|
|
498
|
+
transition: opacity 0.3s;
|
|
499
|
+
`;
|
|
500
|
+
// Keyframes live in the shadow-safe injector (styles.ts), not
|
|
501
|
+
// document.head -- @keyframes defined there do not resolve for shadow
|
|
502
|
+
// content. Idempotent; also called from createSettingsTray().
|
|
503
|
+
injectKeyframes(styleTarget);
|
|
504
|
+
// Spinning logo: a full turn every 2s, linear, infinite.
|
|
505
|
+
const spinWrapper = document.createElement("div");
|
|
506
|
+
spinWrapper.className = "convai-connecting-spin";
|
|
507
|
+
spinWrapper.style.cssText = `
|
|
508
|
+
display: inline-flex;
|
|
509
|
+
animation: convaiConnectingSpin 2s linear infinite;
|
|
510
|
+
`;
|
|
511
|
+
const logo = Icons.ConvaiLogo("xl", "connecting");
|
|
512
|
+
// Themable, like every other accent usage in this file (see
|
|
513
|
+
// theming.test.ts) -- var() with the exact literal React passes
|
|
514
|
+
// (`aeroTheme.colors.convai.light`) as the fallback, so an unset
|
|
515
|
+
// --convai-accent renders identically to the React value.
|
|
516
|
+
logo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
|
|
517
|
+
spinWrapper.appendChild(logo);
|
|
518
|
+
overlay.appendChild(spinWrapper);
|
|
519
|
+
// Pulsing "Connecting to {characterName}..." label.
|
|
520
|
+
const label = document.createElement("div");
|
|
521
|
+
label.id = "convai-connecting-text";
|
|
522
|
+
label.className = "convai-connecting-pulse";
|
|
523
|
+
label.style.cssText = `
|
|
524
|
+
font-size: 14px;
|
|
525
|
+
font-weight: 500;
|
|
526
|
+
color: ${aeroTheme.colors.text.secondary};
|
|
527
|
+
animation: convaiConnectingPulse 2s ease-in-out infinite;
|
|
528
|
+
`;
|
|
529
|
+
refs.connectingLabel = label;
|
|
530
|
+
overlay.appendChild(label);
|
|
531
|
+
return overlay;
|
|
532
|
+
};
|
|
533
|
+
/** Shows/hides the connecting overlay and refreshes its label. Called
|
|
534
|
+
* from updateHeader() so every existing call site (stateChange, botReady,
|
|
535
|
+
* connect/disconnect via stateChange, character-info fetch, ...) stays in
|
|
536
|
+
* sync automatically. */
|
|
537
|
+
const updateConnectingOverlay = () => {
|
|
538
|
+
if (!connectingOverlay)
|
|
539
|
+
return;
|
|
540
|
+
const label = refs.connectingLabel;
|
|
541
|
+
if (label) {
|
|
542
|
+
label.textContent = `Connecting to ${characterName}...`;
|
|
543
|
+
}
|
|
544
|
+
const shouldShow = client.state.isConnected && !client.isBotReady;
|
|
545
|
+
connectingOverlay.style.opacity = shouldShow ? "1" : "0";
|
|
546
|
+
connectingOverlay.style.pointerEvents = shouldShow ? "auto" : "none";
|
|
547
|
+
};
|
|
282
548
|
// Audio Analysis State for Voice Mode
|
|
283
549
|
let audioLevels = Array(40).fill(0);
|
|
284
550
|
let targetLevels = Array(40).fill(0.05);
|
|
@@ -295,14 +561,14 @@ export function createConvaiWidget(container, options) {
|
|
|
295
561
|
// Update colors based on state
|
|
296
562
|
bars.forEach((bar) => {
|
|
297
563
|
bar.style.backgroundColor = isTalking
|
|
298
|
-
? aeroTheme.colors.convai.light
|
|
564
|
+
? `var(--convai-accent, ${aeroTheme.colors.convai.light})`
|
|
299
565
|
: isListening
|
|
300
566
|
? aeroTheme.colors.text.primary
|
|
301
567
|
: aeroTheme.colors.neutral[400];
|
|
302
568
|
});
|
|
303
569
|
// Update Text
|
|
304
|
-
const title =
|
|
305
|
-
const subtitle =
|
|
570
|
+
const title = refs.voiceModeTitle;
|
|
571
|
+
const subtitle = refs.voiceModeSubtitle;
|
|
306
572
|
if (title) {
|
|
307
573
|
title.textContent = isTalking
|
|
308
574
|
? "Character Speaking..."
|
|
@@ -408,7 +674,15 @@ export function createConvaiWidget(container, options) {
|
|
|
408
674
|
updateAudioBars();
|
|
409
675
|
}
|
|
410
676
|
catch (e) {
|
|
411
|
-
|
|
677
|
+
// Fourth mic-acquiring call site: this getUserMedia() is independent
|
|
678
|
+
// of client.audioControls (it's a separate analyser-only stream, not
|
|
679
|
+
// routed through AudioManager), but a denial here is the identical
|
|
680
|
+
// failure mode -- most commonly a mid-session permission revocation,
|
|
681
|
+
// since by the time voice mode is entered the mic has usually already
|
|
682
|
+
// been granted via unmuteAudio(). Same helper, same channel, so a
|
|
683
|
+
// consumer sees one consistent signal regardless of which internal
|
|
684
|
+
// path acquired the mic.
|
|
685
|
+
reportMicFailure(client, "Audio analysis setup failed:", e);
|
|
412
686
|
}
|
|
413
687
|
};
|
|
414
688
|
const stopAudioAnalysis = () => {
|
|
@@ -425,13 +699,14 @@ export function createConvaiWidget(container, options) {
|
|
|
425
699
|
// Create header
|
|
426
700
|
const createHeader = () => {
|
|
427
701
|
const header = document.createElement("div");
|
|
702
|
+
header.setAttribute("part", "header");
|
|
428
703
|
header.style.cssText = `
|
|
429
704
|
display: flex;
|
|
430
705
|
align-items: center;
|
|
431
706
|
justify-content: space-between;
|
|
432
|
-
padding:
|
|
707
|
+
padding: 16px;
|
|
433
708
|
border-bottom: 1px solid ${aeroTheme.colors.neutral[200]};
|
|
434
|
-
background: white;
|
|
709
|
+
background: var(--convai-panel-bg, white);
|
|
435
710
|
position: relative;
|
|
436
711
|
`;
|
|
437
712
|
// Close button on the left
|
|
@@ -439,10 +714,10 @@ export function createConvaiWidget(container, options) {
|
|
|
439
714
|
const chevronIcon = Icons.ChevronDown("md");
|
|
440
715
|
closeButton.appendChild(chevronIcon);
|
|
441
716
|
closeButton.style.cssText = `
|
|
442
|
-
font-size:
|
|
717
|
+
font-size: 20px;
|
|
443
718
|
color: ${aeroTheme.colors.text.secondary};
|
|
444
719
|
cursor: pointer;
|
|
445
|
-
padding:
|
|
720
|
+
padding: 4px;
|
|
446
721
|
transition: ${aeroTheme.transitions.fast};
|
|
447
722
|
background: transparent;
|
|
448
723
|
border: none;
|
|
@@ -459,21 +734,22 @@ export function createConvaiWidget(container, options) {
|
|
|
459
734
|
transform: translateX(-50%);
|
|
460
735
|
display: flex;
|
|
461
736
|
align-items: center;
|
|
462
|
-
gap:
|
|
737
|
+
gap: 8px;
|
|
463
738
|
font-size: ${aeroTheme.typography.fontSize.base};
|
|
464
739
|
font-weight: ${aeroTheme.typography.fontWeight.semibold};
|
|
465
|
-
color: ${aeroTheme.colors.text.primary};
|
|
740
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
466
741
|
`;
|
|
467
742
|
titleSection.id = "convai-widget-title";
|
|
743
|
+
refs.widgetTitle = titleSection;
|
|
468
744
|
// Settings button on the right
|
|
469
745
|
const settingsButton = document.createElement("button");
|
|
470
746
|
const moreIcon = Icons.MoreVertical("md");
|
|
471
747
|
settingsButton.appendChild(moreIcon);
|
|
472
748
|
settingsButton.style.cssText = `
|
|
473
|
-
font-size:
|
|
749
|
+
font-size: 24px;
|
|
474
750
|
color: ${aeroTheme.colors.text.secondary};
|
|
475
751
|
cursor: pointer;
|
|
476
|
-
padding:
|
|
752
|
+
padding: 4px;
|
|
477
753
|
transition: ${aeroTheme.transitions.fast};
|
|
478
754
|
background: transparent;
|
|
479
755
|
border: none;
|
|
@@ -490,7 +766,7 @@ export function createConvaiWidget(container, options) {
|
|
|
490
766
|
};
|
|
491
767
|
// Update header with character info
|
|
492
768
|
const updateHeader = () => {
|
|
493
|
-
const titleSection =
|
|
769
|
+
const titleSection = refs.widgetTitle;
|
|
494
770
|
if (!titleSection)
|
|
495
771
|
return;
|
|
496
772
|
titleSection.innerHTML = "";
|
|
@@ -499,8 +775,8 @@ export function createConvaiWidget(container, options) {
|
|
|
499
775
|
img.src = characterImage;
|
|
500
776
|
img.alt = characterName;
|
|
501
777
|
img.style.cssText = `
|
|
502
|
-
width:
|
|
503
|
-
height:
|
|
778
|
+
width: 24px;
|
|
779
|
+
height: 24px;
|
|
504
780
|
border-radius: 50%;
|
|
505
781
|
object-fit: cover;
|
|
506
782
|
border: 1.5px solid ${getBotStatusColor().color};
|
|
@@ -530,7 +806,7 @@ export function createConvaiWidget(container, options) {
|
|
|
530
806
|
padding: 0;
|
|
531
807
|
display: inline-flex;
|
|
532
808
|
align-items: center;
|
|
533
|
-
margin-left:
|
|
809
|
+
margin-left: 8px;
|
|
534
810
|
outline: none;
|
|
535
811
|
transition: transform 0.1s ease-out;
|
|
536
812
|
`;
|
|
@@ -551,7 +827,7 @@ export function createConvaiWidget(container, options) {
|
|
|
551
827
|
voiceBadge.textContent = "VOICE";
|
|
552
828
|
voiceBadge.style.cssText = `
|
|
553
829
|
font-size: 10px;
|
|
554
|
-
color: ${aeroTheme.colors.convai.light};
|
|
830
|
+
color: var(--convai-accent, ${aeroTheme.colors.convai.light});
|
|
555
831
|
font-weight: 500;
|
|
556
832
|
margin-left: 8px;
|
|
557
833
|
padding: 2px 6px;
|
|
@@ -616,15 +892,21 @@ export function createConvaiWidget(container, options) {
|
|
|
616
892
|
listeningTag.insertBefore(dot, listeningTag.firstChild);
|
|
617
893
|
titleSection.appendChild(listeningTag);
|
|
618
894
|
}
|
|
895
|
+
// Every call site that refreshes the header (stateChange, botReady,
|
|
896
|
+
// connect/disconnect via stateChange, character-info fetch, ...) also
|
|
897
|
+
// needs the connecting overlay re-evaluated -- it depends on the same
|
|
898
|
+
// `isConnected`/`isBotReady`/`characterName` inputs.
|
|
899
|
+
updateConnectingOverlay();
|
|
619
900
|
};
|
|
620
901
|
// Create message list
|
|
621
902
|
const createMessageList = () => {
|
|
622
903
|
const list = document.createElement("div");
|
|
623
904
|
list.id = "convai-message-list";
|
|
905
|
+
list.setAttribute("part", "messages");
|
|
624
906
|
list.style.cssText = `
|
|
625
907
|
display: flex;
|
|
626
908
|
flex-direction: column;
|
|
627
|
-
gap:
|
|
909
|
+
gap: 12px;
|
|
628
910
|
min-height: 100%;
|
|
629
911
|
`;
|
|
630
912
|
return list;
|
|
@@ -633,22 +915,23 @@ export function createConvaiWidget(container, options) {
|
|
|
633
915
|
const createFooter = () => {
|
|
634
916
|
const footer = document.createElement("div");
|
|
635
917
|
footer.style.cssText = `
|
|
636
|
-
padding:
|
|
918
|
+
padding: 16px;
|
|
637
919
|
border-top: 1px solid ${aeroTheme.colors.neutral[200]};
|
|
638
|
-
background: white;
|
|
920
|
+
background: var(--convai-panel-bg, white);
|
|
639
921
|
display: flex;
|
|
640
|
-
gap:
|
|
922
|
+
gap: 8px;
|
|
641
923
|
align-items: center;
|
|
642
924
|
position: relative;
|
|
643
925
|
`;
|
|
644
926
|
// Voice Mode Exit Button (Initially hidden)
|
|
645
927
|
const voiceExitButton = document.createElement("button");
|
|
646
928
|
voiceExitButton.id = "convai-voice-exit-btn";
|
|
929
|
+
refs.voiceExitBtn = voiceExitButton;
|
|
647
930
|
const exitIcon = Icons.PhoneOff("md");
|
|
648
931
|
voiceExitButton.appendChild(exitIcon);
|
|
649
932
|
voiceExitButton.style.cssText = `
|
|
650
|
-
width:
|
|
651
|
-
height:
|
|
933
|
+
width: 36px;
|
|
934
|
+
height: 36px;
|
|
652
935
|
border-radius: 50%;
|
|
653
936
|
background: ${aeroTheme.colors.error[500]};
|
|
654
937
|
color: white;
|
|
@@ -659,19 +942,31 @@ export function createConvaiWidget(container, options) {
|
|
|
659
942
|
margin: 0 auto;
|
|
660
943
|
border: none;
|
|
661
944
|
`;
|
|
662
|
-
voiceExitButton.addEventListener("click",
|
|
945
|
+
voiceExitButton.addEventListener("click", () => {
|
|
946
|
+
// Deliberately a *sync* listener. It used to be `async` with a bare
|
|
947
|
+
// `await client.audioControls.muteAudio()` and no try/catch: on the
|
|
948
|
+
// LiveKit path that call rethrows when the room has dropped, so a
|
|
949
|
+
// network blip mid-call plus a tap on exit leaked an unhandled
|
|
950
|
+
// rejection onto the host page *and* skipped the two lines below,
|
|
951
|
+
// stranding the user in the voice overlay with no way out.
|
|
952
|
+
//
|
|
953
|
+
// The mute is not dropped -- `applyVoiceMode()` -> `updateVoiceMode()`
|
|
954
|
+
// performs it (conditionally, if the mic is live) inside the exit
|
|
955
|
+
// branch, where a failure is now reported rather than fatal. Exiting
|
|
956
|
+
// the UI is unconditional either way: the overlay must never outlive
|
|
957
|
+
// the user's decision to leave it.
|
|
663
958
|
client.sendInterruptMessage();
|
|
664
|
-
await client.audioControls.muteAudio(); // Mute on exit
|
|
665
959
|
isVoiceMode = false;
|
|
666
|
-
|
|
960
|
+
applyVoiceMode();
|
|
667
961
|
});
|
|
668
962
|
footer.appendChild(voiceExitButton);
|
|
669
963
|
// Standard Footer Content (Mic + Input)
|
|
670
964
|
const standardContent = document.createElement("div");
|
|
671
965
|
standardContent.id = "convai-footer-standard";
|
|
966
|
+
refs.footerStandard = standardContent;
|
|
672
967
|
standardContent.style.cssText = `
|
|
673
968
|
display: flex;
|
|
674
|
-
gap:
|
|
969
|
+
gap: 8px;
|
|
675
970
|
align-items: center;
|
|
676
971
|
width: 100%;
|
|
677
972
|
`;
|
|
@@ -684,15 +979,16 @@ export function createConvaiWidget(container, options) {
|
|
|
684
979
|
align-items: center;
|
|
685
980
|
`;
|
|
686
981
|
inputElement = document.createElement("input");
|
|
982
|
+
inputElement.setAttribute("part", "input");
|
|
687
983
|
inputElement.type = "text";
|
|
688
984
|
inputElement.placeholder = "Conversation";
|
|
689
985
|
inputElement.style.cssText = `
|
|
690
986
|
width: 100%;
|
|
691
|
-
padding:
|
|
987
|
+
padding: 12px 44px 12px 16px;
|
|
692
988
|
border-radius: ${aeroTheme.borderRadius.full};
|
|
693
989
|
border: 1px solid ${aeroTheme.colors.neutral[300]};
|
|
694
990
|
background: ${aeroTheme.colors.glass.medium};
|
|
695
|
-
color: ${aeroTheme.colors.text.primary};
|
|
991
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
696
992
|
font-size: ${aeroTheme.typography.fontSize.sm};
|
|
697
993
|
transition: ${aeroTheme.transitions.fast};
|
|
698
994
|
outline: none;
|
|
@@ -708,16 +1004,22 @@ export function createConvaiWidget(container, options) {
|
|
|
708
1004
|
// Send button
|
|
709
1005
|
const sendButton = document.createElement("button");
|
|
710
1006
|
sendButton.id = "convai-send-button";
|
|
1007
|
+
// This button is dual-role -- it sends text when the input has a draft,
|
|
1008
|
+
// and toggles voice mode when empty -- but there is no separate mic
|
|
1009
|
+
// element, so only the send-button part applies. `mic-button` is
|
|
1010
|
+
// reserved for a future version that splits the two; see parts.test.ts.
|
|
1011
|
+
sendButton.setAttribute("part", "send-button");
|
|
1012
|
+
refs.sendButton = sendButton;
|
|
711
1013
|
const sendIcon = Icons.Send("md");
|
|
712
1014
|
sendButton.appendChild(sendIcon);
|
|
713
1015
|
sendButton.style.cssText = `
|
|
714
1016
|
position: absolute;
|
|
715
|
-
right:
|
|
716
|
-
width:
|
|
717
|
-
height:
|
|
1017
|
+
right: 6px;
|
|
1018
|
+
width: 36px;
|
|
1019
|
+
height: 36px;
|
|
718
1020
|
border-radius: 50%;
|
|
719
1021
|
background: transparent; /* Initial transparent for voice toggle */
|
|
720
|
-
color: ${aeroTheme.colors.text.primary};
|
|
1022
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
721
1023
|
display: flex;
|
|
722
1024
|
align-items: center;
|
|
723
1025
|
justify-content: center;
|
|
@@ -732,9 +1034,19 @@ export function createConvaiWidget(container, options) {
|
|
|
732
1034
|
else {
|
|
733
1035
|
// Toggle Voice Mode
|
|
734
1036
|
client.sendInterruptMessage();
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1037
|
+
try {
|
|
1038
|
+
await client.audioControls.unmuteAudio(); // Unmute on enter
|
|
1039
|
+
isVoiceMode = true;
|
|
1040
|
+
// Awaited so this try/catch actually covers it -- bare, the promise
|
|
1041
|
+
// escaped the block it looks like it belongs to.
|
|
1042
|
+
await updateVoiceMode();
|
|
1043
|
+
}
|
|
1044
|
+
catch (error) {
|
|
1045
|
+
// isVoiceMode is only flipped after a successful unmute, so a
|
|
1046
|
+
// denial here leaves the widget in text mode, not stranded
|
|
1047
|
+
// mid-transition into voice mode.
|
|
1048
|
+
reportMicFailure(client, "Failed to enable microphone:", error);
|
|
1049
|
+
}
|
|
738
1050
|
}
|
|
739
1051
|
});
|
|
740
1052
|
inputContainer.appendChild(inputElement);
|
|
@@ -751,7 +1063,7 @@ export function createConvaiWidget(container, options) {
|
|
|
751
1063
|
position: absolute;
|
|
752
1064
|
top: 60px;
|
|
753
1065
|
right: 16px;
|
|
754
|
-
background: white;
|
|
1066
|
+
background: var(--convai-panel-bg, white);
|
|
755
1067
|
border-radius: ${aeroTheme.borderRadius.xl};
|
|
756
1068
|
box-shadow: ${aeroTheme.shadows.xl};
|
|
757
1069
|
padding: 0;
|
|
@@ -766,17 +1078,7 @@ export function createConvaiWidget(container, options) {
|
|
|
766
1078
|
overflow: hidden;
|
|
767
1079
|
`;
|
|
768
1080
|
// Add animation keyframes if not present
|
|
769
|
-
|
|
770
|
-
const style = document.createElement("style");
|
|
771
|
-
style.id = "convai-widget-keyframes";
|
|
772
|
-
style.textContent = `
|
|
773
|
-
@keyframes popIn {
|
|
774
|
-
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
|
775
|
-
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
776
|
-
}
|
|
777
|
-
`;
|
|
778
|
-
document.head.appendChild(style);
|
|
779
|
-
}
|
|
1081
|
+
injectKeyframes(styleTarget);
|
|
780
1082
|
// Horizontal Settings Row Container
|
|
781
1083
|
const settingsRow = document.createElement("div");
|
|
782
1084
|
settingsRow.style.cssText = `
|
|
@@ -805,7 +1107,7 @@ export function createConvaiWidget(container, options) {
|
|
|
805
1107
|
? "#10b981"
|
|
806
1108
|
: isDestructive
|
|
807
1109
|
? "#ef4444"
|
|
808
|
-
: aeroTheme.colors.text.primary};
|
|
1110
|
+
: `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`};
|
|
809
1111
|
transition: ${aeroTheme.transitions.fast};
|
|
810
1112
|
min-width: 50px;
|
|
811
1113
|
`;
|
|
@@ -855,6 +1157,7 @@ export function createConvaiWidget(container, options) {
|
|
|
855
1157
|
videoIcon.style.height = "18px";
|
|
856
1158
|
const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
|
|
857
1159
|
videoBtn.id = "convai-settings-video-btn";
|
|
1160
|
+
refs.settingsVideoBtn = videoBtn;
|
|
858
1161
|
settingsRow.appendChild(videoBtn);
|
|
859
1162
|
}
|
|
860
1163
|
// Screen Share - only show if connection type is video
|
|
@@ -867,6 +1170,7 @@ export function createConvaiWidget(container, options) {
|
|
|
867
1170
|
shareIcon.style.height = "18px";
|
|
868
1171
|
const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
|
|
869
1172
|
shareBtn.id = "convai-settings-share-btn";
|
|
1173
|
+
refs.settingsShareBtn = shareBtn;
|
|
870
1174
|
settingsRow.appendChild(shareBtn);
|
|
871
1175
|
}
|
|
872
1176
|
// Disconnect
|
|
@@ -903,7 +1207,7 @@ export function createConvaiWidget(container, options) {
|
|
|
903
1207
|
? "#10b981"
|
|
904
1208
|
: isDestructive
|
|
905
1209
|
? "#ef4444"
|
|
906
|
-
: aeroTheme.colors.text.primary};
|
|
1210
|
+
: `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`};
|
|
907
1211
|
transition: ${aeroTheme.transitions.fast};
|
|
908
1212
|
min-width: 50px;
|
|
909
1213
|
`;
|
|
@@ -953,6 +1257,7 @@ export function createConvaiWidget(container, options) {
|
|
|
953
1257
|
videoIcon.style.height = "18px";
|
|
954
1258
|
const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
|
|
955
1259
|
videoBtn.id = "convai-settings-video-btn";
|
|
1260
|
+
refs.settingsVideoBtn = videoBtn;
|
|
956
1261
|
settingsRow.appendChild(videoBtn);
|
|
957
1262
|
}
|
|
958
1263
|
// Screen Share - Always show if showScreenShare is true and connection type is video
|
|
@@ -965,6 +1270,7 @@ export function createConvaiWidget(container, options) {
|
|
|
965
1270
|
shareIcon.style.height = "18px";
|
|
966
1271
|
const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
|
|
967
1272
|
shareBtn.id = "convai-settings-share-btn";
|
|
1273
|
+
refs.settingsShareBtn = shareBtn;
|
|
968
1274
|
settingsRow.appendChild(shareBtn);
|
|
969
1275
|
}
|
|
970
1276
|
// Disconnect
|
|
@@ -977,6 +1283,12 @@ export function createConvaiWidget(container, options) {
|
|
|
977
1283
|
const createFloatingVideo = () => {
|
|
978
1284
|
const container = document.createElement("div");
|
|
979
1285
|
container.id = "floating-video-container";
|
|
1286
|
+
// NOTE on z-index: deliberately NOT themable. `--convai-z-index` drives the
|
|
1287
|
+
// widget root only. Both elements are `position: fixed` and this one is
|
|
1288
|
+
// appended *before* the widget root, so on a tie the widget paints on top:
|
|
1289
|
+
// a host raising the token to clear their nav bar would hide the user's own
|
|
1290
|
+
// camera preview behind the panel. Video/screen share are also out of scope
|
|
1291
|
+
// for the embed, which is chat-only.
|
|
980
1292
|
container.style.cssText = `
|
|
981
1293
|
position: fixed;
|
|
982
1294
|
left: 20px;
|
|
@@ -1158,12 +1470,16 @@ export function createConvaiWidget(container, options) {
|
|
|
1158
1470
|
};
|
|
1159
1471
|
// Event handlers
|
|
1160
1472
|
const handleToggle = async () => {
|
|
1161
|
-
if (isOpen) {
|
|
1162
|
-
//
|
|
1473
|
+
if (isOpen || pendingOpen) {
|
|
1474
|
+
// Already open, or an open is already in flight: nothing to do. The
|
|
1475
|
+
// `pendingOpen` half is what keeps a repeat call from falling through
|
|
1476
|
+
// to the `isConnecting` branch below and opening the panel *before* the
|
|
1477
|
+
// connect it is waiting on has finished.
|
|
1163
1478
|
return;
|
|
1164
1479
|
}
|
|
1165
1480
|
// Connect on first click if not already connected/connecting
|
|
1166
1481
|
if (!client.state.isConnected && !client.state.isConnecting) {
|
|
1482
|
+
pendingOpen = true;
|
|
1167
1483
|
try {
|
|
1168
1484
|
// Use reconnect when opening again after a disconnect (reuses stored config)
|
|
1169
1485
|
if (hasConnectedBefore && typeof client.reconnect === "function") {
|
|
@@ -1172,18 +1488,40 @@ export function createConvaiWidget(container, options) {
|
|
|
1172
1488
|
else {
|
|
1173
1489
|
await client.connect();
|
|
1174
1490
|
}
|
|
1491
|
+
// Re-checked *after* the await: a close()/toggle()/launcher click
|
|
1492
|
+
// during the connect clears the flag, and that cancellation has to
|
|
1493
|
+
// win. The client stays connected either way -- only the panel is
|
|
1494
|
+
// held back -- so a later open() takes the already-connected branch
|
|
1495
|
+
// and opens instantly.
|
|
1496
|
+
if (!pendingOpen)
|
|
1497
|
+
return;
|
|
1175
1498
|
setIsOpen(true);
|
|
1176
1499
|
}
|
|
1177
1500
|
catch (error) {
|
|
1178
1501
|
console.error("Failed to connect:", error);
|
|
1179
1502
|
}
|
|
1503
|
+
finally {
|
|
1504
|
+
pendingOpen = false;
|
|
1505
|
+
}
|
|
1180
1506
|
}
|
|
1181
1507
|
else {
|
|
1182
1508
|
// Just toggle open/close if already connected
|
|
1183
1509
|
setIsOpen(!isOpen);
|
|
1184
1510
|
}
|
|
1185
1511
|
};
|
|
1512
|
+
/** A launcher click while an open is pending means "cancel", same as `toggle()`. */
|
|
1513
|
+
const handleLauncherClick = () => {
|
|
1514
|
+
if (pendingOpen) {
|
|
1515
|
+
handleClose();
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
void handleToggle();
|
|
1519
|
+
};
|
|
1186
1520
|
const handleClose = () => {
|
|
1521
|
+
// Cancels an in-flight open as well as closing an open panel; see
|
|
1522
|
+
// `pendingOpen`. `setIsOpen(false)` on an already-closed widget just
|
|
1523
|
+
// re-applies the collapsed styles, so this is safe in both cases.
|
|
1524
|
+
pendingOpen = false;
|
|
1187
1525
|
setIsOpen(false);
|
|
1188
1526
|
};
|
|
1189
1527
|
const handleSend = () => {
|
|
@@ -1218,7 +1556,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1218
1556
|
isVoiceMode = false;
|
|
1219
1557
|
hasEnteredDefaultVoiceMode = false;
|
|
1220
1558
|
updateHeader();
|
|
1221
|
-
updateVoiceMode();
|
|
1559
|
+
await updateVoiceMode();
|
|
1222
1560
|
updateSendButton();
|
|
1223
1561
|
}
|
|
1224
1562
|
catch (error) {
|
|
@@ -1232,7 +1570,11 @@ export function createConvaiWidget(container, options) {
|
|
|
1232
1570
|
isVoiceMode = false;
|
|
1233
1571
|
hasEnteredDefaultVoiceMode = false;
|
|
1234
1572
|
updateHeader();
|
|
1235
|
-
|
|
1573
|
+
// Not awaited: this function has no try/catch of its own and is wired
|
|
1574
|
+
// straight to a click, so awaiting would only move a rejection from one
|
|
1575
|
+
// unguarded place to another. See the note on `handleDisconnect`'s own
|
|
1576
|
+
// `await client.disconnect()` in the fix report.
|
|
1577
|
+
applyVoiceMode();
|
|
1236
1578
|
updateSendButton();
|
|
1237
1579
|
};
|
|
1238
1580
|
const handleToggleVideo = async () => {
|
|
@@ -1271,10 +1613,17 @@ export function createConvaiWidget(container, options) {
|
|
|
1271
1613
|
const setIsOpen = async (open) => {
|
|
1272
1614
|
isOpen = open;
|
|
1273
1615
|
if (open) {
|
|
1274
|
-
morphingContainer.style.width =
|
|
1275
|
-
morphingContainer.style.height =
|
|
1276
|
-
morphingContainer.style.borderRadius = aeroTheme.borderRadius.xl
|
|
1616
|
+
morphingContainer.style.width = `${PANEL_W_PX}px`;
|
|
1617
|
+
morphingContainer.style.height = `${PANEL_H_PX}px`;
|
|
1618
|
+
morphingContainer.style.borderRadius = `var(--convai-radius, ${aeroTheme.borderRadius.xl})`;
|
|
1277
1619
|
morphingContainer.style.cursor = "default";
|
|
1620
|
+
// The open panel is a *different* glass treatment from the launcher
|
|
1621
|
+
// bubble, matching the React widget: near-opaque and saturated, so the
|
|
1622
|
+
// host page does not bleed through. At the bubble's 0.8 alpha a dark
|
|
1623
|
+
// host page shows through the panel and reads as grey.
|
|
1624
|
+
morphingContainer.style.background =
|
|
1625
|
+
"var(--convai-panel-bg, rgba(252, 252, 253, 0.95))";
|
|
1626
|
+
morphingContainer.style.backdropFilter = "blur(20px) saturate(180%)";
|
|
1278
1627
|
buttonContent.style.opacity = "0";
|
|
1279
1628
|
buttonContent.style.transform = "scale(0.8)";
|
|
1280
1629
|
buttonContent.style.pointerEvents = "none";
|
|
@@ -1290,19 +1639,24 @@ export function createConvaiWidget(container, options) {
|
|
|
1290
1639
|
await client.audioControls.unmuteAudio();
|
|
1291
1640
|
isVoiceMode = true;
|
|
1292
1641
|
hasEnteredDefaultVoiceMode = true;
|
|
1293
|
-
updateVoiceMode();
|
|
1642
|
+
await updateVoiceMode();
|
|
1294
1643
|
}
|
|
1295
1644
|
catch (error) {
|
|
1296
|
-
|
|
1645
|
+
reportMicFailure(client, "Failed to enter voice mode on open:", error);
|
|
1297
1646
|
}
|
|
1298
1647
|
}, 100);
|
|
1299
1648
|
}
|
|
1300
1649
|
}
|
|
1301
1650
|
else {
|
|
1302
|
-
morphingContainer.style.width = "
|
|
1303
|
-
morphingContainer.style.height = "
|
|
1651
|
+
morphingContainer.style.width = "var(--convai-launcher-size, 64px)";
|
|
1652
|
+
morphingContainer.style.height = "var(--convai-launcher-size, 64px)";
|
|
1653
|
+
// Literal, not `--convai-radius` — see the creation site: that token is
|
|
1654
|
+
// the *panel* radius, and reusing it here would squircle the launcher.
|
|
1304
1655
|
morphingContainer.style.borderRadius = "50%";
|
|
1305
1656
|
morphingContainer.style.cursor = "pointer";
|
|
1657
|
+
// Back to the launcher bubble's lighter glass — see the open branch.
|
|
1658
|
+
morphingContainer.style.background = `var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop})`;
|
|
1659
|
+
morphingContainer.style.backdropFilter = aeroTheme.glass.backdrop;
|
|
1306
1660
|
buttonContent.style.opacity = "1";
|
|
1307
1661
|
buttonContent.style.transform = "scale(1)";
|
|
1308
1662
|
buttonContent.style.pointerEvents = "auto";
|
|
@@ -1316,7 +1670,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1316
1670
|
settingsTray.style.display = open ? "flex" : "none";
|
|
1317
1671
|
};
|
|
1318
1672
|
const updateSendButton = () => {
|
|
1319
|
-
const sendButton =
|
|
1673
|
+
const sendButton = refs.sendButton;
|
|
1320
1674
|
if (!sendButton)
|
|
1321
1675
|
return;
|
|
1322
1676
|
// Clear previous content
|
|
@@ -1324,8 +1678,15 @@ export function createConvaiWidget(container, options) {
|
|
|
1324
1678
|
if (inputValue.length > 0) {
|
|
1325
1679
|
// Show Send Button
|
|
1326
1680
|
sendButton.appendChild(Icons.Send("md"));
|
|
1327
|
-
sendButton.style.background = aeroTheme.colors.text.primary
|
|
1328
|
-
|
|
1681
|
+
sendButton.style.background = `var(--convai-accent, ${aeroTheme.colors.text.primary})`;
|
|
1682
|
+
// The label sits *on* the accent fill, so it must follow the panel
|
|
1683
|
+
// surface colour rather than stay a hardcoded "white" -- a host setting
|
|
1684
|
+
// a pale --convai-accent would otherwise get white-on-white. `white` is
|
|
1685
|
+
// byte-identical to the fallback --convai-panel-bg already carries at
|
|
1686
|
+
// its three other sites, so the default rendering is unchanged.
|
|
1687
|
+
// Pairing requirement for hosts: --convai-accent and --convai-panel-bg
|
|
1688
|
+
// must contrast with each other, since this is the one place they meet.
|
|
1689
|
+
sendButton.style.color = "var(--convai-panel-bg, white)";
|
|
1329
1690
|
sendButton.style.border = "none";
|
|
1330
1691
|
sendButton.title = "Send";
|
|
1331
1692
|
}
|
|
@@ -1333,7 +1694,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1333
1694
|
// Show Voice Mode Toggle
|
|
1334
1695
|
sendButton.appendChild(Icons.Waveform("md"));
|
|
1335
1696
|
sendButton.style.background = "transparent";
|
|
1336
|
-
sendButton.style.color = aeroTheme.colors.text.primary
|
|
1697
|
+
sendButton.style.color = `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
|
|
1337
1698
|
sendButton.style.border = `1px solid ${aeroTheme.colors.neutral[300]}`;
|
|
1338
1699
|
sendButton.title = "Voice Mode";
|
|
1339
1700
|
}
|
|
@@ -1364,8 +1725,8 @@ export function createConvaiWidget(container, options) {
|
|
|
1364
1725
|
}
|
|
1365
1726
|
};
|
|
1366
1727
|
const updateVoiceMode = async () => {
|
|
1367
|
-
const standardFooter =
|
|
1368
|
-
const voiceExitBtn =
|
|
1728
|
+
const standardFooter = refs.footerStandard;
|
|
1729
|
+
const voiceExitBtn = refs.voiceExitBtn;
|
|
1369
1730
|
if (isVoiceMode) {
|
|
1370
1731
|
// Show Voice Overlay
|
|
1371
1732
|
if (voiceModeOverlay)
|
|
@@ -1391,9 +1752,22 @@ export function createConvaiWidget(container, options) {
|
|
|
1391
1752
|
startAudioAnalysis();
|
|
1392
1753
|
}
|
|
1393
1754
|
else {
|
|
1394
|
-
// Ensure microphone is muted when not in voice mode
|
|
1755
|
+
// Ensure microphone is muted when not in voice mode.
|
|
1756
|
+
//
|
|
1757
|
+
// This await is the only one in this function that can reject, and
|
|
1758
|
+
// everything below it -- hiding the overlay, restoring the standard
|
|
1759
|
+
// footer, updating the header, stopping the analyser -- is the only
|
|
1760
|
+
// route out of the voice UI. So a failed mute must never short-circuit
|
|
1761
|
+
// it: report it and carry on tearing the overlay down. (LiveKit's
|
|
1762
|
+
// setMicrophoneEnabled(false) rethrows on a dropped room, which is
|
|
1763
|
+
// exactly when the user is most likely to be hitting exit.)
|
|
1395
1764
|
if (!client.audioControls.isAudioMuted) {
|
|
1396
|
-
|
|
1765
|
+
try {
|
|
1766
|
+
await client.audioControls.muteAudio();
|
|
1767
|
+
}
|
|
1768
|
+
catch (error) {
|
|
1769
|
+
reportMicFailure(client, "Failed to mute microphone:", error);
|
|
1770
|
+
}
|
|
1397
1771
|
}
|
|
1398
1772
|
// Hide Overlay
|
|
1399
1773
|
if (voiceModeOverlay)
|
|
@@ -1425,6 +1799,24 @@ export function createConvaiWidget(container, options) {
|
|
|
1425
1799
|
stopAudioAnalysis();
|
|
1426
1800
|
}
|
|
1427
1801
|
};
|
|
1802
|
+
/**
|
|
1803
|
+
* Fire-and-forget `updateVoiceMode()` for the call sites that are
|
|
1804
|
+
* synchronous (DOM listeners, the constructor tail) and therefore have no
|
|
1805
|
+
* caller to catch anything it rejects with.
|
|
1806
|
+
*
|
|
1807
|
+
* `updateVoiceMode()` is `async`, so a bare call there would put a floating
|
|
1808
|
+
* promise on the host page. Its one rejecting await is now guarded
|
|
1809
|
+
* internally, which makes this belt-and-braces -- but it is the guarantee
|
|
1810
|
+
* that keeps a future await added inside `updateVoiceMode()` from silently
|
|
1811
|
+
* reintroducing the unhandled rejection this replaces. Routed through
|
|
1812
|
+
* `reportMicFailure` so a failure is observable on the client's `error`
|
|
1813
|
+
* channel rather than console-only.
|
|
1814
|
+
*/
|
|
1815
|
+
const applyVoiceMode = () => {
|
|
1816
|
+
void updateVoiceMode().catch((error) => {
|
|
1817
|
+
reportMicFailure(client, "Failed to apply voice mode:", error);
|
|
1818
|
+
});
|
|
1819
|
+
};
|
|
1428
1820
|
// Microphone button removed - only controlled via voice mode now
|
|
1429
1821
|
// Helper for Markdown - matches React MarkdownRenderer.tsx
|
|
1430
1822
|
const renderMarkdown = (text, container) => {
|
|
@@ -1459,7 +1851,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1459
1851
|
strong.textContent = match[2];
|
|
1460
1852
|
strong.style.cssText = `
|
|
1461
1853
|
font-weight: 600;
|
|
1462
|
-
color: ${aeroTheme.colors.text.primary};
|
|
1854
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
1463
1855
|
`;
|
|
1464
1856
|
parts.push(strong);
|
|
1465
1857
|
}
|
|
@@ -1526,7 +1918,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1526
1918
|
height: 100%;
|
|
1527
1919
|
color: ${aeroTheme.colors.text.secondary};
|
|
1528
1920
|
text-align: center;
|
|
1529
|
-
padding:
|
|
1921
|
+
padding: 32px;
|
|
1530
1922
|
`;
|
|
1531
1923
|
emptyState.innerHTML = `
|
|
1532
1924
|
<p style="font-weight: 500; margin-bottom: 8px;">Start a conversation</p>
|
|
@@ -1548,6 +1940,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1548
1940
|
contain: layout style paint;
|
|
1549
1941
|
`;
|
|
1550
1942
|
const bubble = document.createElement("div");
|
|
1943
|
+
bubble.setAttribute("part", msg.isUser ? "message message-user" : "message message-bot");
|
|
1551
1944
|
// Add data attribute to identify user messages for debugging/styling
|
|
1552
1945
|
if (msg.isUser) {
|
|
1553
1946
|
bubble.setAttribute("data-message-type", "user");
|
|
@@ -1555,6 +1948,10 @@ export function createConvaiWidget(container, options) {
|
|
|
1555
1948
|
// Set base styles first - explicitly set background to match React UserBubble exactly
|
|
1556
1949
|
// CRITICAL: Use the exact same background for both user and bot messages
|
|
1557
1950
|
const backgroundColor = "rgba(252, 252, 253, 0.95)";
|
|
1951
|
+
// NOTE on border-radius: deliberately NOT themable. `--convai-radius` is
|
|
1952
|
+
// the open panel's radius; a bubble's asymmetric tail corner is a
|
|
1953
|
+
// different geometry (`50%` would make ellipses of these). A dedicated
|
|
1954
|
+
// `--convai-bubble-radius` can be added additively later if wanted.
|
|
1558
1955
|
bubble.style.cssText = `
|
|
1559
1956
|
padding: 12px;
|
|
1560
1957
|
border-radius: ${msg.isUser ? "12px 12px 4px 12px" : "12px 12px 12px 4px"};
|
|
@@ -1563,8 +1960,8 @@ export function createConvaiWidget(container, options) {
|
|
|
1563
1960
|
backdrop-filter: blur(20px) saturate(180%);
|
|
1564
1961
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
1565
1962
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8) inset, 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
1566
|
-
color: ${aeroTheme.colors.text.primary};
|
|
1567
|
-
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
1963
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
1964
|
+
font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
|
|
1568
1965
|
font-size: 14px;
|
|
1569
1966
|
line-height: 1.5;
|
|
1570
1967
|
word-wrap: break-word;
|
|
@@ -1596,8 +1993,8 @@ export function createConvaiWidget(container, options) {
|
|
|
1596
1993
|
// Logo/Icon - matches React LogoWrapper
|
|
1597
1994
|
const logoContainer = document.createElement("div");
|
|
1598
1995
|
logoContainer.style.cssText = `
|
|
1599
|
-
width:
|
|
1600
|
-
height:
|
|
1996
|
+
width: 16px;
|
|
1997
|
+
height: 16px;
|
|
1601
1998
|
display: flex;
|
|
1602
1999
|
align-items: center;
|
|
1603
2000
|
justify-content: center;
|
|
@@ -1624,10 +2021,10 @@ export function createConvaiWidget(container, options) {
|
|
|
1624
2021
|
const nameLabel = document.createElement("span");
|
|
1625
2022
|
nameLabel.textContent = msg.sender === "User" ? "You" : msg.sender;
|
|
1626
2023
|
nameLabel.style.cssText = `
|
|
1627
|
-
font-size:
|
|
2024
|
+
font-size: 12px;
|
|
1628
2025
|
color: ${msg.isUser ? aeroTheme.colors.convai.dark : "#10b981"};
|
|
1629
2026
|
font-weight: 500;
|
|
1630
|
-
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
2027
|
+
font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
|
|
1631
2028
|
isolation: isolate;
|
|
1632
2029
|
contain: layout style paint;
|
|
1633
2030
|
`;
|
|
@@ -1649,11 +2046,11 @@ export function createConvaiWidget(container, options) {
|
|
|
1649
2046
|
const timestamp = document.createElement("span");
|
|
1650
2047
|
timestamp.textContent = msg.timestamp;
|
|
1651
2048
|
timestamp.style.cssText = `
|
|
1652
|
-
font-size:
|
|
2049
|
+
font-size: 12px;
|
|
1653
2050
|
color: ${aeroTheme.colors.text.secondary};
|
|
1654
2051
|
margin-top: 4px;
|
|
1655
2052
|
align-self: ${msg.isUser ? "flex-end" : "flex-start"};
|
|
1656
|
-
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
2053
|
+
font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
|
|
1657
2054
|
`;
|
|
1658
2055
|
messageWrapper.appendChild(timestamp);
|
|
1659
2056
|
}
|
|
@@ -1730,7 +2127,10 @@ export function createConvaiWidget(container, options) {
|
|
|
1730
2127
|
isVideoVisible = false;
|
|
1731
2128
|
hasEnteredDefaultVoiceMode = false;
|
|
1732
2129
|
updateHeader();
|
|
1733
|
-
|
|
2130
|
+
// Synchronous listener: nothing here can catch a rejection, and the
|
|
2131
|
+
// mute inside is the one most likely to fail (the room has just
|
|
2132
|
+
// dropped -- that is why we are collapsing).
|
|
2133
|
+
applyVoiceMode();
|
|
1734
2134
|
}
|
|
1735
2135
|
// Auto-enter voice mode on first connection if defaultVoiceMode is true
|
|
1736
2136
|
if (client.state.isConnected &&
|
|
@@ -1743,10 +2143,10 @@ export function createConvaiWidget(container, options) {
|
|
|
1743
2143
|
await client.audioControls.unmuteAudio();
|
|
1744
2144
|
isVoiceMode = true;
|
|
1745
2145
|
hasEnteredDefaultVoiceMode = true;
|
|
1746
|
-
updateVoiceMode();
|
|
2146
|
+
await updateVoiceMode();
|
|
1747
2147
|
}
|
|
1748
2148
|
catch (error) {
|
|
1749
|
-
|
|
2149
|
+
reportMicFailure(client, "Failed to enter voice mode:", error);
|
|
1750
2150
|
}
|
|
1751
2151
|
}, 100);
|
|
1752
2152
|
}
|
|
@@ -1756,9 +2156,9 @@ export function createConvaiWidget(container, options) {
|
|
|
1756
2156
|
client.videoControls.on("videoStateChange", (videoState) => {
|
|
1757
2157
|
if (videoState.isVideoEnabled !== undefined) {
|
|
1758
2158
|
isVideoVisible = videoState.isVideoEnabled;
|
|
1759
|
-
|
|
2159
|
+
applyVoiceMode(); // synchronous listener; see applyVoiceMode()
|
|
1760
2160
|
// Update tray button state
|
|
1761
|
-
const videoBtn =
|
|
2161
|
+
const videoBtn = refs.settingsVideoBtn;
|
|
1762
2162
|
if (videoBtn) {
|
|
1763
2163
|
const isActive = isVideoVisible;
|
|
1764
2164
|
videoBtn.style.backgroundColor = isActive
|
|
@@ -1766,7 +2166,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1766
2166
|
: "transparent";
|
|
1767
2167
|
videoBtn.style.color = isActive
|
|
1768
2168
|
? "#10b981"
|
|
1769
|
-
: aeroTheme.colors.text.primary
|
|
2169
|
+
: `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
|
|
1770
2170
|
// Update Icon - first child is icon container
|
|
1771
2171
|
const iconContainer = videoBtn.querySelector("div");
|
|
1772
2172
|
if (iconContainer) {
|
|
@@ -1783,7 +2183,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1783
2183
|
client.screenShareControls.on("screenShareStateChange", (screenShareState) => {
|
|
1784
2184
|
if (screenShareState.isScreenShareActive !== undefined) {
|
|
1785
2185
|
const isSharing = screenShareState.isScreenShareActive;
|
|
1786
|
-
const shareBtn =
|
|
2186
|
+
const shareBtn = refs.settingsShareBtn;
|
|
1787
2187
|
if (shareBtn) {
|
|
1788
2188
|
const isActive = isSharing;
|
|
1789
2189
|
shareBtn.style.backgroundColor = isActive
|
|
@@ -1791,7 +2191,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1791
2191
|
: "transparent";
|
|
1792
2192
|
shareBtn.style.color = isActive
|
|
1793
2193
|
? "#10b981"
|
|
1794
|
-
: aeroTheme.colors.text.primary
|
|
2194
|
+
: `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
|
|
1795
2195
|
// Update Icon - first child is icon container
|
|
1796
2196
|
const iconContainer = shareBtn.querySelector("div");
|
|
1797
2197
|
if (iconContainer) {
|
|
@@ -1821,8 +2221,13 @@ export function createConvaiWidget(container, options) {
|
|
|
1821
2221
|
client.on("connect", () => {
|
|
1822
2222
|
hasConnectedBefore = true;
|
|
1823
2223
|
// Initialize audio renderer on connection
|
|
2224
|
+
// NOTE: the audio elements live under rootElement, so playback ends if
|
|
2225
|
+
// the host *removes or re-parents* its mount container mid-call. Merely
|
|
2226
|
+
// hiding it (display: none / visibility: hidden) does NOT stop
|
|
2227
|
+
// HTMLMediaElement playback in any browser — an earlier version of this
|
|
2228
|
+
// comment claimed otherwise and was wrong.
|
|
1824
2229
|
if (client.room && !audioRenderer) {
|
|
1825
|
-
audioRenderer = new AudioRenderer(client.room);
|
|
2230
|
+
audioRenderer = new AudioRenderer(client.room, { container: rootElement });
|
|
1826
2231
|
}
|
|
1827
2232
|
// Fetch character info
|
|
1828
2233
|
fetchCharacterInfo();
|
|
@@ -1847,21 +2252,80 @@ export function createConvaiWidget(container, options) {
|
|
|
1847
2252
|
// Initialize
|
|
1848
2253
|
createDOM();
|
|
1849
2254
|
setupClientListeners();
|
|
2255
|
+
// Paint the connecting overlay's initial state. updateHeader() (which also
|
|
2256
|
+
// calls updateConnectingOverlay()) is otherwise only reached via a client
|
|
2257
|
+
// event or a successful character-info fetch, so a widget constructed
|
|
2258
|
+
// already connected-but-not-ready would show nothing until one of those
|
|
2259
|
+
// fires without this.
|
|
2260
|
+
updateConnectingOverlay();
|
|
1850
2261
|
// Set initial button state
|
|
1851
2262
|
updateSendButton(); // Show voice mode button initially since input is empty
|
|
1852
2263
|
// Set initial voice mode UI if defaultVoiceMode is true
|
|
1853
2264
|
if (defaultVoiceMode) {
|
|
1854
|
-
|
|
2265
|
+
applyVoiceMode(); // synchronous constructor tail; see applyVoiceMode()
|
|
1855
2266
|
}
|
|
1856
2267
|
// If already connected, initialize audio renderer and fetch character info
|
|
2268
|
+
// NOTE: same lifetime caveat as the "connect" handler above — the audio
|
|
2269
|
+
// elements are torn down with rootElement if the host detaches it.
|
|
1857
2270
|
if (client.state.isConnected && client.room) {
|
|
1858
|
-
audioRenderer = new AudioRenderer(client.room);
|
|
2271
|
+
audioRenderer = new AudioRenderer(client.room, { container: rootElement });
|
|
1859
2272
|
fetchCharacterInfo();
|
|
1860
2273
|
}
|
|
1861
2274
|
// Return widget instance
|
|
1862
2275
|
const widget = {
|
|
1863
2276
|
element: rootElement,
|
|
1864
2277
|
client: client,
|
|
2278
|
+
// `isOpen` reports *requested* state, which on a warm client is the same
|
|
2279
|
+
// thing as rendered state: `setIsOpen()` runs synchronously. The one case
|
|
2280
|
+
// where they differ is the first open on a disconnected client, where the
|
|
2281
|
+
// panel is committed only after `connect()` resolves; reporting `false`
|
|
2282
|
+
// for those seconds would tell an embed reconciling its own
|
|
2283
|
+
// convai-open/convai-close events that a widget it just opened is closed.
|
|
2284
|
+
// `pendingOpen` is cleared by every cancellation path, so this never
|
|
2285
|
+
// reports open for a request that was called off.
|
|
2286
|
+
get isOpen() {
|
|
2287
|
+
return isOpen || pendingOpen;
|
|
2288
|
+
},
|
|
2289
|
+
// Drives the exact same path the launcher click does: `handleToggle`
|
|
2290
|
+
// (connect-on-first-open, then `setIsOpen(true)`). Idempotent against an
|
|
2291
|
+
// in-flight open as well as an open panel, so repeat calls never start a
|
|
2292
|
+
// second connect.
|
|
2293
|
+
open: () => {
|
|
2294
|
+
if (isOpen || pendingOpen)
|
|
2295
|
+
return;
|
|
2296
|
+
void handleToggle();
|
|
2297
|
+
},
|
|
2298
|
+
// Drives the same path the header's close button does: `handleClose` ->
|
|
2299
|
+
// `setIsOpen(false)`. Also cancels an open that is still waiting on
|
|
2300
|
+
// `connect()`; a redundant `close()` is a no-op.
|
|
2301
|
+
close: () => {
|
|
2302
|
+
if (!isOpen && !pendingOpen)
|
|
2303
|
+
return;
|
|
2304
|
+
handleClose();
|
|
2305
|
+
},
|
|
2306
|
+
toggle: () => {
|
|
2307
|
+
if (isOpen || pendingOpen) {
|
|
2308
|
+
handleClose();
|
|
2309
|
+
}
|
|
2310
|
+
else {
|
|
2311
|
+
void handleToggle();
|
|
2312
|
+
}
|
|
2313
|
+
},
|
|
2314
|
+
// Pushes a synthetic ChatMessage straight onto `client.chatMessages` and
|
|
2315
|
+
// re-renders via the widget's existing `updateMessageList()`. It never
|
|
2316
|
+
// calls a client send method (e.g. `sendUserTextMessage`), so the
|
|
2317
|
+
// message never reaches the character and is never billed as an LLM
|
|
2318
|
+
// turn. The `local-` id keeps it from colliding with a server-issued id.
|
|
2319
|
+
appendMessage: (message) => {
|
|
2320
|
+
const chatMessage = {
|
|
2321
|
+
id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
2322
|
+
type: message.sender === "user" ? "user-llm-text" : "bot-llm-text",
|
|
2323
|
+
content: message.content,
|
|
2324
|
+
timestamp: new Date().toISOString(),
|
|
2325
|
+
};
|
|
2326
|
+
client.chatMessages.push(chatMessage);
|
|
2327
|
+
updateMessageList();
|
|
2328
|
+
},
|
|
1865
2329
|
destroy: () => {
|
|
1866
2330
|
// Cleanup audio renderer
|
|
1867
2331
|
if (audioRenderer) {
|
|
@@ -1869,7 +2333,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1869
2333
|
audioRenderer = null;
|
|
1870
2334
|
}
|
|
1871
2335
|
// Remove event listeners
|
|
1872
|
-
morphingContainer.removeEventListener("click",
|
|
2336
|
+
morphingContainer.removeEventListener("click", handleLauncherClick);
|
|
1873
2337
|
// Remove DOM elements
|
|
1874
2338
|
if (rootElement.parentElement) {
|
|
1875
2339
|
rootElement.remove();
|