@convai/web-sdk 1.7.0 → 1.8.0-beta.1
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 +602 -114
- 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.d.ts.map +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,15 +236,31 @@ 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
|
+
//
|
|
243
|
+
// The two credentials travel in *different* headers: api.convai.com reads
|
|
244
|
+
// an auth token from API-AUTH-TOKEN and an API key from X-API-Key, and
|
|
245
|
+
// answers an API key sent as API-AUTH-TOKEN with `400 Your session has
|
|
246
|
+
// expired.` Because the failure is swallowed below, sending the wrong one
|
|
247
|
+
// showed up only as a header stuck on the fallback name.
|
|
248
|
+
const credential = client.authToken ?? client.apiKey;
|
|
249
|
+
if (!credential || !client.characterId)
|
|
101
250
|
return;
|
|
251
|
+
const headers = {
|
|
252
|
+
"Content-Type": "application/json",
|
|
253
|
+
};
|
|
254
|
+
if (client.authToken) {
|
|
255
|
+
headers["API-AUTH-TOKEN"] = client.authToken;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
headers["X-API-Key"] = credential;
|
|
259
|
+
}
|
|
102
260
|
try {
|
|
103
261
|
const response = await fetch("https://api.convai.com/character/get", {
|
|
104
262
|
method: "POST",
|
|
105
|
-
headers
|
|
106
|
-
"Content-Type": "application/json",
|
|
107
|
-
"API-AUTH-TOKEN": client.apiKey,
|
|
108
|
-
},
|
|
263
|
+
headers,
|
|
109
264
|
body: JSON.stringify({ charID: client.characterId }),
|
|
110
265
|
});
|
|
111
266
|
if (response.ok) {
|
|
@@ -129,23 +284,34 @@ export function createConvaiWidget(container, options) {
|
|
|
129
284
|
rootElement = document.createElement("div");
|
|
130
285
|
rootElement.className = "convai-widget";
|
|
131
286
|
rootElement.style.cssText = `
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
z-index: ${aeroTheme.zIndex.modal};
|
|
136
|
-
font-family: ${aeroTheme.typography.fontFamily.primary};
|
|
287
|
+
${PLACEMENT_CSS[placement]}
|
|
288
|
+
z-index: var(--convai-z-index, ${aeroTheme.zIndex.modal});
|
|
289
|
+
font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.primary});
|
|
137
290
|
`;
|
|
138
291
|
// Morphing container
|
|
139
292
|
morphingContainer = document.createElement("div");
|
|
293
|
+
// `part="launcher"` is precise only while collapsed: this same element
|
|
294
|
+
// morphs into the open panel (400x600, capped to the viewport — see PANEL_MAX_W)
|
|
295
|
+
// and carries that panel's background,
|
|
296
|
+
// radius and shadow. See the `part="panel"` call site below for the full
|
|
297
|
+
// semantics a host needs to know.
|
|
298
|
+
morphingContainer.setAttribute("part", "launcher");
|
|
299
|
+
// NOTE on border-radius: deliberately NOT themable. `--convai-radius`
|
|
300
|
+
// drives the open panel only. One token cannot serve both geometries --
|
|
301
|
+
// `--convai-radius: 8px` would squircle the circular launcher, and `50%`
|
|
302
|
+
// would turn the panel into an ellipse. A dedicated
|
|
303
|
+
// `--convai-launcher-radius` can be added additively later if wanted.
|
|
140
304
|
morphingContainer.style.cssText = `
|
|
141
305
|
position: relative;
|
|
142
|
-
width:
|
|
143
|
-
height:
|
|
144
|
-
|
|
306
|
+
width: var(--convai-launcher-size, 64px);
|
|
307
|
+
height: var(--convai-launcher-size, 64px);
|
|
308
|
+
max-width: ${PANEL_MAX_W};
|
|
309
|
+
max-height: ${PANEL_MAX_H};
|
|
310
|
+
background: var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop});
|
|
145
311
|
backdrop-filter: ${aeroTheme.glass.backdrop};
|
|
146
312
|
border: ${aeroTheme.glass.border};
|
|
147
313
|
border-radius: 50%;
|
|
148
|
-
box-shadow: ${aeroTheme.shadows.glass};
|
|
314
|
+
box-shadow: var(--convai-shadow, ${aeroTheme.shadows.glass});
|
|
149
315
|
transition: all 0.3s ease-in-out;
|
|
150
316
|
overflow: hidden;
|
|
151
317
|
display: flex;
|
|
@@ -166,10 +332,39 @@ export function createConvaiWidget(container, options) {
|
|
|
166
332
|
transform: scale(1);
|
|
167
333
|
`;
|
|
168
334
|
const convaiLogo = Icons.ConvaiLogo("xl", "idle");
|
|
169
|
-
convaiLogo.style.color = aeroTheme.colors.convai.light
|
|
335
|
+
convaiLogo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
|
|
170
336
|
buttonContent.appendChild(convaiLogo);
|
|
171
337
|
// Chat content
|
|
172
338
|
chatContent = document.createElement("div");
|
|
339
|
+
// ----------------------------------------------------------------------
|
|
340
|
+
// `launcher` / `panel` part semantics. Read before writing theming docs.
|
|
341
|
+
//
|
|
342
|
+
// This widget morphs rather than swapping trees: `morphingContainer` IS
|
|
343
|
+
// the 64px circle AND the open panel, animating between them (see
|
|
344
|
+
// setIsOpen). It therefore owns every painted surface property of both
|
|
345
|
+
// states -- background, border, border-radius, box-shadow. `buttonContent`
|
|
346
|
+
// and `chatContent` are transparent `inset: 0` overlays that only
|
|
347
|
+
// cross-fade their contents.
|
|
348
|
+
//
|
|
349
|
+
// So, precisely:
|
|
350
|
+
// ::part(launcher) -> morphingContainer. Paints the collapsed circle
|
|
351
|
+
// AND the open panel; there is no rule that hits
|
|
352
|
+
// only one. This is the surface part.
|
|
353
|
+
// ::part(panel) -> chatContent. The open state's *content layer*:
|
|
354
|
+
// useful for padding, colour, font, opacity. It has
|
|
355
|
+
// no background, no border and no radius of its
|
|
356
|
+
// own, so `::part(panel){border-radius}` does
|
|
357
|
+
// nothing -- set radius via ::part(launcher) or
|
|
358
|
+
// `--convai-radius` (which is panel-scoped).
|
|
359
|
+
//
|
|
360
|
+
// Assessed and deliberately not "fixed": moving `launcher` onto
|
|
361
|
+
// buttonContent would make `::part(launcher){background}` state-specific
|
|
362
|
+
// but would silently break `border-radius` and `box-shadow` on it, since
|
|
363
|
+
// buttonContent is clipped by the container's own radius + overflow:
|
|
364
|
+
// hidden. Correcting this properly needs a separate launcher element
|
|
365
|
+
// instead of a morph -- a rendering change, out of scope here.
|
|
366
|
+
// ----------------------------------------------------------------------
|
|
367
|
+
chatContent.setAttribute("part", "panel");
|
|
173
368
|
chatContent.style.cssText = `
|
|
174
369
|
position: absolute;
|
|
175
370
|
inset: 0;
|
|
@@ -190,11 +385,19 @@ export function createConvaiWidget(container, options) {
|
|
|
190
385
|
contentElement.style.cssText = `
|
|
191
386
|
flex: 1;
|
|
192
387
|
overflow-y: auto;
|
|
193
|
-
padding:
|
|
388
|
+
padding: 16px;
|
|
194
389
|
background: transparent;
|
|
390
|
+
position: relative;
|
|
195
391
|
`;
|
|
196
392
|
messageListElement = createMessageList();
|
|
197
393
|
contentElement.appendChild(messageListElement);
|
|
394
|
+
// Connecting overlay -- shown until the bot is ready. Appended last, not
|
|
395
|
+
// to match DOM order (position: absolute + z-index: 100, set inside
|
|
396
|
+
// createConnectingOverlay, is what keeps it on top regardless of source
|
|
397
|
+
// order) but so inserting it doesn't shift every existing sibling's
|
|
398
|
+
// index-based path in the inline-style-parity fixture.
|
|
399
|
+
connectingOverlay = createConnectingOverlay();
|
|
400
|
+
contentElement.appendChild(connectingOverlay);
|
|
198
401
|
// Footer
|
|
199
402
|
footerElement = createFooter();
|
|
200
403
|
chatContent.appendChild(headerElement);
|
|
@@ -211,7 +414,7 @@ export function createConvaiWidget(container, options) {
|
|
|
211
414
|
container.appendChild(floatingVideo);
|
|
212
415
|
container.appendChild(rootElement);
|
|
213
416
|
// Event listeners
|
|
214
|
-
morphingContainer.addEventListener("click",
|
|
417
|
+
morphingContainer.addEventListener("click", handleLauncherClick);
|
|
215
418
|
};
|
|
216
419
|
// Create Voice Mode Overlay
|
|
217
420
|
const createVoiceModeOverlay = () => {
|
|
@@ -222,13 +425,13 @@ export function createConvaiWidget(container, options) {
|
|
|
222
425
|
left: 50%;
|
|
223
426
|
transform: translate(-50%, -50%);
|
|
224
427
|
text-align: center;
|
|
225
|
-
padding:
|
|
428
|
+
padding: 16px;
|
|
226
429
|
z-index: 10;
|
|
227
430
|
pointer-events: auto;
|
|
228
431
|
display: none;
|
|
229
432
|
flex-direction: column;
|
|
230
433
|
align-items: center;
|
|
231
|
-
gap:
|
|
434
|
+
gap: 24px;
|
|
232
435
|
`;
|
|
233
436
|
// Bars Container
|
|
234
437
|
const barsContainer = document.createElement("div");
|
|
@@ -260,15 +463,17 @@ export function createConvaiWidget(container, options) {
|
|
|
260
463
|
const statusContainer = document.createElement("div");
|
|
261
464
|
const statusTitle = document.createElement("div");
|
|
262
465
|
statusTitle.id = "voice-mode-title";
|
|
466
|
+
refs.voiceModeTitle = statusTitle;
|
|
263
467
|
statusTitle.style.cssText = `
|
|
264
468
|
font-size: 14px;
|
|
265
469
|
font-weight: 500;
|
|
266
|
-
color: ${aeroTheme.colors.text.primary};
|
|
267
|
-
margin-bottom:
|
|
470
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
471
|
+
margin-bottom: 8px;
|
|
268
472
|
`;
|
|
269
473
|
statusTitle.textContent = "Voice Only Mode";
|
|
270
474
|
const statusSubtitle = document.createElement("div");
|
|
271
475
|
statusSubtitle.id = "voice-mode-subtitle";
|
|
476
|
+
refs.voiceModeSubtitle = statusSubtitle;
|
|
272
477
|
statusSubtitle.style.cssText = `
|
|
273
478
|
font-size: 12px;
|
|
274
479
|
color: ${aeroTheme.colors.text.secondary};
|
|
@@ -279,6 +484,91 @@ export function createConvaiWidget(container, options) {
|
|
|
279
484
|
overlay.appendChild(statusContainer);
|
|
280
485
|
return overlay;
|
|
281
486
|
};
|
|
487
|
+
// Create Connecting Overlay -- matches React's inline AnimatePresence
|
|
488
|
+
// block exactly (ConvaiWidget.tsx:681-737): shown whenever
|
|
489
|
+
// `isConnected && !isBotReady`, covering just the content area (not the
|
|
490
|
+
// header/footer) so the chrome stays interactive while the character
|
|
491
|
+
// joins. Persistent in the DOM like voiceModeOverlay above -- toggled via
|
|
492
|
+
// opacity/pointer-events rather than mounted/unmounted, since this file has
|
|
493
|
+
// no AnimatePresence-style exit-animation machinery. Visibility is driven
|
|
494
|
+
// by updateConnectingOverlay().
|
|
495
|
+
const createConnectingOverlay = () => {
|
|
496
|
+
const overlay = document.createElement("div");
|
|
497
|
+
overlay.id = "convai-connecting-overlay";
|
|
498
|
+
overlay.style.cssText = `
|
|
499
|
+
position: absolute;
|
|
500
|
+
inset: 0;
|
|
501
|
+
z-index: 100;
|
|
502
|
+
display: flex;
|
|
503
|
+
flex-direction: column;
|
|
504
|
+
align-items: center;
|
|
505
|
+
justify-content: center;
|
|
506
|
+
gap: 16px;
|
|
507
|
+
background: rgba(255, 255, 255, 0.95);
|
|
508
|
+
opacity: 0;
|
|
509
|
+
pointer-events: none;
|
|
510
|
+
transition: opacity 0.3s;
|
|
511
|
+
`;
|
|
512
|
+
// Keyframes live in the shadow-safe injector (styles.ts), not
|
|
513
|
+
// document.head -- @keyframes defined there do not resolve for shadow
|
|
514
|
+
// content. Idempotent; also called from createSettingsTray().
|
|
515
|
+
injectKeyframes(styleTarget);
|
|
516
|
+
// Spinning logo: a full turn every 2s, linear, infinite.
|
|
517
|
+
const spinWrapper = document.createElement("div");
|
|
518
|
+
spinWrapper.className = "convai-connecting-spin";
|
|
519
|
+
spinWrapper.style.cssText = `
|
|
520
|
+
display: inline-flex;
|
|
521
|
+
animation: convaiConnectingSpin 2s linear infinite;
|
|
522
|
+
`;
|
|
523
|
+
const logo = Icons.ConvaiLogo("xl", "connecting");
|
|
524
|
+
// Themable, like every other accent usage in this file (see
|
|
525
|
+
// theming.test.ts) -- var() with the exact literal React passes
|
|
526
|
+
// (`aeroTheme.colors.convai.light`) as the fallback, so an unset
|
|
527
|
+
// --convai-accent renders identically to the React value.
|
|
528
|
+
logo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
|
|
529
|
+
spinWrapper.appendChild(logo);
|
|
530
|
+
overlay.appendChild(spinWrapper);
|
|
531
|
+
// Pulsing "Connecting to {characterName}..." label.
|
|
532
|
+
const label = document.createElement("div");
|
|
533
|
+
label.id = "convai-connecting-text";
|
|
534
|
+
label.className = "convai-connecting-pulse";
|
|
535
|
+
label.style.cssText = `
|
|
536
|
+
font-size: 14px;
|
|
537
|
+
font-weight: 500;
|
|
538
|
+
color: ${aeroTheme.colors.text.secondary};
|
|
539
|
+
animation: convaiConnectingPulse 2s ease-in-out infinite;
|
|
540
|
+
`;
|
|
541
|
+
refs.connectingLabel = label;
|
|
542
|
+
overlay.appendChild(label);
|
|
543
|
+
return overlay;
|
|
544
|
+
};
|
|
545
|
+
/** Shows/hides the connecting overlay and refreshes its label. Called
|
|
546
|
+
* from updateHeader() so every existing call site (stateChange, botReady,
|
|
547
|
+
* connect/disconnect via stateChange, character-info fetch, ...) stays in
|
|
548
|
+
* sync automatically. */
|
|
549
|
+
const updateConnectingOverlay = () => {
|
|
550
|
+
if (!connectingOverlay)
|
|
551
|
+
return;
|
|
552
|
+
const label = refs.connectingLabel;
|
|
553
|
+
if (label) {
|
|
554
|
+
label.textContent = `Connecting to ${characterName}...`;
|
|
555
|
+
}
|
|
556
|
+
// `isConnecting` matters as much as `isConnected` here. The panel opens
|
|
557
|
+
// on the click that *starts* the connect, so between that click and the
|
|
558
|
+
// transport coming up (a real network round trip — ~3s against the live
|
|
559
|
+
// service) `isConnected` is still false. Gating on it alone left the
|
|
560
|
+
// whole of that window with no overlay at all: an empty message area and
|
|
561
|
+
// a nameless header, i.e. the widget looked broken for exactly as long as
|
|
562
|
+
// it was doing the one thing the user asked for. Then the label appeared
|
|
563
|
+
// *after* the transport was already up, which reads backwards.
|
|
564
|
+
//
|
|
565
|
+
// The embed makes this the normal path rather than an edge case: its
|
|
566
|
+
// launcher click opens the panel immediately by design, so the pre-connect
|
|
567
|
+
// window is always visible to the user.
|
|
568
|
+
const shouldShow = (client.state.isConnected || client.state.isConnecting) && !client.isBotReady;
|
|
569
|
+
connectingOverlay.style.opacity = shouldShow ? "1" : "0";
|
|
570
|
+
connectingOverlay.style.pointerEvents = shouldShow ? "auto" : "none";
|
|
571
|
+
};
|
|
282
572
|
// Audio Analysis State for Voice Mode
|
|
283
573
|
let audioLevels = Array(40).fill(0);
|
|
284
574
|
let targetLevels = Array(40).fill(0.05);
|
|
@@ -295,14 +585,14 @@ export function createConvaiWidget(container, options) {
|
|
|
295
585
|
// Update colors based on state
|
|
296
586
|
bars.forEach((bar) => {
|
|
297
587
|
bar.style.backgroundColor = isTalking
|
|
298
|
-
? aeroTheme.colors.convai.light
|
|
588
|
+
? `var(--convai-accent, ${aeroTheme.colors.convai.light})`
|
|
299
589
|
: isListening
|
|
300
590
|
? aeroTheme.colors.text.primary
|
|
301
591
|
: aeroTheme.colors.neutral[400];
|
|
302
592
|
});
|
|
303
593
|
// Update Text
|
|
304
|
-
const title =
|
|
305
|
-
const subtitle =
|
|
594
|
+
const title = refs.voiceModeTitle;
|
|
595
|
+
const subtitle = refs.voiceModeSubtitle;
|
|
306
596
|
if (title) {
|
|
307
597
|
title.textContent = isTalking
|
|
308
598
|
? "Character Speaking..."
|
|
@@ -408,7 +698,15 @@ export function createConvaiWidget(container, options) {
|
|
|
408
698
|
updateAudioBars();
|
|
409
699
|
}
|
|
410
700
|
catch (e) {
|
|
411
|
-
|
|
701
|
+
// Fourth mic-acquiring call site: this getUserMedia() is independent
|
|
702
|
+
// of client.audioControls (it's a separate analyser-only stream, not
|
|
703
|
+
// routed through AudioManager), but a denial here is the identical
|
|
704
|
+
// failure mode -- most commonly a mid-session permission revocation,
|
|
705
|
+
// since by the time voice mode is entered the mic has usually already
|
|
706
|
+
// been granted via unmuteAudio(). Same helper, same channel, so a
|
|
707
|
+
// consumer sees one consistent signal regardless of which internal
|
|
708
|
+
// path acquired the mic.
|
|
709
|
+
reportMicFailure(client, "Audio analysis setup failed:", e);
|
|
412
710
|
}
|
|
413
711
|
};
|
|
414
712
|
const stopAudioAnalysis = () => {
|
|
@@ -425,13 +723,14 @@ export function createConvaiWidget(container, options) {
|
|
|
425
723
|
// Create header
|
|
426
724
|
const createHeader = () => {
|
|
427
725
|
const header = document.createElement("div");
|
|
726
|
+
header.setAttribute("part", "header");
|
|
428
727
|
header.style.cssText = `
|
|
429
728
|
display: flex;
|
|
430
729
|
align-items: center;
|
|
431
730
|
justify-content: space-between;
|
|
432
|
-
padding:
|
|
731
|
+
padding: 16px;
|
|
433
732
|
border-bottom: 1px solid ${aeroTheme.colors.neutral[200]};
|
|
434
|
-
background: white;
|
|
733
|
+
background: var(--convai-panel-bg, white);
|
|
435
734
|
position: relative;
|
|
436
735
|
`;
|
|
437
736
|
// Close button on the left
|
|
@@ -439,10 +738,10 @@ export function createConvaiWidget(container, options) {
|
|
|
439
738
|
const chevronIcon = Icons.ChevronDown("md");
|
|
440
739
|
closeButton.appendChild(chevronIcon);
|
|
441
740
|
closeButton.style.cssText = `
|
|
442
|
-
font-size:
|
|
741
|
+
font-size: 20px;
|
|
443
742
|
color: ${aeroTheme.colors.text.secondary};
|
|
444
743
|
cursor: pointer;
|
|
445
|
-
padding:
|
|
744
|
+
padding: 4px;
|
|
446
745
|
transition: ${aeroTheme.transitions.fast};
|
|
447
746
|
background: transparent;
|
|
448
747
|
border: none;
|
|
@@ -459,21 +758,22 @@ export function createConvaiWidget(container, options) {
|
|
|
459
758
|
transform: translateX(-50%);
|
|
460
759
|
display: flex;
|
|
461
760
|
align-items: center;
|
|
462
|
-
gap:
|
|
761
|
+
gap: 8px;
|
|
463
762
|
font-size: ${aeroTheme.typography.fontSize.base};
|
|
464
763
|
font-weight: ${aeroTheme.typography.fontWeight.semibold};
|
|
465
|
-
color: ${aeroTheme.colors.text.primary};
|
|
764
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
466
765
|
`;
|
|
467
766
|
titleSection.id = "convai-widget-title";
|
|
767
|
+
refs.widgetTitle = titleSection;
|
|
468
768
|
// Settings button on the right
|
|
469
769
|
const settingsButton = document.createElement("button");
|
|
470
770
|
const moreIcon = Icons.MoreVertical("md");
|
|
471
771
|
settingsButton.appendChild(moreIcon);
|
|
472
772
|
settingsButton.style.cssText = `
|
|
473
|
-
font-size:
|
|
773
|
+
font-size: 24px;
|
|
474
774
|
color: ${aeroTheme.colors.text.secondary};
|
|
475
775
|
cursor: pointer;
|
|
476
|
-
padding:
|
|
776
|
+
padding: 4px;
|
|
477
777
|
transition: ${aeroTheme.transitions.fast};
|
|
478
778
|
background: transparent;
|
|
479
779
|
border: none;
|
|
@@ -490,7 +790,7 @@ export function createConvaiWidget(container, options) {
|
|
|
490
790
|
};
|
|
491
791
|
// Update header with character info
|
|
492
792
|
const updateHeader = () => {
|
|
493
|
-
const titleSection =
|
|
793
|
+
const titleSection = refs.widgetTitle;
|
|
494
794
|
if (!titleSection)
|
|
495
795
|
return;
|
|
496
796
|
titleSection.innerHTML = "";
|
|
@@ -499,8 +799,8 @@ export function createConvaiWidget(container, options) {
|
|
|
499
799
|
img.src = characterImage;
|
|
500
800
|
img.alt = characterName;
|
|
501
801
|
img.style.cssText = `
|
|
502
|
-
width:
|
|
503
|
-
height:
|
|
802
|
+
width: 24px;
|
|
803
|
+
height: 24px;
|
|
504
804
|
border-radius: 50%;
|
|
505
805
|
object-fit: cover;
|
|
506
806
|
border: 1.5px solid ${getBotStatusColor().color};
|
|
@@ -530,7 +830,7 @@ export function createConvaiWidget(container, options) {
|
|
|
530
830
|
padding: 0;
|
|
531
831
|
display: inline-flex;
|
|
532
832
|
align-items: center;
|
|
533
|
-
margin-left:
|
|
833
|
+
margin-left: 8px;
|
|
534
834
|
outline: none;
|
|
535
835
|
transition: transform 0.1s ease-out;
|
|
536
836
|
`;
|
|
@@ -551,7 +851,7 @@ export function createConvaiWidget(container, options) {
|
|
|
551
851
|
voiceBadge.textContent = "VOICE";
|
|
552
852
|
voiceBadge.style.cssText = `
|
|
553
853
|
font-size: 10px;
|
|
554
|
-
color: ${aeroTheme.colors.convai.light};
|
|
854
|
+
color: var(--convai-accent, ${aeroTheme.colors.convai.light});
|
|
555
855
|
font-weight: 500;
|
|
556
856
|
margin-left: 8px;
|
|
557
857
|
padding: 2px 6px;
|
|
@@ -616,15 +916,21 @@ export function createConvaiWidget(container, options) {
|
|
|
616
916
|
listeningTag.insertBefore(dot, listeningTag.firstChild);
|
|
617
917
|
titleSection.appendChild(listeningTag);
|
|
618
918
|
}
|
|
919
|
+
// Every call site that refreshes the header (stateChange, botReady,
|
|
920
|
+
// connect/disconnect via stateChange, character-info fetch, ...) also
|
|
921
|
+
// needs the connecting overlay re-evaluated -- it depends on the same
|
|
922
|
+
// `isConnected`/`isBotReady`/`characterName` inputs.
|
|
923
|
+
updateConnectingOverlay();
|
|
619
924
|
};
|
|
620
925
|
// Create message list
|
|
621
926
|
const createMessageList = () => {
|
|
622
927
|
const list = document.createElement("div");
|
|
623
928
|
list.id = "convai-message-list";
|
|
929
|
+
list.setAttribute("part", "messages");
|
|
624
930
|
list.style.cssText = `
|
|
625
931
|
display: flex;
|
|
626
932
|
flex-direction: column;
|
|
627
|
-
gap:
|
|
933
|
+
gap: 12px;
|
|
628
934
|
min-height: 100%;
|
|
629
935
|
`;
|
|
630
936
|
return list;
|
|
@@ -633,22 +939,23 @@ export function createConvaiWidget(container, options) {
|
|
|
633
939
|
const createFooter = () => {
|
|
634
940
|
const footer = document.createElement("div");
|
|
635
941
|
footer.style.cssText = `
|
|
636
|
-
padding:
|
|
942
|
+
padding: 16px;
|
|
637
943
|
border-top: 1px solid ${aeroTheme.colors.neutral[200]};
|
|
638
|
-
background: white;
|
|
944
|
+
background: var(--convai-panel-bg, white);
|
|
639
945
|
display: flex;
|
|
640
|
-
gap:
|
|
946
|
+
gap: 8px;
|
|
641
947
|
align-items: center;
|
|
642
948
|
position: relative;
|
|
643
949
|
`;
|
|
644
950
|
// Voice Mode Exit Button (Initially hidden)
|
|
645
951
|
const voiceExitButton = document.createElement("button");
|
|
646
952
|
voiceExitButton.id = "convai-voice-exit-btn";
|
|
953
|
+
refs.voiceExitBtn = voiceExitButton;
|
|
647
954
|
const exitIcon = Icons.PhoneOff("md");
|
|
648
955
|
voiceExitButton.appendChild(exitIcon);
|
|
649
956
|
voiceExitButton.style.cssText = `
|
|
650
|
-
width:
|
|
651
|
-
height:
|
|
957
|
+
width: 36px;
|
|
958
|
+
height: 36px;
|
|
652
959
|
border-radius: 50%;
|
|
653
960
|
background: ${aeroTheme.colors.error[500]};
|
|
654
961
|
color: white;
|
|
@@ -659,19 +966,31 @@ export function createConvaiWidget(container, options) {
|
|
|
659
966
|
margin: 0 auto;
|
|
660
967
|
border: none;
|
|
661
968
|
`;
|
|
662
|
-
voiceExitButton.addEventListener("click",
|
|
969
|
+
voiceExitButton.addEventListener("click", () => {
|
|
970
|
+
// Deliberately a *sync* listener. It used to be `async` with a bare
|
|
971
|
+
// `await client.audioControls.muteAudio()` and no try/catch: on the
|
|
972
|
+
// LiveKit path that call rethrows when the room has dropped, so a
|
|
973
|
+
// network blip mid-call plus a tap on exit leaked an unhandled
|
|
974
|
+
// rejection onto the host page *and* skipped the two lines below,
|
|
975
|
+
// stranding the user in the voice overlay with no way out.
|
|
976
|
+
//
|
|
977
|
+
// The mute is not dropped -- `applyVoiceMode()` -> `updateVoiceMode()`
|
|
978
|
+
// performs it (conditionally, if the mic is live) inside the exit
|
|
979
|
+
// branch, where a failure is now reported rather than fatal. Exiting
|
|
980
|
+
// the UI is unconditional either way: the overlay must never outlive
|
|
981
|
+
// the user's decision to leave it.
|
|
663
982
|
client.sendInterruptMessage();
|
|
664
|
-
await client.audioControls.muteAudio(); // Mute on exit
|
|
665
983
|
isVoiceMode = false;
|
|
666
|
-
|
|
984
|
+
applyVoiceMode();
|
|
667
985
|
});
|
|
668
986
|
footer.appendChild(voiceExitButton);
|
|
669
987
|
// Standard Footer Content (Mic + Input)
|
|
670
988
|
const standardContent = document.createElement("div");
|
|
671
989
|
standardContent.id = "convai-footer-standard";
|
|
990
|
+
refs.footerStandard = standardContent;
|
|
672
991
|
standardContent.style.cssText = `
|
|
673
992
|
display: flex;
|
|
674
|
-
gap:
|
|
993
|
+
gap: 8px;
|
|
675
994
|
align-items: center;
|
|
676
995
|
width: 100%;
|
|
677
996
|
`;
|
|
@@ -684,15 +1003,16 @@ export function createConvaiWidget(container, options) {
|
|
|
684
1003
|
align-items: center;
|
|
685
1004
|
`;
|
|
686
1005
|
inputElement = document.createElement("input");
|
|
1006
|
+
inputElement.setAttribute("part", "input");
|
|
687
1007
|
inputElement.type = "text";
|
|
688
1008
|
inputElement.placeholder = "Conversation";
|
|
689
1009
|
inputElement.style.cssText = `
|
|
690
1010
|
width: 100%;
|
|
691
|
-
padding:
|
|
1011
|
+
padding: 12px 44px 12px 16px;
|
|
692
1012
|
border-radius: ${aeroTheme.borderRadius.full};
|
|
693
1013
|
border: 1px solid ${aeroTheme.colors.neutral[300]};
|
|
694
1014
|
background: ${aeroTheme.colors.glass.medium};
|
|
695
|
-
color: ${aeroTheme.colors.text.primary};
|
|
1015
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
696
1016
|
font-size: ${aeroTheme.typography.fontSize.sm};
|
|
697
1017
|
transition: ${aeroTheme.transitions.fast};
|
|
698
1018
|
outline: none;
|
|
@@ -708,16 +1028,22 @@ export function createConvaiWidget(container, options) {
|
|
|
708
1028
|
// Send button
|
|
709
1029
|
const sendButton = document.createElement("button");
|
|
710
1030
|
sendButton.id = "convai-send-button";
|
|
1031
|
+
// This button is dual-role -- it sends text when the input has a draft,
|
|
1032
|
+
// and toggles voice mode when empty -- but there is no separate mic
|
|
1033
|
+
// element, so only the send-button part applies. `mic-button` is
|
|
1034
|
+
// reserved for a future version that splits the two; see parts.test.ts.
|
|
1035
|
+
sendButton.setAttribute("part", "send-button");
|
|
1036
|
+
refs.sendButton = sendButton;
|
|
711
1037
|
const sendIcon = Icons.Send("md");
|
|
712
1038
|
sendButton.appendChild(sendIcon);
|
|
713
1039
|
sendButton.style.cssText = `
|
|
714
1040
|
position: absolute;
|
|
715
|
-
right:
|
|
716
|
-
width:
|
|
717
|
-
height:
|
|
1041
|
+
right: 6px;
|
|
1042
|
+
width: 36px;
|
|
1043
|
+
height: 36px;
|
|
718
1044
|
border-radius: 50%;
|
|
719
1045
|
background: transparent; /* Initial transparent for voice toggle */
|
|
720
|
-
color: ${aeroTheme.colors.text.primary};
|
|
1046
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
721
1047
|
display: flex;
|
|
722
1048
|
align-items: center;
|
|
723
1049
|
justify-content: center;
|
|
@@ -732,9 +1058,19 @@ export function createConvaiWidget(container, options) {
|
|
|
732
1058
|
else {
|
|
733
1059
|
// Toggle Voice Mode
|
|
734
1060
|
client.sendInterruptMessage();
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1061
|
+
try {
|
|
1062
|
+
await client.audioControls.unmuteAudio(); // Unmute on enter
|
|
1063
|
+
isVoiceMode = true;
|
|
1064
|
+
// Awaited so this try/catch actually covers it -- bare, the promise
|
|
1065
|
+
// escaped the block it looks like it belongs to.
|
|
1066
|
+
await updateVoiceMode();
|
|
1067
|
+
}
|
|
1068
|
+
catch (error) {
|
|
1069
|
+
// isVoiceMode is only flipped after a successful unmute, so a
|
|
1070
|
+
// denial here leaves the widget in text mode, not stranded
|
|
1071
|
+
// mid-transition into voice mode.
|
|
1072
|
+
reportMicFailure(client, "Failed to enable microphone:", error);
|
|
1073
|
+
}
|
|
738
1074
|
}
|
|
739
1075
|
});
|
|
740
1076
|
inputContainer.appendChild(inputElement);
|
|
@@ -751,7 +1087,7 @@ export function createConvaiWidget(container, options) {
|
|
|
751
1087
|
position: absolute;
|
|
752
1088
|
top: 60px;
|
|
753
1089
|
right: 16px;
|
|
754
|
-
background: white;
|
|
1090
|
+
background: var(--convai-panel-bg, white);
|
|
755
1091
|
border-radius: ${aeroTheme.borderRadius.xl};
|
|
756
1092
|
box-shadow: ${aeroTheme.shadows.xl};
|
|
757
1093
|
padding: 0;
|
|
@@ -766,17 +1102,7 @@ export function createConvaiWidget(container, options) {
|
|
|
766
1102
|
overflow: hidden;
|
|
767
1103
|
`;
|
|
768
1104
|
// 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
|
-
}
|
|
1105
|
+
injectKeyframes(styleTarget);
|
|
780
1106
|
// Horizontal Settings Row Container
|
|
781
1107
|
const settingsRow = document.createElement("div");
|
|
782
1108
|
settingsRow.style.cssText = `
|
|
@@ -805,7 +1131,7 @@ export function createConvaiWidget(container, options) {
|
|
|
805
1131
|
? "#10b981"
|
|
806
1132
|
: isDestructive
|
|
807
1133
|
? "#ef4444"
|
|
808
|
-
: aeroTheme.colors.text.primary};
|
|
1134
|
+
: `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`};
|
|
809
1135
|
transition: ${aeroTheme.transitions.fast};
|
|
810
1136
|
min-width: 50px;
|
|
811
1137
|
`;
|
|
@@ -855,6 +1181,7 @@ export function createConvaiWidget(container, options) {
|
|
|
855
1181
|
videoIcon.style.height = "18px";
|
|
856
1182
|
const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
|
|
857
1183
|
videoBtn.id = "convai-settings-video-btn";
|
|
1184
|
+
refs.settingsVideoBtn = videoBtn;
|
|
858
1185
|
settingsRow.appendChild(videoBtn);
|
|
859
1186
|
}
|
|
860
1187
|
// Screen Share - only show if connection type is video
|
|
@@ -867,6 +1194,7 @@ export function createConvaiWidget(container, options) {
|
|
|
867
1194
|
shareIcon.style.height = "18px";
|
|
868
1195
|
const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
|
|
869
1196
|
shareBtn.id = "convai-settings-share-btn";
|
|
1197
|
+
refs.settingsShareBtn = shareBtn;
|
|
870
1198
|
settingsRow.appendChild(shareBtn);
|
|
871
1199
|
}
|
|
872
1200
|
// Disconnect
|
|
@@ -903,7 +1231,7 @@ export function createConvaiWidget(container, options) {
|
|
|
903
1231
|
? "#10b981"
|
|
904
1232
|
: isDestructive
|
|
905
1233
|
? "#ef4444"
|
|
906
|
-
: aeroTheme.colors.text.primary};
|
|
1234
|
+
: `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`};
|
|
907
1235
|
transition: ${aeroTheme.transitions.fast};
|
|
908
1236
|
min-width: 50px;
|
|
909
1237
|
`;
|
|
@@ -953,6 +1281,7 @@ export function createConvaiWidget(container, options) {
|
|
|
953
1281
|
videoIcon.style.height = "18px";
|
|
954
1282
|
const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
|
|
955
1283
|
videoBtn.id = "convai-settings-video-btn";
|
|
1284
|
+
refs.settingsVideoBtn = videoBtn;
|
|
956
1285
|
settingsRow.appendChild(videoBtn);
|
|
957
1286
|
}
|
|
958
1287
|
// Screen Share - Always show if showScreenShare is true and connection type is video
|
|
@@ -965,6 +1294,7 @@ export function createConvaiWidget(container, options) {
|
|
|
965
1294
|
shareIcon.style.height = "18px";
|
|
966
1295
|
const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
|
|
967
1296
|
shareBtn.id = "convai-settings-share-btn";
|
|
1297
|
+
refs.settingsShareBtn = shareBtn;
|
|
968
1298
|
settingsRow.appendChild(shareBtn);
|
|
969
1299
|
}
|
|
970
1300
|
// Disconnect
|
|
@@ -977,6 +1307,12 @@ export function createConvaiWidget(container, options) {
|
|
|
977
1307
|
const createFloatingVideo = () => {
|
|
978
1308
|
const container = document.createElement("div");
|
|
979
1309
|
container.id = "floating-video-container";
|
|
1310
|
+
// NOTE on z-index: deliberately NOT themable. `--convai-z-index` drives the
|
|
1311
|
+
// widget root only. Both elements are `position: fixed` and this one is
|
|
1312
|
+
// appended *before* the widget root, so on a tie the widget paints on top:
|
|
1313
|
+
// a host raising the token to clear their nav bar would hide the user's own
|
|
1314
|
+
// camera preview behind the panel. Video/screen share are also out of scope
|
|
1315
|
+
// for the embed, which is chat-only.
|
|
980
1316
|
container.style.cssText = `
|
|
981
1317
|
position: fixed;
|
|
982
1318
|
left: 20px;
|
|
@@ -1158,12 +1494,16 @@ export function createConvaiWidget(container, options) {
|
|
|
1158
1494
|
};
|
|
1159
1495
|
// Event handlers
|
|
1160
1496
|
const handleToggle = async () => {
|
|
1161
|
-
if (isOpen) {
|
|
1162
|
-
//
|
|
1497
|
+
if (isOpen || pendingOpen) {
|
|
1498
|
+
// Already open, or an open is already in flight: nothing to do. The
|
|
1499
|
+
// `pendingOpen` half is what keeps a repeat call from falling through
|
|
1500
|
+
// to the `isConnecting` branch below and opening the panel *before* the
|
|
1501
|
+
// connect it is waiting on has finished.
|
|
1163
1502
|
return;
|
|
1164
1503
|
}
|
|
1165
1504
|
// Connect on first click if not already connected/connecting
|
|
1166
1505
|
if (!client.state.isConnected && !client.state.isConnecting) {
|
|
1506
|
+
pendingOpen = true;
|
|
1167
1507
|
try {
|
|
1168
1508
|
// Use reconnect when opening again after a disconnect (reuses stored config)
|
|
1169
1509
|
if (hasConnectedBefore && typeof client.reconnect === "function") {
|
|
@@ -1172,18 +1512,40 @@ export function createConvaiWidget(container, options) {
|
|
|
1172
1512
|
else {
|
|
1173
1513
|
await client.connect();
|
|
1174
1514
|
}
|
|
1515
|
+
// Re-checked *after* the await: a close()/toggle()/launcher click
|
|
1516
|
+
// during the connect clears the flag, and that cancellation has to
|
|
1517
|
+
// win. The client stays connected either way -- only the panel is
|
|
1518
|
+
// held back -- so a later open() takes the already-connected branch
|
|
1519
|
+
// and opens instantly.
|
|
1520
|
+
if (!pendingOpen)
|
|
1521
|
+
return;
|
|
1175
1522
|
setIsOpen(true);
|
|
1176
1523
|
}
|
|
1177
1524
|
catch (error) {
|
|
1178
1525
|
console.error("Failed to connect:", error);
|
|
1179
1526
|
}
|
|
1527
|
+
finally {
|
|
1528
|
+
pendingOpen = false;
|
|
1529
|
+
}
|
|
1180
1530
|
}
|
|
1181
1531
|
else {
|
|
1182
1532
|
// Just toggle open/close if already connected
|
|
1183
1533
|
setIsOpen(!isOpen);
|
|
1184
1534
|
}
|
|
1185
1535
|
};
|
|
1536
|
+
/** A launcher click while an open is pending means "cancel", same as `toggle()`. */
|
|
1537
|
+
const handleLauncherClick = () => {
|
|
1538
|
+
if (pendingOpen) {
|
|
1539
|
+
handleClose();
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
void handleToggle();
|
|
1543
|
+
};
|
|
1186
1544
|
const handleClose = () => {
|
|
1545
|
+
// Cancels an in-flight open as well as closing an open panel; see
|
|
1546
|
+
// `pendingOpen`. `setIsOpen(false)` on an already-closed widget just
|
|
1547
|
+
// re-applies the collapsed styles, so this is safe in both cases.
|
|
1548
|
+
pendingOpen = false;
|
|
1187
1549
|
setIsOpen(false);
|
|
1188
1550
|
};
|
|
1189
1551
|
const handleSend = () => {
|
|
@@ -1218,7 +1580,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1218
1580
|
isVoiceMode = false;
|
|
1219
1581
|
hasEnteredDefaultVoiceMode = false;
|
|
1220
1582
|
updateHeader();
|
|
1221
|
-
updateVoiceMode();
|
|
1583
|
+
await updateVoiceMode();
|
|
1222
1584
|
updateSendButton();
|
|
1223
1585
|
}
|
|
1224
1586
|
catch (error) {
|
|
@@ -1232,7 +1594,11 @@ export function createConvaiWidget(container, options) {
|
|
|
1232
1594
|
isVoiceMode = false;
|
|
1233
1595
|
hasEnteredDefaultVoiceMode = false;
|
|
1234
1596
|
updateHeader();
|
|
1235
|
-
|
|
1597
|
+
// Not awaited: this function has no try/catch of its own and is wired
|
|
1598
|
+
// straight to a click, so awaiting would only move a rejection from one
|
|
1599
|
+
// unguarded place to another. See the note on `handleDisconnect`'s own
|
|
1600
|
+
// `await client.disconnect()` in the fix report.
|
|
1601
|
+
applyVoiceMode();
|
|
1236
1602
|
updateSendButton();
|
|
1237
1603
|
};
|
|
1238
1604
|
const handleToggleVideo = async () => {
|
|
@@ -1271,10 +1637,17 @@ export function createConvaiWidget(container, options) {
|
|
|
1271
1637
|
const setIsOpen = async (open) => {
|
|
1272
1638
|
isOpen = open;
|
|
1273
1639
|
if (open) {
|
|
1274
|
-
morphingContainer.style.width =
|
|
1275
|
-
morphingContainer.style.height =
|
|
1276
|
-
morphingContainer.style.borderRadius = aeroTheme.borderRadius.xl
|
|
1640
|
+
morphingContainer.style.width = `${PANEL_W_PX}px`;
|
|
1641
|
+
morphingContainer.style.height = `${PANEL_H_PX}px`;
|
|
1642
|
+
morphingContainer.style.borderRadius = `var(--convai-radius, ${aeroTheme.borderRadius.xl})`;
|
|
1277
1643
|
morphingContainer.style.cursor = "default";
|
|
1644
|
+
// The open panel is a *different* glass treatment from the launcher
|
|
1645
|
+
// bubble, matching the React widget: near-opaque and saturated, so the
|
|
1646
|
+
// host page does not bleed through. At the bubble's 0.8 alpha a dark
|
|
1647
|
+
// host page shows through the panel and reads as grey.
|
|
1648
|
+
morphingContainer.style.background =
|
|
1649
|
+
"var(--convai-panel-bg, rgba(252, 252, 253, 0.95))";
|
|
1650
|
+
morphingContainer.style.backdropFilter = "blur(20px) saturate(180%)";
|
|
1278
1651
|
buttonContent.style.opacity = "0";
|
|
1279
1652
|
buttonContent.style.transform = "scale(0.8)";
|
|
1280
1653
|
buttonContent.style.pointerEvents = "none";
|
|
@@ -1290,19 +1663,24 @@ export function createConvaiWidget(container, options) {
|
|
|
1290
1663
|
await client.audioControls.unmuteAudio();
|
|
1291
1664
|
isVoiceMode = true;
|
|
1292
1665
|
hasEnteredDefaultVoiceMode = true;
|
|
1293
|
-
updateVoiceMode();
|
|
1666
|
+
await updateVoiceMode();
|
|
1294
1667
|
}
|
|
1295
1668
|
catch (error) {
|
|
1296
|
-
|
|
1669
|
+
reportMicFailure(client, "Failed to enter voice mode on open:", error);
|
|
1297
1670
|
}
|
|
1298
1671
|
}, 100);
|
|
1299
1672
|
}
|
|
1300
1673
|
}
|
|
1301
1674
|
else {
|
|
1302
|
-
morphingContainer.style.width = "
|
|
1303
|
-
morphingContainer.style.height = "
|
|
1675
|
+
morphingContainer.style.width = "var(--convai-launcher-size, 64px)";
|
|
1676
|
+
morphingContainer.style.height = "var(--convai-launcher-size, 64px)";
|
|
1677
|
+
// Literal, not `--convai-radius` — see the creation site: that token is
|
|
1678
|
+
// the *panel* radius, and reusing it here would squircle the launcher.
|
|
1304
1679
|
morphingContainer.style.borderRadius = "50%";
|
|
1305
1680
|
morphingContainer.style.cursor = "pointer";
|
|
1681
|
+
// Back to the launcher bubble's lighter glass — see the open branch.
|
|
1682
|
+
morphingContainer.style.background = `var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop})`;
|
|
1683
|
+
morphingContainer.style.backdropFilter = aeroTheme.glass.backdrop;
|
|
1306
1684
|
buttonContent.style.opacity = "1";
|
|
1307
1685
|
buttonContent.style.transform = "scale(1)";
|
|
1308
1686
|
buttonContent.style.pointerEvents = "auto";
|
|
@@ -1316,7 +1694,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1316
1694
|
settingsTray.style.display = open ? "flex" : "none";
|
|
1317
1695
|
};
|
|
1318
1696
|
const updateSendButton = () => {
|
|
1319
|
-
const sendButton =
|
|
1697
|
+
const sendButton = refs.sendButton;
|
|
1320
1698
|
if (!sendButton)
|
|
1321
1699
|
return;
|
|
1322
1700
|
// Clear previous content
|
|
@@ -1324,8 +1702,15 @@ export function createConvaiWidget(container, options) {
|
|
|
1324
1702
|
if (inputValue.length > 0) {
|
|
1325
1703
|
// Show Send Button
|
|
1326
1704
|
sendButton.appendChild(Icons.Send("md"));
|
|
1327
|
-
sendButton.style.background = aeroTheme.colors.text.primary
|
|
1328
|
-
|
|
1705
|
+
sendButton.style.background = `var(--convai-accent, ${aeroTheme.colors.text.primary})`;
|
|
1706
|
+
// The label sits *on* the accent fill, so it must follow the panel
|
|
1707
|
+
// surface colour rather than stay a hardcoded "white" -- a host setting
|
|
1708
|
+
// a pale --convai-accent would otherwise get white-on-white. `white` is
|
|
1709
|
+
// byte-identical to the fallback --convai-panel-bg already carries at
|
|
1710
|
+
// its three other sites, so the default rendering is unchanged.
|
|
1711
|
+
// Pairing requirement for hosts: --convai-accent and --convai-panel-bg
|
|
1712
|
+
// must contrast with each other, since this is the one place they meet.
|
|
1713
|
+
sendButton.style.color = "var(--convai-panel-bg, white)";
|
|
1329
1714
|
sendButton.style.border = "none";
|
|
1330
1715
|
sendButton.title = "Send";
|
|
1331
1716
|
}
|
|
@@ -1333,7 +1718,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1333
1718
|
// Show Voice Mode Toggle
|
|
1334
1719
|
sendButton.appendChild(Icons.Waveform("md"));
|
|
1335
1720
|
sendButton.style.background = "transparent";
|
|
1336
|
-
sendButton.style.color = aeroTheme.colors.text.primary
|
|
1721
|
+
sendButton.style.color = `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
|
|
1337
1722
|
sendButton.style.border = `1px solid ${aeroTheme.colors.neutral[300]}`;
|
|
1338
1723
|
sendButton.title = "Voice Mode";
|
|
1339
1724
|
}
|
|
@@ -1364,8 +1749,8 @@ export function createConvaiWidget(container, options) {
|
|
|
1364
1749
|
}
|
|
1365
1750
|
};
|
|
1366
1751
|
const updateVoiceMode = async () => {
|
|
1367
|
-
const standardFooter =
|
|
1368
|
-
const voiceExitBtn =
|
|
1752
|
+
const standardFooter = refs.footerStandard;
|
|
1753
|
+
const voiceExitBtn = refs.voiceExitBtn;
|
|
1369
1754
|
if (isVoiceMode) {
|
|
1370
1755
|
// Show Voice Overlay
|
|
1371
1756
|
if (voiceModeOverlay)
|
|
@@ -1391,9 +1776,22 @@ export function createConvaiWidget(container, options) {
|
|
|
1391
1776
|
startAudioAnalysis();
|
|
1392
1777
|
}
|
|
1393
1778
|
else {
|
|
1394
|
-
// Ensure microphone is muted when not in voice mode
|
|
1779
|
+
// Ensure microphone is muted when not in voice mode.
|
|
1780
|
+
//
|
|
1781
|
+
// This await is the only one in this function that can reject, and
|
|
1782
|
+
// everything below it -- hiding the overlay, restoring the standard
|
|
1783
|
+
// footer, updating the header, stopping the analyser -- is the only
|
|
1784
|
+
// route out of the voice UI. So a failed mute must never short-circuit
|
|
1785
|
+
// it: report it and carry on tearing the overlay down. (LiveKit's
|
|
1786
|
+
// setMicrophoneEnabled(false) rethrows on a dropped room, which is
|
|
1787
|
+
// exactly when the user is most likely to be hitting exit.)
|
|
1395
1788
|
if (!client.audioControls.isAudioMuted) {
|
|
1396
|
-
|
|
1789
|
+
try {
|
|
1790
|
+
await client.audioControls.muteAudio();
|
|
1791
|
+
}
|
|
1792
|
+
catch (error) {
|
|
1793
|
+
reportMicFailure(client, "Failed to mute microphone:", error);
|
|
1794
|
+
}
|
|
1397
1795
|
}
|
|
1398
1796
|
// Hide Overlay
|
|
1399
1797
|
if (voiceModeOverlay)
|
|
@@ -1425,6 +1823,24 @@ export function createConvaiWidget(container, options) {
|
|
|
1425
1823
|
stopAudioAnalysis();
|
|
1426
1824
|
}
|
|
1427
1825
|
};
|
|
1826
|
+
/**
|
|
1827
|
+
* Fire-and-forget `updateVoiceMode()` for the call sites that are
|
|
1828
|
+
* synchronous (DOM listeners, the constructor tail) and therefore have no
|
|
1829
|
+
* caller to catch anything it rejects with.
|
|
1830
|
+
*
|
|
1831
|
+
* `updateVoiceMode()` is `async`, so a bare call there would put a floating
|
|
1832
|
+
* promise on the host page. Its one rejecting await is now guarded
|
|
1833
|
+
* internally, which makes this belt-and-braces -- but it is the guarantee
|
|
1834
|
+
* that keeps a future await added inside `updateVoiceMode()` from silently
|
|
1835
|
+
* reintroducing the unhandled rejection this replaces. Routed through
|
|
1836
|
+
* `reportMicFailure` so a failure is observable on the client's `error`
|
|
1837
|
+
* channel rather than console-only.
|
|
1838
|
+
*/
|
|
1839
|
+
const applyVoiceMode = () => {
|
|
1840
|
+
void updateVoiceMode().catch((error) => {
|
|
1841
|
+
reportMicFailure(client, "Failed to apply voice mode:", error);
|
|
1842
|
+
});
|
|
1843
|
+
};
|
|
1428
1844
|
// Microphone button removed - only controlled via voice mode now
|
|
1429
1845
|
// Helper for Markdown - matches React MarkdownRenderer.tsx
|
|
1430
1846
|
const renderMarkdown = (text, container) => {
|
|
@@ -1459,7 +1875,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1459
1875
|
strong.textContent = match[2];
|
|
1460
1876
|
strong.style.cssText = `
|
|
1461
1877
|
font-weight: 600;
|
|
1462
|
-
color: ${aeroTheme.colors.text.primary};
|
|
1878
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
1463
1879
|
`;
|
|
1464
1880
|
parts.push(strong);
|
|
1465
1881
|
}
|
|
@@ -1526,7 +1942,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1526
1942
|
height: 100%;
|
|
1527
1943
|
color: ${aeroTheme.colors.text.secondary};
|
|
1528
1944
|
text-align: center;
|
|
1529
|
-
padding:
|
|
1945
|
+
padding: 32px;
|
|
1530
1946
|
`;
|
|
1531
1947
|
emptyState.innerHTML = `
|
|
1532
1948
|
<p style="font-weight: 500; margin-bottom: 8px;">Start a conversation</p>
|
|
@@ -1548,6 +1964,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1548
1964
|
contain: layout style paint;
|
|
1549
1965
|
`;
|
|
1550
1966
|
const bubble = document.createElement("div");
|
|
1967
|
+
bubble.setAttribute("part", msg.isUser ? "message message-user" : "message message-bot");
|
|
1551
1968
|
// Add data attribute to identify user messages for debugging/styling
|
|
1552
1969
|
if (msg.isUser) {
|
|
1553
1970
|
bubble.setAttribute("data-message-type", "user");
|
|
@@ -1555,6 +1972,10 @@ export function createConvaiWidget(container, options) {
|
|
|
1555
1972
|
// Set base styles first - explicitly set background to match React UserBubble exactly
|
|
1556
1973
|
// CRITICAL: Use the exact same background for both user and bot messages
|
|
1557
1974
|
const backgroundColor = "rgba(252, 252, 253, 0.95)";
|
|
1975
|
+
// NOTE on border-radius: deliberately NOT themable. `--convai-radius` is
|
|
1976
|
+
// the open panel's radius; a bubble's asymmetric tail corner is a
|
|
1977
|
+
// different geometry (`50%` would make ellipses of these). A dedicated
|
|
1978
|
+
// `--convai-bubble-radius` can be added additively later if wanted.
|
|
1558
1979
|
bubble.style.cssText = `
|
|
1559
1980
|
padding: 12px;
|
|
1560
1981
|
border-radius: ${msg.isUser ? "12px 12px 4px 12px" : "12px 12px 12px 4px"};
|
|
@@ -1563,8 +1984,8 @@ export function createConvaiWidget(container, options) {
|
|
|
1563
1984
|
backdrop-filter: blur(20px) saturate(180%);
|
|
1564
1985
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
1565
1986
|
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};
|
|
1987
|
+
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
1988
|
+
font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
|
|
1568
1989
|
font-size: 14px;
|
|
1569
1990
|
line-height: 1.5;
|
|
1570
1991
|
word-wrap: break-word;
|
|
@@ -1596,8 +2017,8 @@ export function createConvaiWidget(container, options) {
|
|
|
1596
2017
|
// Logo/Icon - matches React LogoWrapper
|
|
1597
2018
|
const logoContainer = document.createElement("div");
|
|
1598
2019
|
logoContainer.style.cssText = `
|
|
1599
|
-
width:
|
|
1600
|
-
height:
|
|
2020
|
+
width: 16px;
|
|
2021
|
+
height: 16px;
|
|
1601
2022
|
display: flex;
|
|
1602
2023
|
align-items: center;
|
|
1603
2024
|
justify-content: center;
|
|
@@ -1624,10 +2045,10 @@ export function createConvaiWidget(container, options) {
|
|
|
1624
2045
|
const nameLabel = document.createElement("span");
|
|
1625
2046
|
nameLabel.textContent = msg.sender === "User" ? "You" : msg.sender;
|
|
1626
2047
|
nameLabel.style.cssText = `
|
|
1627
|
-
font-size:
|
|
2048
|
+
font-size: 12px;
|
|
1628
2049
|
color: ${msg.isUser ? aeroTheme.colors.convai.dark : "#10b981"};
|
|
1629
2050
|
font-weight: 500;
|
|
1630
|
-
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
2051
|
+
font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
|
|
1631
2052
|
isolation: isolate;
|
|
1632
2053
|
contain: layout style paint;
|
|
1633
2054
|
`;
|
|
@@ -1649,11 +2070,11 @@ export function createConvaiWidget(container, options) {
|
|
|
1649
2070
|
const timestamp = document.createElement("span");
|
|
1650
2071
|
timestamp.textContent = msg.timestamp;
|
|
1651
2072
|
timestamp.style.cssText = `
|
|
1652
|
-
font-size:
|
|
2073
|
+
font-size: 12px;
|
|
1653
2074
|
color: ${aeroTheme.colors.text.secondary};
|
|
1654
2075
|
margin-top: 4px;
|
|
1655
2076
|
align-self: ${msg.isUser ? "flex-end" : "flex-start"};
|
|
1656
|
-
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
2077
|
+
font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
|
|
1657
2078
|
`;
|
|
1658
2079
|
messageWrapper.appendChild(timestamp);
|
|
1659
2080
|
}
|
|
@@ -1730,7 +2151,10 @@ export function createConvaiWidget(container, options) {
|
|
|
1730
2151
|
isVideoVisible = false;
|
|
1731
2152
|
hasEnteredDefaultVoiceMode = false;
|
|
1732
2153
|
updateHeader();
|
|
1733
|
-
|
|
2154
|
+
// Synchronous listener: nothing here can catch a rejection, and the
|
|
2155
|
+
// mute inside is the one most likely to fail (the room has just
|
|
2156
|
+
// dropped -- that is why we are collapsing).
|
|
2157
|
+
applyVoiceMode();
|
|
1734
2158
|
}
|
|
1735
2159
|
// Auto-enter voice mode on first connection if defaultVoiceMode is true
|
|
1736
2160
|
if (client.state.isConnected &&
|
|
@@ -1743,10 +2167,10 @@ export function createConvaiWidget(container, options) {
|
|
|
1743
2167
|
await client.audioControls.unmuteAudio();
|
|
1744
2168
|
isVoiceMode = true;
|
|
1745
2169
|
hasEnteredDefaultVoiceMode = true;
|
|
1746
|
-
updateVoiceMode();
|
|
2170
|
+
await updateVoiceMode();
|
|
1747
2171
|
}
|
|
1748
2172
|
catch (error) {
|
|
1749
|
-
|
|
2173
|
+
reportMicFailure(client, "Failed to enter voice mode:", error);
|
|
1750
2174
|
}
|
|
1751
2175
|
}, 100);
|
|
1752
2176
|
}
|
|
@@ -1756,9 +2180,9 @@ export function createConvaiWidget(container, options) {
|
|
|
1756
2180
|
client.videoControls.on("videoStateChange", (videoState) => {
|
|
1757
2181
|
if (videoState.isVideoEnabled !== undefined) {
|
|
1758
2182
|
isVideoVisible = videoState.isVideoEnabled;
|
|
1759
|
-
|
|
2183
|
+
applyVoiceMode(); // synchronous listener; see applyVoiceMode()
|
|
1760
2184
|
// Update tray button state
|
|
1761
|
-
const videoBtn =
|
|
2185
|
+
const videoBtn = refs.settingsVideoBtn;
|
|
1762
2186
|
if (videoBtn) {
|
|
1763
2187
|
const isActive = isVideoVisible;
|
|
1764
2188
|
videoBtn.style.backgroundColor = isActive
|
|
@@ -1766,7 +2190,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1766
2190
|
: "transparent";
|
|
1767
2191
|
videoBtn.style.color = isActive
|
|
1768
2192
|
? "#10b981"
|
|
1769
|
-
: aeroTheme.colors.text.primary
|
|
2193
|
+
: `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
|
|
1770
2194
|
// Update Icon - first child is icon container
|
|
1771
2195
|
const iconContainer = videoBtn.querySelector("div");
|
|
1772
2196
|
if (iconContainer) {
|
|
@@ -1783,7 +2207,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1783
2207
|
client.screenShareControls.on("screenShareStateChange", (screenShareState) => {
|
|
1784
2208
|
if (screenShareState.isScreenShareActive !== undefined) {
|
|
1785
2209
|
const isSharing = screenShareState.isScreenShareActive;
|
|
1786
|
-
const shareBtn =
|
|
2210
|
+
const shareBtn = refs.settingsShareBtn;
|
|
1787
2211
|
if (shareBtn) {
|
|
1788
2212
|
const isActive = isSharing;
|
|
1789
2213
|
shareBtn.style.backgroundColor = isActive
|
|
@@ -1791,7 +2215,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1791
2215
|
: "transparent";
|
|
1792
2216
|
shareBtn.style.color = isActive
|
|
1793
2217
|
? "#10b981"
|
|
1794
|
-
: aeroTheme.colors.text.primary
|
|
2218
|
+
: `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
|
|
1795
2219
|
// Update Icon - first child is icon container
|
|
1796
2220
|
const iconContainer = shareBtn.querySelector("div");
|
|
1797
2221
|
if (iconContainer) {
|
|
@@ -1821,8 +2245,13 @@ export function createConvaiWidget(container, options) {
|
|
|
1821
2245
|
client.on("connect", () => {
|
|
1822
2246
|
hasConnectedBefore = true;
|
|
1823
2247
|
// Initialize audio renderer on connection
|
|
2248
|
+
// NOTE: the audio elements live under rootElement, so playback ends if
|
|
2249
|
+
// the host *removes or re-parents* its mount container mid-call. Merely
|
|
2250
|
+
// hiding it (display: none / visibility: hidden) does NOT stop
|
|
2251
|
+
// HTMLMediaElement playback in any browser — an earlier version of this
|
|
2252
|
+
// comment claimed otherwise and was wrong.
|
|
1824
2253
|
if (client.room && !audioRenderer) {
|
|
1825
|
-
audioRenderer = new AudioRenderer(client.room);
|
|
2254
|
+
audioRenderer = new AudioRenderer(client.room, { container: rootElement });
|
|
1826
2255
|
}
|
|
1827
2256
|
// Fetch character info
|
|
1828
2257
|
fetchCharacterInfo();
|
|
@@ -1847,21 +2276,80 @@ export function createConvaiWidget(container, options) {
|
|
|
1847
2276
|
// Initialize
|
|
1848
2277
|
createDOM();
|
|
1849
2278
|
setupClientListeners();
|
|
2279
|
+
// Paint the connecting overlay's initial state. updateHeader() (which also
|
|
2280
|
+
// calls updateConnectingOverlay()) is otherwise only reached via a client
|
|
2281
|
+
// event or a successful character-info fetch, so a widget constructed
|
|
2282
|
+
// already connected-but-not-ready would show nothing until one of those
|
|
2283
|
+
// fires without this.
|
|
2284
|
+
updateConnectingOverlay();
|
|
1850
2285
|
// Set initial button state
|
|
1851
2286
|
updateSendButton(); // Show voice mode button initially since input is empty
|
|
1852
2287
|
// Set initial voice mode UI if defaultVoiceMode is true
|
|
1853
2288
|
if (defaultVoiceMode) {
|
|
1854
|
-
|
|
2289
|
+
applyVoiceMode(); // synchronous constructor tail; see applyVoiceMode()
|
|
1855
2290
|
}
|
|
1856
2291
|
// If already connected, initialize audio renderer and fetch character info
|
|
2292
|
+
// NOTE: same lifetime caveat as the "connect" handler above — the audio
|
|
2293
|
+
// elements are torn down with rootElement if the host detaches it.
|
|
1857
2294
|
if (client.state.isConnected && client.room) {
|
|
1858
|
-
audioRenderer = new AudioRenderer(client.room);
|
|
2295
|
+
audioRenderer = new AudioRenderer(client.room, { container: rootElement });
|
|
1859
2296
|
fetchCharacterInfo();
|
|
1860
2297
|
}
|
|
1861
2298
|
// Return widget instance
|
|
1862
2299
|
const widget = {
|
|
1863
2300
|
element: rootElement,
|
|
1864
2301
|
client: client,
|
|
2302
|
+
// `isOpen` reports *requested* state, which on a warm client is the same
|
|
2303
|
+
// thing as rendered state: `setIsOpen()` runs synchronously. The one case
|
|
2304
|
+
// where they differ is the first open on a disconnected client, where the
|
|
2305
|
+
// panel is committed only after `connect()` resolves; reporting `false`
|
|
2306
|
+
// for those seconds would tell an embed reconciling its own
|
|
2307
|
+
// convai-open/convai-close events that a widget it just opened is closed.
|
|
2308
|
+
// `pendingOpen` is cleared by every cancellation path, so this never
|
|
2309
|
+
// reports open for a request that was called off.
|
|
2310
|
+
get isOpen() {
|
|
2311
|
+
return isOpen || pendingOpen;
|
|
2312
|
+
},
|
|
2313
|
+
// Drives the exact same path the launcher click does: `handleToggle`
|
|
2314
|
+
// (connect-on-first-open, then `setIsOpen(true)`). Idempotent against an
|
|
2315
|
+
// in-flight open as well as an open panel, so repeat calls never start a
|
|
2316
|
+
// second connect.
|
|
2317
|
+
open: () => {
|
|
2318
|
+
if (isOpen || pendingOpen)
|
|
2319
|
+
return;
|
|
2320
|
+
void handleToggle();
|
|
2321
|
+
},
|
|
2322
|
+
// Drives the same path the header's close button does: `handleClose` ->
|
|
2323
|
+
// `setIsOpen(false)`. Also cancels an open that is still waiting on
|
|
2324
|
+
// `connect()`; a redundant `close()` is a no-op.
|
|
2325
|
+
close: () => {
|
|
2326
|
+
if (!isOpen && !pendingOpen)
|
|
2327
|
+
return;
|
|
2328
|
+
handleClose();
|
|
2329
|
+
},
|
|
2330
|
+
toggle: () => {
|
|
2331
|
+
if (isOpen || pendingOpen) {
|
|
2332
|
+
handleClose();
|
|
2333
|
+
}
|
|
2334
|
+
else {
|
|
2335
|
+
void handleToggle();
|
|
2336
|
+
}
|
|
2337
|
+
},
|
|
2338
|
+
// Pushes a synthetic ChatMessage straight onto `client.chatMessages` and
|
|
2339
|
+
// re-renders via the widget's existing `updateMessageList()`. It never
|
|
2340
|
+
// calls a client send method (e.g. `sendUserTextMessage`), so the
|
|
2341
|
+
// message never reaches the character and is never billed as an LLM
|
|
2342
|
+
// turn. The `local-` id keeps it from colliding with a server-issued id.
|
|
2343
|
+
appendMessage: (message) => {
|
|
2344
|
+
const chatMessage = {
|
|
2345
|
+
id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
2346
|
+
type: message.sender === "user" ? "user-llm-text" : "bot-llm-text",
|
|
2347
|
+
content: message.content,
|
|
2348
|
+
timestamp: new Date().toISOString(),
|
|
2349
|
+
};
|
|
2350
|
+
client.chatMessages.push(chatMessage);
|
|
2351
|
+
updateMessageList();
|
|
2352
|
+
},
|
|
1865
2353
|
destroy: () => {
|
|
1866
2354
|
// Cleanup audio renderer
|
|
1867
2355
|
if (audioRenderer) {
|
|
@@ -1869,7 +2357,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1869
2357
|
audioRenderer = null;
|
|
1870
2358
|
}
|
|
1871
2359
|
// Remove event listeners
|
|
1872
|
-
morphingContainer.removeEventListener("click",
|
|
2360
|
+
morphingContainer.removeEventListener("click", handleLauncherClick);
|
|
1873
2361
|
// Remove DOM elements
|
|
1874
2362
|
if (rootElement.parentElement) {
|
|
1875
2363
|
rootElement.remove();
|