@convai/web-sdk 1.8.0-beta.7 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -194
- package/dist/core/ConvaiClient.d.ts +1 -190
- package/dist/core/ConvaiClient.d.ts.map +1 -1
- package/dist/core/ConvaiClient.js +286 -1306
- package/dist/core/ConvaiClient.js.map +1 -1
- package/dist/core/MessageHandler.d.ts +1 -19
- package/dist/core/MessageHandler.d.ts.map +1 -1
- package/dist/core/MessageHandler.js +27 -378
- package/dist/core/MessageHandler.js.map +1 -1
- package/dist/core/connectRequest.d.ts +4 -17
- package/dist/core/connectRequest.d.ts.map +1 -1
- package/dist/core/connectRequest.js +17 -28
- package/dist/core/connectRequest.js.map +1 -1
- package/dist/core/index.d.ts +1 -6
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -7
- package/dist/core/index.js.map +1 -1
- package/dist/core/logicalTurn.d.ts +6 -4
- package/dist/core/logicalTurn.d.ts.map +1 -1
- package/dist/core/logicalTurn.js +4 -29
- package/dist/core/logicalTurn.js.map +1 -1
- package/dist/core/modelOutput.d.ts.map +1 -1
- package/dist/core/modelOutput.js +2 -2
- package/dist/core/modelOutput.js.map +1 -1
- package/dist/core/types.d.ts +22 -860
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/embed/chat-embed-v1.js +9 -10
- package/dist/embed/chat-embed-v1.js.map +4 -4
- package/dist/react/components/ConvaiWidget.d.ts.map +1 -1
- package/dist/react/components/ConvaiWidget.js +1 -6
- package/dist/react/components/ConvaiWidget.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 +1 -28
- package/dist/react/components/rtc-widget/components/AudioVisualizer.js.map +1 -1
- package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
- package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +44 -39
- package/dist/react/components/rtc-widget/components/MarkdownRenderer.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 +49 -93
- 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 +2 -17
- package/dist/react/hooks/useConvaiClient.js.map +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/vanilla/AudioRenderer.d.ts +1 -3
- package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
- package/dist/vanilla/AudioRenderer.js +2 -8
- package/dist/vanilla/AudioRenderer.js.map +1 -1
- package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
- package/dist/vanilla/ConvaiWidget.js +300 -994
- package/dist/vanilla/ConvaiWidget.js.map +1 -1
- package/dist/vanilla/WidgetEventSubscriptions.d.ts +1 -1
- package/dist/vanilla/WidgetEventSubscriptions.d.ts.map +1 -1
- package/dist/vanilla/WidgetEventSubscriptions.js +0 -5
- package/dist/vanilla/WidgetEventSubscriptions.js.map +1 -1
- package/dist/vanilla/icons.d.ts.map +1 -1
- package/dist/vanilla/icons.js +15 -62
- package/dist/vanilla/icons.js.map +1 -1
- package/dist/vanilla/index.d.ts +2 -4
- package/dist/vanilla/index.d.ts.map +1 -1
- package/dist/vanilla/index.js +1 -3
- package/dist/vanilla/index.js.map +1 -1
- package/dist/vanilla/styles.d.ts +1 -18
- package/dist/vanilla/styles.d.ts.map +1 -1
- package/dist/vanilla/styles.js +29 -129
- package/dist/vanilla/styles.js.map +1 -1
- package/dist/vanilla/types.d.ts +0 -74
- 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 +8 -13
- package/dist/core/CharacterRoster.d.ts +0 -84
- package/dist/core/CharacterRoster.d.ts.map +0 -1
- package/dist/core/CharacterRoster.js +0 -348
- package/dist/core/CharacterRoster.js.map +0 -1
- package/dist/core/CharacterVersionManager.d.ts +0 -100
- package/dist/core/CharacterVersionManager.d.ts.map +0 -1
- package/dist/core/CharacterVersionManager.js +0 -227
- package/dist/core/CharacterVersionManager.js.map +0 -1
- package/dist/core/ConvaiRoomError.d.ts +0 -48
- package/dist/core/ConvaiRoomError.d.ts.map +0 -1
- package/dist/core/ConvaiRoomError.js +0 -111
- package/dist/core/ConvaiRoomError.js.map +0 -1
- package/dist/core/SSESession.d.ts +0 -59
- package/dist/core/SSESession.d.ts.map +0 -1
- package/dist/core/SSESession.js +0 -289
- package/dist/core/SSESession.js.map +0 -1
- package/dist/core/characterReference.d.ts +0 -40
- package/dist/core/characterReference.d.ts.map +0 -1
- package/dist/core/characterReference.js +0 -72
- package/dist/core/characterReference.js.map +0 -1
- package/dist/core/rosterRequest.d.ts +0 -57
- package/dist/core/rosterRequest.d.ts.map +0 -1
- package/dist/core/rosterRequest.js +0 -210
- package/dist/core/rosterRequest.js.map +0 -1
- package/dist/utils/inlineMarkdown.d.ts +0 -38
- package/dist/utils/inlineMarkdown.d.ts.map +0 -1
- package/dist/utils/inlineMarkdown.js +0 -106
- package/dist/utils/inlineMarkdown.js.map +0 -1
|
@@ -3,107 +3,11 @@
|
|
|
3
3
|
* Ports the React ConvaiWidget to vanilla TypeScript with DOM manipulation
|
|
4
4
|
*/
|
|
5
5
|
import { AudioRenderer } from "./AudioRenderer.js";
|
|
6
|
-
import { aeroTheme, injectGlobalStyles
|
|
6
|
+
import { aeroTheme, injectGlobalStyles } from "./styles.js";
|
|
7
7
|
import { Icons } from "./icons.js";
|
|
8
8
|
import { WidgetEventSubscriptions } from "./WidgetEventSubscriptions.js";
|
|
9
9
|
import { ConvaiClient } from "../core/ConvaiClient.js";
|
|
10
|
-
import { parseInlineMarkdown } from "../utils/inlineMarkdown.js";
|
|
11
10
|
import { freshPublicationGrantOptions } from "../core/publishedChat.js";
|
|
12
|
-
/**
|
|
13
|
-
* Positioning declarations for the widget root, keyed by `placement`. The
|
|
14
|
-
* `bottom-right` entry must stay byte-identical to the literals it replaces --
|
|
15
|
-
* the inline-style parity suite snapshots the resolved `cssText` and compares
|
|
16
|
-
* it to a committed fixture.
|
|
17
|
-
*/
|
|
18
|
-
/** Gap between a fixed-placement widget and the viewport edge it anchors to. */
|
|
19
|
-
const PLACEMENT_INSET_PX = 24;
|
|
20
|
-
const PLACEMENT_CSS = {
|
|
21
|
-
"bottom-right": `position: fixed; bottom: ${PLACEMENT_INSET_PX}px; right: ${PLACEMENT_INSET_PX}px;`,
|
|
22
|
-
"bottom-left": `position: fixed; bottom: ${PLACEMENT_INSET_PX}px; left: ${PLACEMENT_INSET_PX}px;`,
|
|
23
|
-
"top-right": `position: fixed; top: ${PLACEMENT_INSET_PX}px; right: ${PLACEMENT_INSET_PX}px;`,
|
|
24
|
-
"top-left": `position: fixed; top: ${PLACEMENT_INSET_PX}px; left: ${PLACEMENT_INSET_PX}px;`,
|
|
25
|
-
inline: "position: relative;",
|
|
26
|
-
};
|
|
27
|
-
/** The open panel's preferred size. Capped by `PANEL_MAX_*` — see below. */
|
|
28
|
-
const PANEL_W_PX = 400;
|
|
29
|
-
const PANEL_H_PX = 600;
|
|
30
|
-
/**
|
|
31
|
-
* A ceiling on the open panel, so it can never be clipped by the viewport.
|
|
32
|
-
*
|
|
33
|
-
* The panel used to be a flat 400x600. The widget is `position: fixed`
|
|
34
|
-
* anchored `PLACEMENT_INSET_PX` from one edge, so on any viewport narrower
|
|
35
|
-
* than 448px — or shorter than 648px — the panel ran past the opposite edge
|
|
36
|
-
* and was silently cut off, with no scrollbar to reveal it, because fixed
|
|
37
|
-
* elements do not contribute to scrollable overflow. That is most phones: at
|
|
38
|
-
* 393px (iPhone 15 Pro) 30px of the panel sat off-screen, at 360px 63px did,
|
|
39
|
-
* and in landscape 233px of it — the entire header, including the close
|
|
40
|
-
* button — sat above the viewport, leaving nothing on screen to dismiss it.
|
|
41
|
-
*
|
|
42
|
-
* Expressed as `max-width`/`max-height` rather than folding the cap into the
|
|
43
|
-
* width with `min()`. Both compute the same thing, but they fail differently:
|
|
44
|
-
* a parser that does not know `min()` drops the whole declaration and the
|
|
45
|
-
* panel is left with no width at all, which is worse than the bug. An unknown
|
|
46
|
-
* `max-width` drops only the ceiling and leaves the previous behaviour intact.
|
|
47
|
-
* (Not hypothetical — happy-dom, which this package's own tests run in, parses
|
|
48
|
-
* the `max-width` form and discards the `min()` one.)
|
|
49
|
-
*
|
|
50
|
-
* Dynamic viewport units so mobile browser chrome is accounted for as it shows
|
|
51
|
-
* and hides, rather than sizing against a viewport the user cannot fully see.
|
|
52
|
-
* Applied to the container permanently rather than per state: the collapsed
|
|
53
|
-
* bubble is far below the ceiling, so it is unaffected.
|
|
54
|
-
*/
|
|
55
|
-
const PANEL_MAX_W = `calc(100dvw - ${PLACEMENT_INSET_PX * 2}px)`;
|
|
56
|
-
const PANEL_MAX_H = `calc(100dvh - ${PLACEMENT_INSET_PX * 2}px)`;
|
|
57
|
-
/** `convai-error{code:'mic-permission'}` per the embed's error-handling contract. */
|
|
58
|
-
const MIC_PERMISSION_ERROR_CODE = "mic-permission";
|
|
59
|
-
/**
|
|
60
|
-
* Distinguishes a microphone-permission denial from any other audio failure.
|
|
61
|
-
*
|
|
62
|
-
* Traced (not assumed): `client.audioControls.unmuteAudio()` delegates to
|
|
63
|
-
* `AudioManager.enableAudio()`, which calls
|
|
64
|
-
* `room.localParticipant.setMicrophoneEnabled(true)`. That's LiveKit's
|
|
65
|
-
* `setTrackEnabled()`, which on failure does `throw e` unchanged, from a
|
|
66
|
-
* `catch` around `createLocalTracks()`, which itself does `throw e` unchanged
|
|
67
|
-
* around `navigator.mediaDevices.getUserMedia()`. `AudioManager.enableAudio()`
|
|
68
|
-
* also just does `throw error` in its own catch. Nothing on that path wraps or
|
|
69
|
-
* replaces the error, so a user's permission denial reaches here as the exact
|
|
70
|
-
* `DOMException` `getUserMedia()` produced, with `.name === 'NotAllowedError'`
|
|
71
|
-
* (`'PermissionDeniedError'` is the old pre-spec name some browsers used).
|
|
72
|
-
*/
|
|
73
|
-
function isMicPermissionDenial(error) {
|
|
74
|
-
const name = error?.name;
|
|
75
|
-
return name === "NotAllowedError" || name === "PermissionDeniedError";
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Wraps a mic-permission denial so a consumer of the client's `error`
|
|
79
|
-
* channel can tell it apart from a generic failure without knowing anything
|
|
80
|
-
* about `DOMException` internals -- it only has to check `error.code`. The
|
|
81
|
-
* original error is preserved on `.cause` for anyone who wants the detail.
|
|
82
|
-
*/
|
|
83
|
-
function toMicPermissionError(cause) {
|
|
84
|
-
const err = new Error("Microphone access was denied. Continuing in text-only mode.");
|
|
85
|
-
err.name = "ConvaiMicPermissionError";
|
|
86
|
-
err.code = MIC_PERMISSION_ERROR_CODE;
|
|
87
|
-
err.cause = cause;
|
|
88
|
-
return err;
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Routes a failed `enableAudio()`/`unmuteAudio()` call onto the client's
|
|
92
|
-
* *existing* `error` channel -- the same one `ConvaiClient` already uses --
|
|
93
|
-
* instead of letting it become an unhandled rejection (the click-handler
|
|
94
|
-
* site) or vanish into a bare `console.error` (the auto-voice-mode sites).
|
|
95
|
-
* `console.error` is kept alongside for local debugging; this just adds the
|
|
96
|
-
* event emission next to it. `client.emit` is called defensively since it
|
|
97
|
-
* isn't part of the public `IConvaiClient` surface -- see the `emit?` field
|
|
98
|
-
* on `VanillaWidgetOptions.convaiClient` in `./types.ts`.
|
|
99
|
-
*/
|
|
100
|
-
function reportMicFailure(client, logLabel, error) {
|
|
101
|
-
console.error(logLabel, error);
|
|
102
|
-
const payload = isMicPermissionDenial(error)
|
|
103
|
-
? toMicPermissionError(error)
|
|
104
|
-
: error;
|
|
105
|
-
client.emit?.("error", payload);
|
|
106
|
-
}
|
|
107
11
|
/**
|
|
108
12
|
* Create a Convai chat widget in the specified container
|
|
109
13
|
*
|
|
@@ -140,21 +44,6 @@ function reportMicFailure(client, logLabel, error) {
|
|
|
140
44
|
* ```
|
|
141
45
|
*/
|
|
142
46
|
export function createConvaiWidget(container, options) {
|
|
143
|
-
/**
|
|
144
|
-
* Where global styles and `@keyframes` are injected.
|
|
145
|
-
*
|
|
146
|
-
* An explicit `options.styleTarget` wins and is used verbatim. Otherwise we
|
|
147
|
-
* fall back to deriving it from the container's current root — which is what
|
|
148
|
-
* every existing caller relies on, but only works when the container is
|
|
149
|
-
* already attached to its final root. A caller that builds a detached
|
|
150
|
-
* subtree and appends it to a shadow root afterwards must pass
|
|
151
|
-
* `styleTarget` explicitly, or the styles land in `document.head` and the
|
|
152
|
-
* shadow tree renders unstyled with no error.
|
|
153
|
-
*/
|
|
154
|
-
const styleTarget = options.styleTarget ??
|
|
155
|
-
(container.getRootNode() instanceof ShadowRoot
|
|
156
|
-
? container.getRootNode()
|
|
157
|
-
: document);
|
|
158
47
|
// Create client if not provided - guarantee it's defined
|
|
159
48
|
const client = options.convaiClient ||
|
|
160
49
|
(() => {
|
|
@@ -174,14 +63,8 @@ export function createConvaiWidget(container, options) {
|
|
|
174
63
|
});
|
|
175
64
|
})();
|
|
176
65
|
const { showVideo = true, showScreenShare = true, defaultVoiceMode = true, requestPublicationGrant, publishedChatConnectOptions, publishedChatDescriptor, onConnect, onDisconnect, onMessage, } = options;
|
|
177
|
-
// Unknown/typo'd values fall back to the default rather than throwing --
|
|
178
|
-
// a bad `placement` attribute on a customer's page must not blank the
|
|
179
|
-
// widget.
|
|
180
|
-
const placement = options.placement && options.placement in PLACEMENT_CSS
|
|
181
|
-
? options.placement
|
|
182
|
-
: "bottom-right";
|
|
183
66
|
// Inject global styles
|
|
184
|
-
injectGlobalStyles(
|
|
67
|
+
injectGlobalStyles();
|
|
185
68
|
// State
|
|
186
69
|
let isOpen = false;
|
|
187
70
|
let isSettingsOpen = false;
|
|
@@ -197,22 +80,8 @@ export function createConvaiWidget(container, options) {
|
|
|
197
80
|
let hasEnteredDefaultVoiceMode = false;
|
|
198
81
|
/** True after we have connected at least once; used to call reconnect() instead of connect() when opening after disconnect */
|
|
199
82
|
let hasConnectedBefore = false;
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
* until that open either completes or is cancelled.
|
|
203
|
-
*
|
|
204
|
-
* `isOpen` alone cannot express this: the first open awaits a real
|
|
205
|
-
* `connect()` (0.5-3s in production) and only flips `isOpen` afterwards, so
|
|
206
|
-
* for that whole window the widget is neither open nor idle. Without this
|
|
207
|
-
* flag `close()` sees `isOpen === false` and no-ops -- and the connect then
|
|
208
|
-
* pops the panel open behind the user -- while `toggle()` sees the same
|
|
209
|
-
* `false`, starts a *second* open, and lands on the already-connecting
|
|
210
|
-
* branch that opens immediately. Every entry point below treats
|
|
211
|
-
* `pendingOpen` as "open" for the purpose of deciding what a close/toggle
|
|
212
|
-
* means, and `handleToggle` re-checks it before committing `setIsOpen(true)`
|
|
213
|
-
* so a cancellation during the await is honoured.
|
|
214
|
-
*/
|
|
215
|
-
let pendingOpen = false;
|
|
83
|
+
/** Prevent repeated open clicks from minting multiple one-time publication grants. */
|
|
84
|
+
let isStartingConnection = false;
|
|
216
85
|
/** Prevent an in-flight publication grant request from connecting after teardown. */
|
|
217
86
|
let isDestroyed = false;
|
|
218
87
|
const eventSubscriptions = new WidgetEventSubscriptions(() => isDestroyed);
|
|
@@ -229,13 +98,6 @@ export function createConvaiWidget(container, options) {
|
|
|
229
98
|
let settingsTray;
|
|
230
99
|
let floatingVideo;
|
|
231
100
|
let voiceModeOverlay;
|
|
232
|
-
let connectingOverlay;
|
|
233
|
-
/**
|
|
234
|
-
* Elements captured at creation. IDs are scoped per DOM tree, so
|
|
235
|
-
* document.getElementById() returns null when the widget is mounted
|
|
236
|
-
* inside a shadow root. Every element here is created in this file.
|
|
237
|
-
*/
|
|
238
|
-
const refs = {};
|
|
239
101
|
// Audio Analysis State
|
|
240
102
|
let audioContext = null;
|
|
241
103
|
let analyzer = null;
|
|
@@ -246,31 +108,15 @@ export function createConvaiWidget(container, options) {
|
|
|
246
108
|
const fetchCharacterInfo = async () => {
|
|
247
109
|
if (publishedChatDescriptor)
|
|
248
110
|
return;
|
|
249
|
-
|
|
250
|
-
// uses when building /connect headers -- a client carrying both must
|
|
251
|
-
// behave identically in both places.
|
|
252
|
-
//
|
|
253
|
-
// The two credentials travel in *different* headers: api.convai.com reads
|
|
254
|
-
// an auth token from API-AUTH-TOKEN and an API key from X-API-Key, and
|
|
255
|
-
// answers an API key sent as API-AUTH-TOKEN with `400 Your session has
|
|
256
|
-
// expired.` Because the failure is swallowed below, sending the wrong one
|
|
257
|
-
// showed up only as a header stuck on the fallback name.
|
|
258
|
-
const credential = client.authToken ?? client.apiKey;
|
|
259
|
-
if (!credential || !client.characterId)
|
|
111
|
+
if (!client.apiKey || !client.characterId)
|
|
260
112
|
return;
|
|
261
|
-
const headers = {
|
|
262
|
-
"Content-Type": "application/json",
|
|
263
|
-
};
|
|
264
|
-
if (client.authToken) {
|
|
265
|
-
headers["API-AUTH-TOKEN"] = client.authToken;
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
headers["X-API-Key"] = credential;
|
|
269
|
-
}
|
|
270
113
|
try {
|
|
271
114
|
const response = await fetch("https://api.convai.com/character/get", {
|
|
272
115
|
method: "POST",
|
|
273
|
-
headers
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
"API-AUTH-TOKEN": client.apiKey,
|
|
119
|
+
},
|
|
274
120
|
body: JSON.stringify({ charID: client.characterId }),
|
|
275
121
|
});
|
|
276
122
|
if (response.ok) {
|
|
@@ -294,34 +140,23 @@ export function createConvaiWidget(container, options) {
|
|
|
294
140
|
rootElement = document.createElement("div");
|
|
295
141
|
rootElement.className = "convai-widget";
|
|
296
142
|
rootElement.style.cssText = `
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
143
|
+
position: fixed;
|
|
144
|
+
bottom: 1.5rem;
|
|
145
|
+
right: 1.5rem;
|
|
146
|
+
z-index: ${aeroTheme.zIndex.modal};
|
|
147
|
+
font-family: ${aeroTheme.typography.fontFamily.primary};
|
|
300
148
|
`;
|
|
301
149
|
// Morphing container
|
|
302
150
|
morphingContainer = document.createElement("div");
|
|
303
|
-
// `part="launcher"` is precise only while collapsed: this same element
|
|
304
|
-
// morphs into the open panel (400x600, capped to the viewport — see PANEL_MAX_W)
|
|
305
|
-
// and carries that panel's background,
|
|
306
|
-
// radius and shadow. See the `part="panel"` call site below for the full
|
|
307
|
-
// semantics a host needs to know.
|
|
308
|
-
morphingContainer.setAttribute("part", "launcher");
|
|
309
|
-
// NOTE on border-radius: deliberately NOT themable. `--convai-radius`
|
|
310
|
-
// drives the open panel only. One token cannot serve both geometries --
|
|
311
|
-
// `--convai-radius: 8px` would squircle the circular launcher, and `50%`
|
|
312
|
-
// would turn the panel into an ellipse. A dedicated
|
|
313
|
-
// `--convai-launcher-radius` can be added additively later if wanted.
|
|
314
151
|
morphingContainer.style.cssText = `
|
|
315
152
|
position: relative;
|
|
316
|
-
width:
|
|
317
|
-
height:
|
|
318
|
-
|
|
319
|
-
max-height: ${PANEL_MAX_H};
|
|
320
|
-
background: var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop});
|
|
153
|
+
width: 4rem;
|
|
154
|
+
height: 4rem;
|
|
155
|
+
background: ${aeroTheme.colors.glass.backdrop};
|
|
321
156
|
backdrop-filter: ${aeroTheme.glass.backdrop};
|
|
322
157
|
border: ${aeroTheme.glass.border};
|
|
323
158
|
border-radius: 50%;
|
|
324
|
-
box-shadow:
|
|
159
|
+
box-shadow: ${aeroTheme.shadows.glass};
|
|
325
160
|
transition: all 0.3s ease-in-out;
|
|
326
161
|
overflow: hidden;
|
|
327
162
|
display: flex;
|
|
@@ -342,39 +177,10 @@ export function createConvaiWidget(container, options) {
|
|
|
342
177
|
transform: scale(1);
|
|
343
178
|
`;
|
|
344
179
|
const convaiLogo = Icons.ConvaiLogo("xl", "idle");
|
|
345
|
-
convaiLogo.style.color =
|
|
180
|
+
convaiLogo.style.color = aeroTheme.colors.convai.light;
|
|
346
181
|
buttonContent.appendChild(convaiLogo);
|
|
347
182
|
// Chat content
|
|
348
183
|
chatContent = document.createElement("div");
|
|
349
|
-
// ----------------------------------------------------------------------
|
|
350
|
-
// `launcher` / `panel` part semantics. Read before writing theming docs.
|
|
351
|
-
//
|
|
352
|
-
// This widget morphs rather than swapping trees: `morphingContainer` IS
|
|
353
|
-
// the 64px circle AND the open panel, animating between them (see
|
|
354
|
-
// setIsOpen). It therefore owns every painted surface property of both
|
|
355
|
-
// states -- background, border, border-radius, box-shadow. `buttonContent`
|
|
356
|
-
// and `chatContent` are transparent `inset: 0` overlays that only
|
|
357
|
-
// cross-fade their contents.
|
|
358
|
-
//
|
|
359
|
-
// So, precisely:
|
|
360
|
-
// ::part(launcher) -> morphingContainer. Paints the collapsed circle
|
|
361
|
-
// AND the open panel; there is no rule that hits
|
|
362
|
-
// only one. This is the surface part.
|
|
363
|
-
// ::part(panel) -> chatContent. The open state's *content layer*:
|
|
364
|
-
// useful for padding, colour, font, opacity. It has
|
|
365
|
-
// no background, no border and no radius of its
|
|
366
|
-
// own, so `::part(panel){border-radius}` does
|
|
367
|
-
// nothing -- set radius via ::part(launcher) or
|
|
368
|
-
// `--convai-radius` (which is panel-scoped).
|
|
369
|
-
//
|
|
370
|
-
// Assessed and deliberately not "fixed": moving `launcher` onto
|
|
371
|
-
// buttonContent would make `::part(launcher){background}` state-specific
|
|
372
|
-
// but would silently break `border-radius` and `box-shadow` on it, since
|
|
373
|
-
// buttonContent is clipped by the container's own radius + overflow:
|
|
374
|
-
// hidden. Correcting this properly needs a separate launcher element
|
|
375
|
-
// instead of a morph -- a rendering change, out of scope here.
|
|
376
|
-
// ----------------------------------------------------------------------
|
|
377
|
-
chatContent.setAttribute("part", "panel");
|
|
378
184
|
chatContent.style.cssText = `
|
|
379
185
|
position: absolute;
|
|
380
186
|
inset: 0;
|
|
@@ -395,19 +201,11 @@ export function createConvaiWidget(container, options) {
|
|
|
395
201
|
contentElement.style.cssText = `
|
|
396
202
|
flex: 1;
|
|
397
203
|
overflow-y: auto;
|
|
398
|
-
padding:
|
|
204
|
+
padding: 1rem;
|
|
399
205
|
background: transparent;
|
|
400
|
-
position: relative;
|
|
401
206
|
`;
|
|
402
207
|
messageListElement = createMessageList();
|
|
403
208
|
contentElement.appendChild(messageListElement);
|
|
404
|
-
// Connecting overlay -- shown until the bot is ready. Appended last, not
|
|
405
|
-
// to match DOM order (position: absolute + z-index: 100, set inside
|
|
406
|
-
// createConnectingOverlay, is what keeps it on top regardless of source
|
|
407
|
-
// order) but so inserting it doesn't shift every existing sibling's
|
|
408
|
-
// index-based path in the inline-style-parity fixture.
|
|
409
|
-
connectingOverlay = createConnectingOverlay();
|
|
410
|
-
contentElement.appendChild(connectingOverlay);
|
|
411
209
|
// Footer
|
|
412
210
|
footerElement = createFooter();
|
|
413
211
|
chatContent.appendChild(headerElement);
|
|
@@ -424,31 +222,9 @@ export function createConvaiWidget(container, options) {
|
|
|
424
222
|
container.appendChild(floatingVideo);
|
|
425
223
|
container.appendChild(rootElement);
|
|
426
224
|
// Event listeners
|
|
427
|
-
morphingContainer.addEventListener("click",
|
|
225
|
+
morphingContainer.addEventListener("click", handleToggle);
|
|
428
226
|
};
|
|
429
227
|
// Create Voice Mode Overlay
|
|
430
|
-
/**
|
|
431
|
-
* Voice mode, matched to the React overlay (VoiceModeOverlay.tsx) rather
|
|
432
|
-
* than to what this file used to draw.
|
|
433
|
-
*
|
|
434
|
-
* The two had diverged badly: React centres the character's avatar inside
|
|
435
|
-
* two counter-rotating liquid-gradient layers and paints the user's
|
|
436
|
-
* microphone as three overlapping sine waves on a canvas, while this file
|
|
437
|
-
* drew forty grey vertical bars and no avatar at all. Same product, two
|
|
438
|
-
* unrelated screens.
|
|
439
|
-
*
|
|
440
|
-
* What is reproduced here, value for value: the 120px avatar with its 3px
|
|
441
|
-
* rgba(255,255,255,0.2) ring and letter fallback, the 126px conic and
|
|
442
|
-
* radial gradient layers (20s clockwise / 15s anticlockwise, blurred 8px
|
|
443
|
-
* and 6px), the 250x80 canvas carrying three waves, and the status text at
|
|
444
|
-
* 16px/600 over 13px with React's wording.
|
|
445
|
-
*
|
|
446
|
-
* The one deliberate omission is the `AudioVisualizer` ring that React
|
|
447
|
-
* fades in behind the avatar while the character speaks: it is a 364-line
|
|
448
|
-
* component wired directly to a LiveKit `Room`, and porting it is its own
|
|
449
|
-
* change. The avatar still breathes while the character speaks, so the
|
|
450
|
-
* speaking state reads without it.
|
|
451
|
-
*/
|
|
452
228
|
const createVoiceModeOverlay = () => {
|
|
453
229
|
const overlay = document.createElement("div");
|
|
454
230
|
overlay.style.cssText = `
|
|
@@ -457,383 +233,171 @@ export function createConvaiWidget(container, options) {
|
|
|
457
233
|
left: 50%;
|
|
458
234
|
transform: translate(-50%, -50%);
|
|
459
235
|
text-align: center;
|
|
460
|
-
padding:
|
|
236
|
+
padding: 1rem;
|
|
461
237
|
z-index: 10;
|
|
462
238
|
pointer-events: auto;
|
|
463
239
|
display: none;
|
|
464
240
|
flex-direction: column;
|
|
465
241
|
align-items: center;
|
|
466
|
-
gap:
|
|
242
|
+
gap: 1.5rem;
|
|
467
243
|
`;
|
|
468
|
-
//
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
244
|
+
// Bars Container
|
|
245
|
+
const barsContainer = document.createElement("div");
|
|
246
|
+
barsContainer.id = "voice-bars-container";
|
|
247
|
+
barsContainer.style.cssText = `
|
|
472
248
|
display: flex;
|
|
473
249
|
align-items: center;
|
|
474
250
|
justify-content: center;
|
|
475
|
-
|
|
251
|
+
gap: 2px;
|
|
252
|
+
height: 80px;
|
|
253
|
+
max-width: 300px;
|
|
476
254
|
`;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
layer1.style.cssText = `
|
|
494
|
-
position: absolute;
|
|
495
|
-
width: 126px;
|
|
496
|
-
height: 126px;
|
|
497
|
-
border-radius: 50%;
|
|
498
|
-
background: conic-gradient(
|
|
499
|
-
from 0deg,
|
|
500
|
-
${aeroTheme.colors.convai.light}00 0%,
|
|
501
|
-
${aeroTheme.colors.convai.light}40 25%,
|
|
502
|
-
${aeroTheme.colors.convai.dark}60 50%,
|
|
503
|
-
${aeroTheme.colors.convai.light}40 75%,
|
|
504
|
-
${aeroTheme.colors.convai.light}00 100%
|
|
505
|
-
);
|
|
506
|
-
opacity: 0.8;
|
|
507
|
-
filter: blur(8px);
|
|
508
|
-
z-index: -2;
|
|
509
|
-
animation: convaiVoiceSpin 20s linear infinite;
|
|
510
|
-
`;
|
|
511
|
-
const layer2 = document.createElement("div");
|
|
512
|
-
layer2.className = "convai-voice-gradient";
|
|
513
|
-
layer2.style.cssText = `
|
|
514
|
-
position: absolute;
|
|
515
|
-
width: 126px;
|
|
516
|
-
height: 126px;
|
|
517
|
-
border-radius: 50%;
|
|
518
|
-
background:
|
|
519
|
-
radial-gradient(ellipse 80% 100% at 30% 40%,
|
|
520
|
-
${aeroTheme.colors.convai.light}50,
|
|
521
|
-
${aeroTheme.colors.convai.light}30 40%,
|
|
522
|
-
transparent 70%),
|
|
523
|
-
radial-gradient(ellipse 80% 100% at 70% 60%,
|
|
524
|
-
${aeroTheme.colors.convai.dark}50,
|
|
525
|
-
${aeroTheme.colors.convai.dark}30 40%,
|
|
526
|
-
transparent 70%);
|
|
527
|
-
opacity: 0.9;
|
|
528
|
-
filter: blur(6px);
|
|
529
|
-
z-index: -1;
|
|
530
|
-
animation: convaiVoiceSpinReverse 15s linear infinite;
|
|
531
|
-
`;
|
|
532
|
-
// The image is clipped by its own container so the gradients above stay
|
|
533
|
-
// visible outside it — React nests it the same way for the same reason.
|
|
534
|
-
const avatarClip = document.createElement("div");
|
|
535
|
-
avatarClip.style.cssText = `
|
|
536
|
-
position: relative;
|
|
537
|
-
border-radius: 50%;
|
|
538
|
-
overflow: hidden;
|
|
539
|
-
border: 3px solid rgba(255, 255, 255, 0.2);
|
|
540
|
-
width: 120px;
|
|
541
|
-
height: 120px;
|
|
542
|
-
display: flex;
|
|
543
|
-
align-items: center;
|
|
544
|
-
justify-content: center;
|
|
545
|
-
`;
|
|
546
|
-
refs.voiceModeAvatarClip = avatarClip;
|
|
547
|
-
avatarStack.append(layer1, layer2, avatarClip);
|
|
548
|
-
avatarWrap.appendChild(avatarStack);
|
|
549
|
-
overlay.appendChild(avatarWrap);
|
|
550
|
-
// --- The user's microphone, as three waves on a canvas ----------------
|
|
551
|
-
const canvas = document.createElement("canvas");
|
|
552
|
-
canvas.className = "convai-voice-wave";
|
|
553
|
-
canvas.style.cssText = `width: 250px; height: 80px;`;
|
|
554
|
-
refs.voiceModeCanvas = canvas;
|
|
555
|
-
overlay.appendChild(canvas);
|
|
556
|
-
// --- Status text ------------------------------------------------------
|
|
255
|
+
// Create 40 bars
|
|
256
|
+
for (let i = 0; i < 40; i++) {
|
|
257
|
+
const bar = document.createElement("div");
|
|
258
|
+
bar.className = "voice-bar";
|
|
259
|
+
bar.style.cssText = `
|
|
260
|
+
width: 3px;
|
|
261
|
+
height: 15px;
|
|
262
|
+
background-color: ${aeroTheme.colors.neutral[400]};
|
|
263
|
+
border-radius: 1.5px;
|
|
264
|
+
transition: height 0.08s ease-out, background-color 0.2s;
|
|
265
|
+
transform-origin: center;
|
|
266
|
+
`;
|
|
267
|
+
barsContainer.appendChild(bar);
|
|
268
|
+
}
|
|
269
|
+
overlay.appendChild(barsContainer);
|
|
270
|
+
// Status Text
|
|
557
271
|
const statusContainer = document.createElement("div");
|
|
558
272
|
const statusTitle = document.createElement("div");
|
|
559
273
|
statusTitle.id = "voice-mode-title";
|
|
560
|
-
refs.voiceModeTitle = statusTitle;
|
|
561
274
|
statusTitle.style.cssText = `
|
|
562
|
-
font-size:
|
|
563
|
-
font-weight:
|
|
564
|
-
color:
|
|
565
|
-
margin-bottom:
|
|
275
|
+
font-size: 14px;
|
|
276
|
+
font-weight: 500;
|
|
277
|
+
color: ${aeroTheme.colors.text.primary};
|
|
278
|
+
margin-bottom: 0.5rem;
|
|
566
279
|
`;
|
|
567
|
-
statusTitle.textContent = "Voice Mode";
|
|
280
|
+
statusTitle.textContent = "Voice Only Mode";
|
|
568
281
|
const statusSubtitle = document.createElement("div");
|
|
569
282
|
statusSubtitle.id = "voice-mode-subtitle";
|
|
570
|
-
refs.voiceModeSubtitle = statusSubtitle;
|
|
571
283
|
statusSubtitle.style.cssText = `
|
|
572
|
-
font-size:
|
|
284
|
+
font-size: 12px;
|
|
573
285
|
color: ${aeroTheme.colors.text.secondary};
|
|
574
286
|
`;
|
|
575
|
-
statusSubtitle.textContent = "
|
|
576
|
-
statusContainer.
|
|
287
|
+
statusSubtitle.textContent = "Press and hold the microphone to talk";
|
|
288
|
+
statusContainer.appendChild(statusTitle);
|
|
289
|
+
statusContainer.appendChild(statusSubtitle);
|
|
577
290
|
overlay.appendChild(statusContainer);
|
|
578
|
-
void accent;
|
|
579
291
|
return overlay;
|
|
580
292
|
};
|
|
581
|
-
/**
|
|
582
|
-
* Fills the voice-mode avatar from the character info fetch, or falls back
|
|
583
|
-
* to the character's initial the way React does. Called from
|
|
584
|
-
* updateHeader(), so it tracks a late `fetchCharacterInfo()` rather than
|
|
585
|
-
* being fixed at construction.
|
|
586
|
-
*/
|
|
587
|
-
const updateVoiceModeAvatar = () => {
|
|
588
|
-
const clip = refs.voiceModeAvatarClip;
|
|
589
|
-
if (!clip)
|
|
590
|
-
return;
|
|
591
|
-
const wanted = characterImage || `letter:${characterName}`;
|
|
592
|
-
if (clip.dataset.filledWith === wanted)
|
|
593
|
-
return;
|
|
594
|
-
clip.dataset.filledWith = wanted;
|
|
595
|
-
clip.replaceChildren();
|
|
596
|
-
if (characterImage) {
|
|
597
|
-
const img = document.createElement("img");
|
|
598
|
-
img.src = characterImage;
|
|
599
|
-
img.alt = characterName;
|
|
600
|
-
img.style.cssText = `width: 120px; height: 120px; object-fit: cover; display: block;`;
|
|
601
|
-
clip.appendChild(img);
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
const letter = document.createElement("div");
|
|
605
|
-
letter.style.cssText = `
|
|
606
|
-
width: 120px;
|
|
607
|
-
height: 120px;
|
|
608
|
-
background-color: ${aeroTheme.colors.convai.light}20;
|
|
609
|
-
display: flex;
|
|
610
|
-
align-items: center;
|
|
611
|
-
justify-content: center;
|
|
612
|
-
font-size: 48px;
|
|
613
|
-
font-weight: 600;
|
|
614
|
-
color: var(--convai-accent, ${aeroTheme.colors.convai.light});
|
|
615
|
-
`;
|
|
616
|
-
letter.textContent = characterName[0]?.toUpperCase() || "C";
|
|
617
|
-
clip.appendChild(letter);
|
|
618
|
-
};
|
|
619
|
-
// Create Connecting Overlay -- matches React's inline AnimatePresence
|
|
620
|
-
// block exactly (ConvaiWidget.tsx:681-737): shown whenever
|
|
621
|
-
// `isConnected && !isBotReady`, covering just the content area (not the
|
|
622
|
-
// header/footer) so the chrome stays interactive while the character
|
|
623
|
-
// joins. Persistent in the DOM like voiceModeOverlay above -- toggled via
|
|
624
|
-
// opacity/pointer-events rather than mounted/unmounted, since this file has
|
|
625
|
-
// no AnimatePresence-style exit-animation machinery. Visibility is driven
|
|
626
|
-
// by updateConnectingOverlay().
|
|
627
|
-
const createConnectingOverlay = () => {
|
|
628
|
-
const overlay = document.createElement("div");
|
|
629
|
-
overlay.id = "convai-connecting-overlay";
|
|
630
|
-
overlay.style.cssText = `
|
|
631
|
-
position: absolute;
|
|
632
|
-
inset: 0;
|
|
633
|
-
z-index: 100;
|
|
634
|
-
display: flex;
|
|
635
|
-
flex-direction: column;
|
|
636
|
-
align-items: center;
|
|
637
|
-
justify-content: center;
|
|
638
|
-
gap: 16px;
|
|
639
|
-
background: rgba(255, 255, 255, 0.95);
|
|
640
|
-
opacity: 0;
|
|
641
|
-
pointer-events: none;
|
|
642
|
-
transition: opacity 0.3s;
|
|
643
|
-
`;
|
|
644
|
-
// Keyframes live in the shadow-safe injector (styles.ts), not
|
|
645
|
-
// document.head -- @keyframes defined there do not resolve for shadow
|
|
646
|
-
// content. Idempotent; also called from createSettingsTray().
|
|
647
|
-
injectKeyframes(styleTarget);
|
|
648
|
-
// Spinning logo: a full turn every 2s, linear, infinite.
|
|
649
|
-
const spinWrapper = document.createElement("div");
|
|
650
|
-
spinWrapper.className = "convai-connecting-spin";
|
|
651
|
-
spinWrapper.style.cssText = `
|
|
652
|
-
display: inline-flex;
|
|
653
|
-
animation: convaiConnectingSpin 2s linear infinite;
|
|
654
|
-
`;
|
|
655
|
-
const logo = Icons.ConvaiLogo("xl", "connecting");
|
|
656
|
-
// Themable, like every other accent usage in this file (see
|
|
657
|
-
// theming.test.ts) -- var() with the exact literal React passes
|
|
658
|
-
// (`aeroTheme.colors.convai.light`) as the fallback, so an unset
|
|
659
|
-
// --convai-accent renders identically to the React value.
|
|
660
|
-
logo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
|
|
661
|
-
spinWrapper.appendChild(logo);
|
|
662
|
-
overlay.appendChild(spinWrapper);
|
|
663
|
-
// Pulsing "Connecting to {characterName}..." label.
|
|
664
|
-
const label = document.createElement("div");
|
|
665
|
-
label.id = "convai-connecting-text";
|
|
666
|
-
label.className = "convai-connecting-pulse";
|
|
667
|
-
label.style.cssText = `
|
|
668
|
-
font-size: 14px;
|
|
669
|
-
font-weight: 500;
|
|
670
|
-
color: ${aeroTheme.colors.text.secondary};
|
|
671
|
-
animation: convaiConnectingPulse 2s ease-in-out infinite;
|
|
672
|
-
`;
|
|
673
|
-
refs.connectingLabel = label;
|
|
674
|
-
overlay.appendChild(label);
|
|
675
|
-
return overlay;
|
|
676
|
-
};
|
|
677
|
-
/** Shows/hides the connecting overlay and refreshes its label. Called
|
|
678
|
-
* from updateHeader() so every existing call site (stateChange, botReady,
|
|
679
|
-
* connect/disconnect via stateChange, character-info fetch, ...) stays in
|
|
680
|
-
* sync automatically. */
|
|
681
|
-
const updateConnectingOverlay = () => {
|
|
682
|
-
if (!connectingOverlay)
|
|
683
|
-
return;
|
|
684
|
-
const label = refs.connectingLabel;
|
|
685
|
-
if (label) {
|
|
686
|
-
label.textContent = `Connecting to ${characterName}...`;
|
|
687
|
-
}
|
|
688
|
-
// `isConnecting` matters as much as `isConnected` here. The panel opens
|
|
689
|
-
// on the click that *starts* the connect, so between that click and the
|
|
690
|
-
// transport coming up (a real network round trip — ~3s against the live
|
|
691
|
-
// service) `isConnected` is still false. Gating on it alone left the
|
|
692
|
-
// whole of that window with no overlay at all: an empty message area and
|
|
693
|
-
// a nameless header, i.e. the widget looked broken for exactly as long as
|
|
694
|
-
// it was doing the one thing the user asked for. Then the label appeared
|
|
695
|
-
// *after* the transport was already up, which reads backwards.
|
|
696
|
-
//
|
|
697
|
-
// The embed makes this the normal path rather than an edge case: its
|
|
698
|
-
// launcher click opens the panel immediately by design, so the pre-connect
|
|
699
|
-
// window is always visible to the user.
|
|
700
|
-
const shouldShow = (client.state.isConnected || client.state.isConnecting) && !client.isBotReady;
|
|
701
|
-
connectingOverlay.style.opacity = shouldShow ? "1" : "0";
|
|
702
|
-
connectingOverlay.style.pointerEvents = shouldShow ? "auto" : "none";
|
|
703
|
-
};
|
|
704
293
|
// Audio Analysis State for Voice Mode
|
|
705
294
|
let audioLevels = Array(40).fill(0);
|
|
295
|
+
let targetLevels = Array(40).fill(0.05);
|
|
296
|
+
let currentLevels = Array(40).fill(0.05);
|
|
706
297
|
let startTime = 0;
|
|
707
|
-
|
|
708
|
-
* Paints the three overlapping sine waves React draws for the user's
|
|
709
|
-
* microphone (VoiceModeOverlay.tsx's `drawWaves`), including its two
|
|
710
|
-
* performance guards, which matter more here than there: this canvas sits
|
|
711
|
-
* over whatever the host page is rendering, and every repaint forces the
|
|
712
|
-
* compositor to re-blend the whole panel. So it runs at 30fps rather than
|
|
713
|
-
* 60, and stops entirely while the input is silent — the static line is
|
|
714
|
-
* already on the canvas at that point.
|
|
715
|
-
*/
|
|
716
|
-
let waveFrameParity = 0;
|
|
717
|
-
let waveWasSilent = false;
|
|
718
|
-
const drawWaves = () => {
|
|
719
|
-
const canvas = refs.voiceModeCanvas;
|
|
720
|
-
if (!canvas)
|
|
721
|
-
return;
|
|
722
|
-
waveFrameParity ^= 1;
|
|
723
|
-
if (waveFrameParity)
|
|
724
|
-
return;
|
|
725
|
-
let sum = 0;
|
|
726
|
-
for (let i = 0; i < audioLevels.length; i++)
|
|
727
|
-
sum += audioLevels[i];
|
|
728
|
-
const silent = sum / audioLevels.length < 0.012;
|
|
729
|
-
if (silent && waveWasSilent)
|
|
730
|
-
return;
|
|
731
|
-
waveWasSilent = silent;
|
|
732
|
-
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
733
|
-
if (canvas.width !== 250 * dpr) {
|
|
734
|
-
canvas.width = 250 * dpr;
|
|
735
|
-
canvas.height = 80 * dpr;
|
|
736
|
-
}
|
|
737
|
-
const ctx = canvas.getContext("2d");
|
|
738
|
-
if (!ctx)
|
|
739
|
-
return;
|
|
740
|
-
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
741
|
-
ctx.clearRect(0, 0, 250, 80);
|
|
742
|
-
const centerY = 40;
|
|
743
|
-
const waveWidth = 250;
|
|
744
|
-
const segments = 40;
|
|
745
|
-
const avgLevel = sum / audioLevels.length;
|
|
746
|
-
const waveColors = [
|
|
747
|
-
aeroTheme.colors.convai.light,
|
|
748
|
-
aeroTheme.colors.convai.light,
|
|
749
|
-
"#34d399",
|
|
750
|
-
];
|
|
751
|
-
ctx.lineCap = "round";
|
|
752
|
-
ctx.lineJoin = "round";
|
|
753
|
-
for (let waveIndex = 2; waveIndex >= 0; waveIndex--) {
|
|
754
|
-
ctx.beginPath();
|
|
755
|
-
for (let i = 0; i <= segments; i++) {
|
|
756
|
-
const x = (i / segments) * waveWidth;
|
|
757
|
-
const levelIndex = Math.floor((i / segments) * (audioLevels.length - 1));
|
|
758
|
-
const level = audioLevels[levelIndex] || 0;
|
|
759
|
-
const phase = (Date.now() / 500 + i * 0.2 + waveIndex * 1.5) % (Math.PI * 2);
|
|
760
|
-
const baseAmplitude = avgLevel > 0.05 ? 2 + waveIndex * 1.5 : 0;
|
|
761
|
-
const amplitude = baseAmplitude + level * (25 - baseAmplitude);
|
|
762
|
-
const y = centerY + Math.sin(phase) * amplitude;
|
|
763
|
-
if (i === 0)
|
|
764
|
-
ctx.moveTo(x, y);
|
|
765
|
-
else
|
|
766
|
-
ctx.lineTo(x, y);
|
|
767
|
-
}
|
|
768
|
-
ctx.strokeStyle = waveColors[waveIndex];
|
|
769
|
-
ctx.globalAlpha = 0.18 * (0.3 + avgLevel);
|
|
770
|
-
ctx.lineWidth = 2.5 - waveIndex * 0.4 + 8 * avgLevel + 3;
|
|
771
|
-
ctx.stroke();
|
|
772
|
-
ctx.globalAlpha = 0.95 - waveIndex * 0.15;
|
|
773
|
-
ctx.lineWidth = 2.5 - waveIndex * 0.4;
|
|
774
|
-
ctx.stroke();
|
|
775
|
-
}
|
|
776
|
-
ctx.globalAlpha = 1;
|
|
777
|
-
};
|
|
778
|
-
/**
|
|
779
|
-
* The voice-mode frame loop. Keeps the name `updateAudioBars` because
|
|
780
|
-
* every call site refers to it; what it drives is now the avatar, the
|
|
781
|
-
* canvas and the status text rather than forty bars.
|
|
782
|
-
*/
|
|
298
|
+
// Audio Analysis Logic
|
|
783
299
|
const updateAudioBars = () => {
|
|
784
300
|
if (!voiceModeOverlay)
|
|
785
301
|
return;
|
|
302
|
+
const bars = voiceModeOverlay.querySelectorAll(".voice-bar");
|
|
786
303
|
const isTalking = client.state.isSpeaking;
|
|
787
|
-
const isListening = client.state.isListening;
|
|
788
|
-
const
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
797
|
-
//
|
|
798
|
-
|
|
799
|
-
const
|
|
800
|
-
const subtitle = refs.voiceModeSubtitle;
|
|
304
|
+
const isListening = client.state.isListening; // User is speaking (from user-started-speaking)
|
|
305
|
+
const isAnimating = isListening || isTalking;
|
|
306
|
+
// Update colors based on state
|
|
307
|
+
bars.forEach((bar) => {
|
|
308
|
+
bar.style.backgroundColor = isTalking
|
|
309
|
+
? aeroTheme.colors.convai.light
|
|
310
|
+
: isListening
|
|
311
|
+
? aeroTheme.colors.text.primary
|
|
312
|
+
: aeroTheme.colors.neutral[400];
|
|
313
|
+
});
|
|
314
|
+
// Update Text
|
|
315
|
+
const title = document.getElementById("voice-mode-title");
|
|
316
|
+
const subtitle = document.getElementById("voice-mode-subtitle");
|
|
801
317
|
if (title) {
|
|
802
318
|
title.textContent = isTalking
|
|
803
|
-
?
|
|
804
|
-
: isListening
|
|
319
|
+
? "Character Speaking..."
|
|
320
|
+
: isListening
|
|
805
321
|
? "Listening..."
|
|
806
|
-
: "Voice Mode";
|
|
322
|
+
: "Voice Only Mode";
|
|
807
323
|
}
|
|
808
324
|
if (subtitle) {
|
|
809
325
|
subtitle.textContent =
|
|
810
|
-
isListening ||
|
|
326
|
+
isListening || isTalking
|
|
327
|
+
? "Audio active"
|
|
328
|
+
: "Press and hold the microphone to talk";
|
|
811
329
|
}
|
|
812
|
-
|
|
330
|
+
// Animation Logic - Matches React version (animate when user or bot is active)
|
|
331
|
+
if ((isListening || !client.audioControls.isAudioMuted) && analyzer && dataArray) {
|
|
332
|
+
// Use time domain data (waveform) instead of frequency
|
|
813
333
|
// @ts-ignore - TypeScript strict mode issue with Uint8Array type
|
|
814
334
|
analyzer.getByteTimeDomainData(dataArray);
|
|
335
|
+
// Calculate RMS (Root Mean Square) for volume
|
|
815
336
|
let sum = 0;
|
|
816
337
|
for (let i = 0; i < dataArray.length; i++) {
|
|
817
|
-
const normalized = (dataArray[i] - 128) / 128;
|
|
338
|
+
const normalized = (dataArray[i] - 128) / 128; // Center around 0
|
|
818
339
|
sum += normalized * normalized;
|
|
819
340
|
}
|
|
820
341
|
const rms = Math.sqrt(sum / dataArray.length);
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
342
|
+
// Apply some scaling and clamping
|
|
343
|
+
const volume = Math.min(1, rms * 3); // Boost sensitivity
|
|
344
|
+
// Create left-to-right wave effect
|
|
345
|
+
const minHeight = 8;
|
|
346
|
+
const maxHeight = 70;
|
|
347
|
+
bars.forEach((bar, i) => {
|
|
348
|
+
// Progressive wave from left to right
|
|
349
|
+
const position = i / 40; // 0 to 1 from left to right
|
|
824
350
|
const wavePhase = Date.now() / 300 + position * Math.PI * 2;
|
|
825
|
-
const waveVariation = Math.sin(wavePhase) * 0.15 + 0.85;
|
|
826
|
-
|
|
351
|
+
const waveVariation = Math.sin(wavePhase) * 0.15 + 0.85; // 0.7 to 1.0
|
|
352
|
+
const level = volume * waveVariation;
|
|
353
|
+
const height = minHeight + level * (maxHeight - minHeight);
|
|
354
|
+
bar.style.height = `${Math.max(minHeight, height)}px`;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
else if (isTalking) {
|
|
358
|
+
// Simulate speaking bars with natural speech patterns
|
|
359
|
+
const elapsed = (Date.now() - startTime) / 1000; // seconds
|
|
360
|
+
// Generate new random target levels occasionally (simulating syllables/words)
|
|
361
|
+
if (Math.random() < 0.08) {
|
|
362
|
+
// 8% chance per frame = ~5 times per second
|
|
363
|
+
targetLevels = Array(40)
|
|
364
|
+
.fill(0)
|
|
365
|
+
.map((_, i) => {
|
|
366
|
+
// More variation in the middle bars, less on edges for natural spread
|
|
367
|
+
const position = i / 40;
|
|
368
|
+
const centerWeight = 1 - Math.abs(position - 0.5) * 0.5;
|
|
369
|
+
// Random peaks and valleys like speech patterns
|
|
370
|
+
const randomPeak = 0.2 + Math.random() * 0.7; // 0.2 to 0.9
|
|
371
|
+
// Add some neighbor correlation so bars don't jump independently
|
|
372
|
+
const prevTarget = targetLevels[i] || 0.3;
|
|
373
|
+
const correlation = prevTarget * 0.4 + randomPeak * 0.6;
|
|
374
|
+
return correlation * centerWeight;
|
|
375
|
+
});
|
|
827
376
|
}
|
|
377
|
+
// Smoothly interpolate current levels toward targets (organic movement)
|
|
378
|
+
currentLevels = currentLevels.map((current, i) => {
|
|
379
|
+
const target = targetLevels[i];
|
|
380
|
+
const speed = 0.2; // Smooth but responsive
|
|
381
|
+
return current + (target - current) * speed;
|
|
382
|
+
});
|
|
383
|
+
// Apply a gentle fade-in for the first 0.3 seconds
|
|
384
|
+
const fadeIn = Math.min(1, elapsed / 0.3);
|
|
385
|
+
const minHeight = 8;
|
|
386
|
+
const maxHeight = 70;
|
|
387
|
+
bars.forEach((bar, i) => {
|
|
388
|
+
// Small random jitter for micro-variation
|
|
389
|
+
const microJitter = 0.95 + Math.random() * 0.1; // 0.95 to 1.05
|
|
390
|
+
const level = Math.max(0.05, Math.min(1, currentLevels[i] * fadeIn * microJitter));
|
|
391
|
+
const height = minHeight + level * (maxHeight - minHeight);
|
|
392
|
+
bar.style.height = `${Math.max(minHeight, height)}px`;
|
|
393
|
+
});
|
|
828
394
|
}
|
|
829
395
|
else {
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
audioLevels[i] = 0;
|
|
396
|
+
// Reset to idle state
|
|
397
|
+
bars.forEach((bar) => {
|
|
398
|
+
bar.style.height = "15px";
|
|
399
|
+
});
|
|
835
400
|
}
|
|
836
|
-
drawWaves();
|
|
837
401
|
rafId = requestAnimationFrame(updateAudioBars);
|
|
838
402
|
};
|
|
839
403
|
const startAudioAnalysis = async () => {
|
|
@@ -855,15 +419,7 @@ export function createConvaiWidget(container, options) {
|
|
|
855
419
|
updateAudioBars();
|
|
856
420
|
}
|
|
857
421
|
catch (e) {
|
|
858
|
-
|
|
859
|
-
// of client.audioControls (it's a separate analyser-only stream, not
|
|
860
|
-
// routed through AudioManager), but a denial here is the identical
|
|
861
|
-
// failure mode -- most commonly a mid-session permission revocation,
|
|
862
|
-
// since by the time voice mode is entered the mic has usually already
|
|
863
|
-
// been granted via unmuteAudio(). Same helper, same channel, so a
|
|
864
|
-
// consumer sees one consistent signal regardless of which internal
|
|
865
|
-
// path acquired the mic.
|
|
866
|
-
reportMicFailure(client, "Audio analysis setup failed:", e);
|
|
422
|
+
console.error("Audio analysis setup failed:", e);
|
|
867
423
|
}
|
|
868
424
|
};
|
|
869
425
|
const stopAudioAnalysis = () => {
|
|
@@ -880,14 +436,13 @@ export function createConvaiWidget(container, options) {
|
|
|
880
436
|
// Create header
|
|
881
437
|
const createHeader = () => {
|
|
882
438
|
const header = document.createElement("div");
|
|
883
|
-
header.setAttribute("part", "header");
|
|
884
439
|
header.style.cssText = `
|
|
885
440
|
display: flex;
|
|
886
441
|
align-items: center;
|
|
887
442
|
justify-content: space-between;
|
|
888
|
-
padding:
|
|
443
|
+
padding: 1rem;
|
|
889
444
|
border-bottom: 1px solid ${aeroTheme.colors.neutral[200]};
|
|
890
|
-
background:
|
|
445
|
+
background: white;
|
|
891
446
|
position: relative;
|
|
892
447
|
`;
|
|
893
448
|
// Close button on the left
|
|
@@ -895,10 +450,10 @@ export function createConvaiWidget(container, options) {
|
|
|
895
450
|
const chevronIcon = Icons.ChevronDown("md");
|
|
896
451
|
closeButton.appendChild(chevronIcon);
|
|
897
452
|
closeButton.style.cssText = `
|
|
898
|
-
font-size:
|
|
453
|
+
font-size: 1.25rem;
|
|
899
454
|
color: ${aeroTheme.colors.text.secondary};
|
|
900
455
|
cursor: pointer;
|
|
901
|
-
padding:
|
|
456
|
+
padding: 0.25rem;
|
|
902
457
|
transition: ${aeroTheme.transitions.fast};
|
|
903
458
|
background: transparent;
|
|
904
459
|
border: none;
|
|
@@ -915,22 +470,21 @@ export function createConvaiWidget(container, options) {
|
|
|
915
470
|
transform: translateX(-50%);
|
|
916
471
|
display: flex;
|
|
917
472
|
align-items: center;
|
|
918
|
-
gap:
|
|
473
|
+
gap: 0.5rem;
|
|
919
474
|
font-size: ${aeroTheme.typography.fontSize.base};
|
|
920
475
|
font-weight: ${aeroTheme.typography.fontWeight.semibold};
|
|
921
|
-
color:
|
|
476
|
+
color: ${aeroTheme.colors.text.primary};
|
|
922
477
|
`;
|
|
923
478
|
titleSection.id = "convai-widget-title";
|
|
924
|
-
refs.widgetTitle = titleSection;
|
|
925
479
|
// Settings button on the right
|
|
926
480
|
const settingsButton = document.createElement("button");
|
|
927
481
|
const moreIcon = Icons.MoreVertical("md");
|
|
928
482
|
settingsButton.appendChild(moreIcon);
|
|
929
483
|
settingsButton.style.cssText = `
|
|
930
|
-
font-size:
|
|
484
|
+
font-size: 1.5rem;
|
|
931
485
|
color: ${aeroTheme.colors.text.secondary};
|
|
932
486
|
cursor: pointer;
|
|
933
|
-
padding:
|
|
487
|
+
padding: 0.25rem;
|
|
934
488
|
transition: ${aeroTheme.transitions.fast};
|
|
935
489
|
background: transparent;
|
|
936
490
|
border: none;
|
|
@@ -947,7 +501,7 @@ export function createConvaiWidget(container, options) {
|
|
|
947
501
|
};
|
|
948
502
|
// Update header with character info
|
|
949
503
|
const updateHeader = () => {
|
|
950
|
-
const titleSection =
|
|
504
|
+
const titleSection = document.getElementById("convai-widget-title");
|
|
951
505
|
if (!titleSection)
|
|
952
506
|
return;
|
|
953
507
|
titleSection.innerHTML = "";
|
|
@@ -956,8 +510,8 @@ export function createConvaiWidget(container, options) {
|
|
|
956
510
|
img.src = characterImage;
|
|
957
511
|
img.alt = characterName;
|
|
958
512
|
img.style.cssText = `
|
|
959
|
-
width:
|
|
960
|
-
height:
|
|
513
|
+
width: 1.5rem;
|
|
514
|
+
height: 1.5rem;
|
|
961
515
|
border-radius: 50%;
|
|
962
516
|
object-fit: cover;
|
|
963
517
|
border: 1.5px solid ${getBotStatusColor().color};
|
|
@@ -987,7 +541,7 @@ export function createConvaiWidget(container, options) {
|
|
|
987
541
|
padding: 0;
|
|
988
542
|
display: inline-flex;
|
|
989
543
|
align-items: center;
|
|
990
|
-
margin-left:
|
|
544
|
+
margin-left: 0.5rem;
|
|
991
545
|
outline: none;
|
|
992
546
|
transition: transform 0.1s ease-out;
|
|
993
547
|
`;
|
|
@@ -1008,7 +562,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1008
562
|
voiceBadge.textContent = "VOICE";
|
|
1009
563
|
voiceBadge.style.cssText = `
|
|
1010
564
|
font-size: 10px;
|
|
1011
|
-
color:
|
|
565
|
+
color: ${aeroTheme.colors.convai.light};
|
|
1012
566
|
font-weight: 500;
|
|
1013
567
|
margin-left: 8px;
|
|
1014
568
|
padding: 2px 6px;
|
|
@@ -1073,22 +627,15 @@ export function createConvaiWidget(container, options) {
|
|
|
1073
627
|
listeningTag.insertBefore(dot, listeningTag.firstChild);
|
|
1074
628
|
titleSection.appendChild(listeningTag);
|
|
1075
629
|
}
|
|
1076
|
-
// Every call site that refreshes the header (stateChange, botReady,
|
|
1077
|
-
// connect/disconnect via stateChange, character-info fetch, ...) also
|
|
1078
|
-
// needs the connecting overlay re-evaluated -- it depends on the same
|
|
1079
|
-
// `isConnected`/`isBotReady`/`characterName` inputs.
|
|
1080
|
-
updateConnectingOverlay();
|
|
1081
|
-
updateVoiceModeAvatar();
|
|
1082
630
|
};
|
|
1083
631
|
// Create message list
|
|
1084
632
|
const createMessageList = () => {
|
|
1085
633
|
const list = document.createElement("div");
|
|
1086
634
|
list.id = "convai-message-list";
|
|
1087
|
-
list.setAttribute("part", "messages");
|
|
1088
635
|
list.style.cssText = `
|
|
1089
636
|
display: flex;
|
|
1090
637
|
flex-direction: column;
|
|
1091
|
-
gap:
|
|
638
|
+
gap: 0.75rem;
|
|
1092
639
|
min-height: 100%;
|
|
1093
640
|
`;
|
|
1094
641
|
return list;
|
|
@@ -1097,23 +644,22 @@ export function createConvaiWidget(container, options) {
|
|
|
1097
644
|
const createFooter = () => {
|
|
1098
645
|
const footer = document.createElement("div");
|
|
1099
646
|
footer.style.cssText = `
|
|
1100
|
-
padding:
|
|
647
|
+
padding: 1rem;
|
|
1101
648
|
border-top: 1px solid ${aeroTheme.colors.neutral[200]};
|
|
1102
|
-
background:
|
|
649
|
+
background: white;
|
|
1103
650
|
display: flex;
|
|
1104
|
-
gap:
|
|
651
|
+
gap: 0.5rem;
|
|
1105
652
|
align-items: center;
|
|
1106
653
|
position: relative;
|
|
1107
654
|
`;
|
|
1108
655
|
// Voice Mode Exit Button (Initially hidden)
|
|
1109
656
|
const voiceExitButton = document.createElement("button");
|
|
1110
657
|
voiceExitButton.id = "convai-voice-exit-btn";
|
|
1111
|
-
refs.voiceExitBtn = voiceExitButton;
|
|
1112
658
|
const exitIcon = Icons.PhoneOff("md");
|
|
1113
659
|
voiceExitButton.appendChild(exitIcon);
|
|
1114
660
|
voiceExitButton.style.cssText = `
|
|
1115
|
-
width:
|
|
1116
|
-
height:
|
|
661
|
+
width: 2.25rem;
|
|
662
|
+
height: 2.25rem;
|
|
1117
663
|
border-radius: 50%;
|
|
1118
664
|
background: ${aeroTheme.colors.error[500]};
|
|
1119
665
|
color: white;
|
|
@@ -1124,31 +670,19 @@ export function createConvaiWidget(container, options) {
|
|
|
1124
670
|
margin: 0 auto;
|
|
1125
671
|
border: none;
|
|
1126
672
|
`;
|
|
1127
|
-
voiceExitButton.addEventListener("click", () => {
|
|
1128
|
-
// Deliberately a *sync* listener. It used to be `async` with a bare
|
|
1129
|
-
// `await client.audioControls.muteAudio()` and no try/catch: on the
|
|
1130
|
-
// LiveKit path that call rethrows when the room has dropped, so a
|
|
1131
|
-
// network blip mid-call plus a tap on exit leaked an unhandled
|
|
1132
|
-
// rejection onto the host page *and* skipped the two lines below,
|
|
1133
|
-
// stranding the user in the voice overlay with no way out.
|
|
1134
|
-
//
|
|
1135
|
-
// The mute is not dropped -- `applyVoiceMode()` -> `updateVoiceMode()`
|
|
1136
|
-
// performs it (conditionally, if the mic is live) inside the exit
|
|
1137
|
-
// branch, where a failure is now reported rather than fatal. Exiting
|
|
1138
|
-
// the UI is unconditional either way: the overlay must never outlive
|
|
1139
|
-
// the user's decision to leave it.
|
|
673
|
+
voiceExitButton.addEventListener("click", async () => {
|
|
1140
674
|
client.sendInterruptMessage();
|
|
675
|
+
await client.audioControls.muteAudio(); // Mute on exit
|
|
1141
676
|
isVoiceMode = false;
|
|
1142
|
-
|
|
677
|
+
updateVoiceMode();
|
|
1143
678
|
});
|
|
1144
679
|
footer.appendChild(voiceExitButton);
|
|
1145
680
|
// Standard Footer Content (Mic + Input)
|
|
1146
681
|
const standardContent = document.createElement("div");
|
|
1147
682
|
standardContent.id = "convai-footer-standard";
|
|
1148
|
-
refs.footerStandard = standardContent;
|
|
1149
683
|
standardContent.style.cssText = `
|
|
1150
684
|
display: flex;
|
|
1151
|
-
gap:
|
|
685
|
+
gap: 0.5rem;
|
|
1152
686
|
align-items: center;
|
|
1153
687
|
width: 100%;
|
|
1154
688
|
`;
|
|
@@ -1161,16 +695,15 @@ export function createConvaiWidget(container, options) {
|
|
|
1161
695
|
align-items: center;
|
|
1162
696
|
`;
|
|
1163
697
|
inputElement = document.createElement("input");
|
|
1164
|
-
inputElement.setAttribute("part", "input");
|
|
1165
698
|
inputElement.type = "text";
|
|
1166
699
|
inputElement.placeholder = "Conversation";
|
|
1167
700
|
inputElement.style.cssText = `
|
|
1168
701
|
width: 100%;
|
|
1169
|
-
padding:
|
|
702
|
+
padding: 0.75rem 2.75rem 0.75rem 1rem;
|
|
1170
703
|
border-radius: ${aeroTheme.borderRadius.full};
|
|
1171
704
|
border: 1px solid ${aeroTheme.colors.neutral[300]};
|
|
1172
705
|
background: ${aeroTheme.colors.glass.medium};
|
|
1173
|
-
color:
|
|
706
|
+
color: ${aeroTheme.colors.text.primary};
|
|
1174
707
|
font-size: ${aeroTheme.typography.fontSize.sm};
|
|
1175
708
|
transition: ${aeroTheme.transitions.fast};
|
|
1176
709
|
outline: none;
|
|
@@ -1186,22 +719,16 @@ export function createConvaiWidget(container, options) {
|
|
|
1186
719
|
// Send button
|
|
1187
720
|
const sendButton = document.createElement("button");
|
|
1188
721
|
sendButton.id = "convai-send-button";
|
|
1189
|
-
// This button is dual-role -- it sends text when the input has a draft,
|
|
1190
|
-
// and toggles voice mode when empty -- but there is no separate mic
|
|
1191
|
-
// element, so only the send-button part applies. `mic-button` is
|
|
1192
|
-
// reserved for a future version that splits the two; see parts.test.ts.
|
|
1193
|
-
sendButton.setAttribute("part", "send-button");
|
|
1194
|
-
refs.sendButton = sendButton;
|
|
1195
722
|
const sendIcon = Icons.Send("md");
|
|
1196
723
|
sendButton.appendChild(sendIcon);
|
|
1197
724
|
sendButton.style.cssText = `
|
|
1198
725
|
position: absolute;
|
|
1199
|
-
right:
|
|
1200
|
-
width:
|
|
1201
|
-
height:
|
|
726
|
+
right: 0.375rem;
|
|
727
|
+
width: 2.25rem;
|
|
728
|
+
height: 2.25rem;
|
|
1202
729
|
border-radius: 50%;
|
|
1203
730
|
background: transparent; /* Initial transparent for voice toggle */
|
|
1204
|
-
color:
|
|
731
|
+
color: ${aeroTheme.colors.text.primary};
|
|
1205
732
|
display: flex;
|
|
1206
733
|
align-items: center;
|
|
1207
734
|
justify-content: center;
|
|
@@ -1217,19 +744,9 @@ export function createConvaiWidget(container, options) {
|
|
|
1217
744
|
client.connectionType !== "text") {
|
|
1218
745
|
// Toggle Voice Mode
|
|
1219
746
|
client.sendInterruptMessage();
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
// Awaited so this try/catch actually covers it -- bare, the promise
|
|
1224
|
-
// escaped the block it looks like it belongs to.
|
|
1225
|
-
await updateVoiceMode();
|
|
1226
|
-
}
|
|
1227
|
-
catch (error) {
|
|
1228
|
-
// isVoiceMode is only flipped after a successful unmute, so a
|
|
1229
|
-
// denial here leaves the widget in text mode, not stranded
|
|
1230
|
-
// mid-transition into voice mode.
|
|
1231
|
-
reportMicFailure(client, "Failed to enable microphone:", error);
|
|
1232
|
-
}
|
|
747
|
+
await client.audioControls.unmuteAudio(); // Unmute on enter
|
|
748
|
+
isVoiceMode = true;
|
|
749
|
+
updateVoiceMode();
|
|
1233
750
|
}
|
|
1234
751
|
});
|
|
1235
752
|
inputContainer.appendChild(inputElement);
|
|
@@ -1246,7 +763,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1246
763
|
position: absolute;
|
|
1247
764
|
top: 60px;
|
|
1248
765
|
right: 16px;
|
|
1249
|
-
background:
|
|
766
|
+
background: white;
|
|
1250
767
|
border-radius: ${aeroTheme.borderRadius.xl};
|
|
1251
768
|
box-shadow: ${aeroTheme.shadows.xl};
|
|
1252
769
|
padding: 0;
|
|
@@ -1261,7 +778,17 @@ export function createConvaiWidget(container, options) {
|
|
|
1261
778
|
overflow: hidden;
|
|
1262
779
|
`;
|
|
1263
780
|
// Add animation keyframes if not present
|
|
1264
|
-
|
|
781
|
+
if (!document.getElementById("convai-widget-keyframes")) {
|
|
782
|
+
const style = document.createElement("style");
|
|
783
|
+
style.id = "convai-widget-keyframes";
|
|
784
|
+
style.textContent = `
|
|
785
|
+
@keyframes popIn {
|
|
786
|
+
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
|
787
|
+
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
788
|
+
}
|
|
789
|
+
`;
|
|
790
|
+
document.head.appendChild(style);
|
|
791
|
+
}
|
|
1265
792
|
// Horizontal Settings Row Container
|
|
1266
793
|
const settingsRow = document.createElement("div");
|
|
1267
794
|
settingsRow.style.cssText = `
|
|
@@ -1290,7 +817,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1290
817
|
? "#10b981"
|
|
1291
818
|
: isDestructive
|
|
1292
819
|
? "#ef4444"
|
|
1293
|
-
:
|
|
820
|
+
: aeroTheme.colors.text.primary};
|
|
1294
821
|
transition: ${aeroTheme.transitions.fast};
|
|
1295
822
|
min-width: 50px;
|
|
1296
823
|
`;
|
|
@@ -1342,7 +869,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1342
869
|
videoIcon.style.height = "18px";
|
|
1343
870
|
const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
|
|
1344
871
|
videoBtn.id = "convai-settings-video-btn";
|
|
1345
|
-
refs.settingsVideoBtn = videoBtn;
|
|
1346
872
|
settingsRow.appendChild(videoBtn);
|
|
1347
873
|
}
|
|
1348
874
|
// Screen Share - only show if connection type is video
|
|
@@ -1355,7 +881,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1355
881
|
shareIcon.style.height = "18px";
|
|
1356
882
|
const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
|
|
1357
883
|
shareBtn.id = "convai-settings-share-btn";
|
|
1358
|
-
refs.settingsShareBtn = shareBtn;
|
|
1359
884
|
settingsRow.appendChild(shareBtn);
|
|
1360
885
|
}
|
|
1361
886
|
// Disconnect
|
|
@@ -1394,7 +919,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1394
919
|
? "#10b981"
|
|
1395
920
|
: isDestructive
|
|
1396
921
|
? "#ef4444"
|
|
1397
|
-
:
|
|
922
|
+
: aeroTheme.colors.text.primary};
|
|
1398
923
|
transition: ${aeroTheme.transitions.fast};
|
|
1399
924
|
min-width: 50px;
|
|
1400
925
|
`;
|
|
@@ -1446,7 +971,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1446
971
|
videoIcon.style.height = "18px";
|
|
1447
972
|
const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
|
|
1448
973
|
videoBtn.id = "convai-settings-video-btn";
|
|
1449
|
-
refs.settingsVideoBtn = videoBtn;
|
|
1450
974
|
settingsRow.appendChild(videoBtn);
|
|
1451
975
|
}
|
|
1452
976
|
// Screen Share - Always show if showScreenShare is true and connection type is video
|
|
@@ -1459,7 +983,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1459
983
|
shareIcon.style.height = "18px";
|
|
1460
984
|
const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
|
|
1461
985
|
shareBtn.id = "convai-settings-share-btn";
|
|
1462
|
-
refs.settingsShareBtn = shareBtn;
|
|
1463
986
|
settingsRow.appendChild(shareBtn);
|
|
1464
987
|
}
|
|
1465
988
|
// Disconnect
|
|
@@ -1474,12 +997,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1474
997
|
const createFloatingVideo = () => {
|
|
1475
998
|
const container = document.createElement("div");
|
|
1476
999
|
container.id = "floating-video-container";
|
|
1477
|
-
// NOTE on z-index: deliberately NOT themable. `--convai-z-index` drives the
|
|
1478
|
-
// widget root only. Both elements are `position: fixed` and this one is
|
|
1479
|
-
// appended *before* the widget root, so on a tie the widget paints on top:
|
|
1480
|
-
// a host raising the token to clear their nav bar would hide the user's own
|
|
1481
|
-
// camera preview behind the panel. Video/screen share are also out of scope
|
|
1482
|
-
// for the embed, which is chat-only.
|
|
1483
1000
|
container.style.cssText = `
|
|
1484
1001
|
position: fixed;
|
|
1485
1002
|
left: 20px;
|
|
@@ -1661,18 +1178,16 @@ export function createConvaiWidget(container, options) {
|
|
|
1661
1178
|
};
|
|
1662
1179
|
// Event handlers
|
|
1663
1180
|
const handleToggle = async () => {
|
|
1664
|
-
if (isDestroyed)
|
|
1181
|
+
if (isDestroyed || isStartingConnection) {
|
|
1665
1182
|
return;
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
//
|
|
1669
|
-
// to the `isConnecting` branch below and opening the panel *before* the
|
|
1670
|
-
// connect it is waiting on has finished.
|
|
1183
|
+
}
|
|
1184
|
+
if (isOpen) {
|
|
1185
|
+
// Just close if already open
|
|
1671
1186
|
return;
|
|
1672
1187
|
}
|
|
1673
1188
|
// Connect on first click if not already connected/connecting
|
|
1674
1189
|
if (!client.state.isConnected && !client.state.isConnecting) {
|
|
1675
|
-
|
|
1190
|
+
isStartingConnection = true;
|
|
1676
1191
|
try {
|
|
1677
1192
|
if (requestPublicationGrant || client.isPublishedChatSession) {
|
|
1678
1193
|
if (!requestPublicationGrant) {
|
|
@@ -1702,20 +1217,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1702
1217
|
await client.disconnect();
|
|
1703
1218
|
return;
|
|
1704
1219
|
}
|
|
1705
|
-
// Re-checked *after* the await: a close()/toggle()/launcher click
|
|
1706
|
-
// during the connect clears the flag, and that cancellation has to
|
|
1707
|
-
// win. The client stays connected either way -- only the panel is
|
|
1708
|
-
// held back -- so a later open() takes the already-connected branch
|
|
1709
|
-
// and opens instantly.
|
|
1710
|
-
if (isDestroyed) {
|
|
1711
|
-
// destroy() can also run after the grant is exchanged but before
|
|
1712
|
-
// Core or LiveKit finishes connecting. Disconnect that late session
|
|
1713
|
-
// instead of leaving an invisible room alive.
|
|
1714
|
-
await client.disconnect();
|
|
1715
|
-
return;
|
|
1716
|
-
}
|
|
1717
|
-
if (!pendingOpen)
|
|
1718
|
-
return;
|
|
1719
1220
|
setIsOpen(true);
|
|
1720
1221
|
}
|
|
1721
1222
|
catch (error) {
|
|
@@ -1724,7 +1225,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1724
1225
|
}
|
|
1725
1226
|
}
|
|
1726
1227
|
finally {
|
|
1727
|
-
|
|
1228
|
+
isStartingConnection = false;
|
|
1728
1229
|
}
|
|
1729
1230
|
}
|
|
1730
1231
|
else {
|
|
@@ -1732,29 +1233,11 @@ export function createConvaiWidget(container, options) {
|
|
|
1732
1233
|
setIsOpen(!isOpen);
|
|
1733
1234
|
}
|
|
1734
1235
|
};
|
|
1735
|
-
/** A launcher click while an open is pending means "cancel", same as `toggle()`. */
|
|
1736
|
-
const handleLauncherClick = () => {
|
|
1737
|
-
if (pendingOpen) {
|
|
1738
|
-
handleClose();
|
|
1739
|
-
return;
|
|
1740
|
-
}
|
|
1741
|
-
void handleToggle();
|
|
1742
|
-
};
|
|
1743
1236
|
const handleClose = () => {
|
|
1744
|
-
// Cancels an in-flight open as well as closing an open panel; see
|
|
1745
|
-
// `pendingOpen`. `setIsOpen(false)` on an already-closed widget just
|
|
1746
|
-
// re-applies the collapsed styles, so this is safe in both cases.
|
|
1747
|
-
pendingOpen = false;
|
|
1748
1237
|
setIsOpen(false);
|
|
1749
1238
|
};
|
|
1750
|
-
// Published sessions must wait for Core's bot-ready handshake. A normal
|
|
1751
|
-
// authenticated text session still gets the text-only UI, but can send as
|
|
1752
|
-
// soon as it connects.
|
|
1753
|
-
const requiresBotReadyForText = () => Boolean(requestPublicationGrant) || Boolean(client.isPublishedChatSession);
|
|
1754
|
-
const isTextInputReady = () => client.state.isConnected &&
|
|
1755
|
-
(!requiresBotReadyForText() || client.isBotReady);
|
|
1756
1239
|
const handleSend = () => {
|
|
1757
|
-
if (inputValue.trim() &&
|
|
1240
|
+
if (inputValue.trim() && client.state.isConnected && client.isBotReady) {
|
|
1758
1241
|
client.sendUserTextMessage(inputValue);
|
|
1759
1242
|
inputValue = "";
|
|
1760
1243
|
inputElement.value = "";
|
|
@@ -1785,7 +1268,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1785
1268
|
isVoiceMode = false;
|
|
1786
1269
|
hasEnteredDefaultVoiceMode = false;
|
|
1787
1270
|
updateHeader();
|
|
1788
|
-
|
|
1271
|
+
updateVoiceMode();
|
|
1789
1272
|
updateSendButton();
|
|
1790
1273
|
}
|
|
1791
1274
|
catch (error) {
|
|
@@ -1799,11 +1282,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1799
1282
|
isVoiceMode = false;
|
|
1800
1283
|
hasEnteredDefaultVoiceMode = false;
|
|
1801
1284
|
updateHeader();
|
|
1802
|
-
|
|
1803
|
-
// straight to a click, so awaiting would only move a rejection from one
|
|
1804
|
-
// unguarded place to another. See the note on `handleDisconnect`'s own
|
|
1805
|
-
// `await client.disconnect()` in the fix report.
|
|
1806
|
-
applyVoiceMode();
|
|
1285
|
+
updateVoiceMode();
|
|
1807
1286
|
updateSendButton();
|
|
1808
1287
|
};
|
|
1809
1288
|
const handleToggleVideo = async () => {
|
|
@@ -1842,17 +1321,10 @@ export function createConvaiWidget(container, options) {
|
|
|
1842
1321
|
const setIsOpen = async (open) => {
|
|
1843
1322
|
isOpen = open;
|
|
1844
1323
|
if (open) {
|
|
1845
|
-
morphingContainer.style.width =
|
|
1846
|
-
morphingContainer.style.height =
|
|
1847
|
-
morphingContainer.style.borderRadius =
|
|
1324
|
+
morphingContainer.style.width = "400px";
|
|
1325
|
+
morphingContainer.style.height = "600px";
|
|
1326
|
+
morphingContainer.style.borderRadius = aeroTheme.borderRadius.xl;
|
|
1848
1327
|
morphingContainer.style.cursor = "default";
|
|
1849
|
-
// The open panel is a *different* glass treatment from the launcher
|
|
1850
|
-
// bubble, matching the React widget: near-opaque and saturated, so the
|
|
1851
|
-
// host page does not bleed through. At the bubble's 0.8 alpha a dark
|
|
1852
|
-
// host page shows through the panel and reads as grey.
|
|
1853
|
-
morphingContainer.style.background =
|
|
1854
|
-
"var(--convai-panel-bg, rgba(252, 252, 253, 0.95))";
|
|
1855
|
-
morphingContainer.style.backdropFilter = "blur(20px) saturate(180%)";
|
|
1856
1328
|
buttonContent.style.opacity = "0";
|
|
1857
1329
|
buttonContent.style.transform = "scale(0.8)";
|
|
1858
1330
|
buttonContent.style.pointerEvents = "none";
|
|
@@ -1870,24 +1342,19 @@ export function createConvaiWidget(container, options) {
|
|
|
1870
1342
|
await client.audioControls.unmuteAudio();
|
|
1871
1343
|
isVoiceMode = true;
|
|
1872
1344
|
hasEnteredDefaultVoiceMode = true;
|
|
1873
|
-
|
|
1345
|
+
updateVoiceMode();
|
|
1874
1346
|
}
|
|
1875
1347
|
catch (error) {
|
|
1876
|
-
|
|
1348
|
+
console.error("Failed to enter voice mode on open:", error);
|
|
1877
1349
|
}
|
|
1878
1350
|
}, 100);
|
|
1879
1351
|
}
|
|
1880
1352
|
}
|
|
1881
1353
|
else {
|
|
1882
|
-
morphingContainer.style.width = "
|
|
1883
|
-
morphingContainer.style.height = "
|
|
1884
|
-
// Literal, not `--convai-radius` — see the creation site: that token is
|
|
1885
|
-
// the *panel* radius, and reusing it here would squircle the launcher.
|
|
1354
|
+
morphingContainer.style.width = "4rem";
|
|
1355
|
+
morphingContainer.style.height = "4rem";
|
|
1886
1356
|
morphingContainer.style.borderRadius = "50%";
|
|
1887
1357
|
morphingContainer.style.cursor = "pointer";
|
|
1888
|
-
// Back to the launcher bubble's lighter glass — see the open branch.
|
|
1889
|
-
morphingContainer.style.background = `var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop})`;
|
|
1890
|
-
morphingContainer.style.backdropFilter = aeroTheme.glass.backdrop;
|
|
1891
1358
|
buttonContent.style.opacity = "1";
|
|
1892
1359
|
buttonContent.style.transform = "scale(1)";
|
|
1893
1360
|
buttonContent.style.pointerEvents = "auto";
|
|
@@ -1901,52 +1368,35 @@ export function createConvaiWidget(container, options) {
|
|
|
1901
1368
|
settingsTray.style.display = open ? "flex" : "none";
|
|
1902
1369
|
};
|
|
1903
1370
|
const updateSendButton = () => {
|
|
1904
|
-
const sendButton =
|
|
1371
|
+
const sendButton = document.getElementById("convai-send-button");
|
|
1905
1372
|
if (!sendButton)
|
|
1906
1373
|
return;
|
|
1907
1374
|
// Clear previous content
|
|
1908
1375
|
sendButton.innerHTML = "";
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
// for authenticated voice/video sessions.
|
|
1912
|
-
const isPublishedTextChat = requiresBotReadyForText();
|
|
1913
|
-
const textInputReady = isTextInputReady();
|
|
1914
|
-
inputElement.disabled = isPublishedTextChat && !textInputReady;
|
|
1376
|
+
const canSendText = client.state.isConnected && client.isBotReady;
|
|
1377
|
+
inputElement.disabled = !canSendText;
|
|
1915
1378
|
if (inputValue.length > 0 ||
|
|
1916
1379
|
requestPublicationGrant ||
|
|
1917
1380
|
client.connectionType === "text") {
|
|
1918
1381
|
// Show Send Button
|
|
1919
1382
|
sendButton.appendChild(Icons.Send("md"));
|
|
1920
|
-
sendButton.style.background =
|
|
1921
|
-
|
|
1922
|
-
// surface colour rather than stay a hardcoded "white" -- a host setting
|
|
1923
|
-
// a pale --convai-accent would otherwise get white-on-white. `white` is
|
|
1924
|
-
// byte-identical to the fallback --convai-panel-bg already carries at
|
|
1925
|
-
// its three other sites, so the default rendering is unchanged.
|
|
1926
|
-
// Pairing requirement for hosts: --convai-accent and --convai-panel-bg
|
|
1927
|
-
// must contrast with each other, since this is the one place they meet.
|
|
1928
|
-
sendButton.style.color = "var(--convai-panel-bg, white)";
|
|
1383
|
+
sendButton.style.background = aeroTheme.colors.text.primary;
|
|
1384
|
+
sendButton.style.color = "white";
|
|
1929
1385
|
sendButton.style.border = "none";
|
|
1930
|
-
sendButton.disabled =
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
: "Type a message"
|
|
1939
|
-
: "Waiting for character";
|
|
1940
|
-
}
|
|
1941
|
-
else {
|
|
1942
|
-
sendButton.title = "Send";
|
|
1943
|
-
}
|
|
1386
|
+
sendButton.disabled = inputValue.length === 0 || !canSendText;
|
|
1387
|
+
sendButton.style.opacity =
|
|
1388
|
+
inputValue.length > 0 && canSendText ? "1" : "0.5";
|
|
1389
|
+
sendButton.title = canSendText
|
|
1390
|
+
? inputValue.length > 0
|
|
1391
|
+
? "Send"
|
|
1392
|
+
: "Type a message"
|
|
1393
|
+
: "Waiting for character";
|
|
1944
1394
|
}
|
|
1945
1395
|
else {
|
|
1946
1396
|
// Show Voice Mode Toggle
|
|
1947
1397
|
sendButton.appendChild(Icons.Waveform("md"));
|
|
1948
1398
|
sendButton.style.background = "transparent";
|
|
1949
|
-
sendButton.style.color =
|
|
1399
|
+
sendButton.style.color = aeroTheme.colors.text.primary;
|
|
1950
1400
|
sendButton.style.border = `1px solid ${aeroTheme.colors.neutral[300]}`;
|
|
1951
1401
|
sendButton.style.opacity = "1";
|
|
1952
1402
|
sendButton.disabled = false;
|
|
@@ -1979,8 +1429,8 @@ export function createConvaiWidget(container, options) {
|
|
|
1979
1429
|
}
|
|
1980
1430
|
};
|
|
1981
1431
|
const updateVoiceMode = async () => {
|
|
1982
|
-
const standardFooter =
|
|
1983
|
-
const voiceExitBtn =
|
|
1432
|
+
const standardFooter = document.getElementById("convai-footer-standard");
|
|
1433
|
+
const voiceExitBtn = document.getElementById("convai-voice-exit-btn");
|
|
1984
1434
|
if (isVoiceMode) {
|
|
1985
1435
|
// Show Voice Overlay
|
|
1986
1436
|
if (voiceModeOverlay)
|
|
@@ -2000,26 +1450,15 @@ export function createConvaiWidget(container, options) {
|
|
|
2000
1450
|
updateHeader();
|
|
2001
1451
|
// Reset animation state for voice mode
|
|
2002
1452
|
startTime = Date.now();
|
|
1453
|
+
currentLevels = Array(40).fill(0.05);
|
|
1454
|
+
targetLevels = Array(40).fill(0.05);
|
|
2003
1455
|
// Start Audio Analysis
|
|
2004
1456
|
startAudioAnalysis();
|
|
2005
1457
|
}
|
|
2006
1458
|
else {
|
|
2007
|
-
// Ensure microphone is muted when not in voice mode
|
|
2008
|
-
//
|
|
2009
|
-
// This await is the only one in this function that can reject, and
|
|
2010
|
-
// everything below it -- hiding the overlay, restoring the standard
|
|
2011
|
-
// footer, updating the header, stopping the analyser -- is the only
|
|
2012
|
-
// route out of the voice UI. So a failed mute must never short-circuit
|
|
2013
|
-
// it: report it and carry on tearing the overlay down. (LiveKit's
|
|
2014
|
-
// setMicrophoneEnabled(false) rethrows on a dropped room, which is
|
|
2015
|
-
// exactly when the user is most likely to be hitting exit.)
|
|
1459
|
+
// Ensure microphone is muted when not in voice mode
|
|
2016
1460
|
if (!client.audioControls.isAudioMuted) {
|
|
2017
|
-
|
|
2018
|
-
await client.audioControls.muteAudio();
|
|
2019
|
-
}
|
|
2020
|
-
catch (error) {
|
|
2021
|
-
reportMicFailure(client, "Failed to mute microphone:", error);
|
|
2022
|
-
}
|
|
1461
|
+
await client.audioControls.muteAudio();
|
|
2023
1462
|
}
|
|
2024
1463
|
// Hide Overlay
|
|
2025
1464
|
if (voiceModeOverlay)
|
|
@@ -2051,24 +1490,6 @@ export function createConvaiWidget(container, options) {
|
|
|
2051
1490
|
stopAudioAnalysis();
|
|
2052
1491
|
}
|
|
2053
1492
|
};
|
|
2054
|
-
/**
|
|
2055
|
-
* Fire-and-forget `updateVoiceMode()` for the call sites that are
|
|
2056
|
-
* synchronous (DOM listeners, the constructor tail) and therefore have no
|
|
2057
|
-
* caller to catch anything it rejects with.
|
|
2058
|
-
*
|
|
2059
|
-
* `updateVoiceMode()` is `async`, so a bare call there would put a floating
|
|
2060
|
-
* promise on the host page. Its one rejecting await is now guarded
|
|
2061
|
-
* internally, which makes this belt-and-braces -- but it is the guarantee
|
|
2062
|
-
* that keeps a future await added inside `updateVoiceMode()` from silently
|
|
2063
|
-
* reintroducing the unhandled rejection this replaces. Routed through
|
|
2064
|
-
* `reportMicFailure` so a failure is observable on the client's `error`
|
|
2065
|
-
* channel rather than console-only.
|
|
2066
|
-
*/
|
|
2067
|
-
const applyVoiceMode = () => {
|
|
2068
|
-
void updateVoiceMode().catch((error) => {
|
|
2069
|
-
reportMicFailure(client, "Failed to apply voice mode:", error);
|
|
2070
|
-
});
|
|
2071
|
-
};
|
|
2072
1493
|
// Microphone button removed - only controlled via voice mode now
|
|
2073
1494
|
// Helper for Markdown - matches React MarkdownRenderer.tsx
|
|
2074
1495
|
const renderMarkdown = (text, container) => {
|
|
@@ -2079,65 +1500,59 @@ export function createConvaiWidget(container, options) {
|
|
|
2079
1500
|
const lines = normalizedText.split("\n");
|
|
2080
1501
|
lines.forEach((line, lineIndex) => {
|
|
2081
1502
|
// Process markdown within each line
|
|
2082
|
-
// Bold, italic, links, images and bare URLs — see utils/inlineMarkdown.
|
|
2083
|
-
const mediaPreview = (src, alt, media) => {
|
|
2084
|
-
const el = media === "image"
|
|
2085
|
-
? Object.assign(document.createElement("img"), { src, alt, loading: "lazy" })
|
|
2086
|
-
: Object.assign(document.createElement("video"), { src, controls: true, preload: "metadata" });
|
|
2087
|
-
el.setAttribute("part", "message-media");
|
|
2088
|
-
el.style.cssText = `
|
|
2089
|
-
display: block;
|
|
2090
|
-
max-width: 100%;
|
|
2091
|
-
max-height: 240px;
|
|
2092
|
-
margin-top: 6px;
|
|
2093
|
-
border-radius: 8px;
|
|
2094
|
-
`;
|
|
2095
|
-
return el;
|
|
2096
|
-
};
|
|
2097
1503
|
const processLine = (text) => {
|
|
2098
1504
|
const parts = [];
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
1505
|
+
let remaining = text;
|
|
1506
|
+
// Process bold and italic markdown
|
|
1507
|
+
// Bold: **text** or __text__
|
|
1508
|
+
// Italic: *text* or _text_
|
|
1509
|
+
const markdownRegex = /(\*\*|__)(.*?)\1|\*([^*]+)\*|_([^_]+)_/g;
|
|
1510
|
+
let lastIndex = 0;
|
|
1511
|
+
let match;
|
|
1512
|
+
while ((match = markdownRegex.exec(remaining)) !== null) {
|
|
1513
|
+
// Add text before the match
|
|
1514
|
+
if (match.index > lastIndex) {
|
|
1515
|
+
const textBefore = remaining.substring(lastIndex, match.index);
|
|
1516
|
+
if (textBefore) {
|
|
1517
|
+
parts.push(document.createTextNode(textBefore));
|
|
2111
1518
|
}
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
1519
|
+
}
|
|
1520
|
+
// Determine if it's bold or italic
|
|
1521
|
+
if (match[1] && match[2]) {
|
|
1522
|
+
// Bold text (** or __) - matches React UserBubble styling
|
|
1523
|
+
const strong = document.createElement("strong");
|
|
1524
|
+
strong.textContent = match[2];
|
|
1525
|
+
strong.style.cssText = `
|
|
1526
|
+
font-weight: 600;
|
|
1527
|
+
color: ${aeroTheme.colors.text.primary};
|
|
1528
|
+
`;
|
|
1529
|
+
parts.push(strong);
|
|
1530
|
+
}
|
|
1531
|
+
else if (match[3]) {
|
|
1532
|
+
// Italic text (*) - matches React UserBubble styling
|
|
1533
|
+
const em = document.createElement("em");
|
|
1534
|
+
em.textContent = match[3];
|
|
1535
|
+
em.style.cssText = `
|
|
1536
|
+
font-style: italic;
|
|
1537
|
+
`;
|
|
1538
|
+
parts.push(em);
|
|
1539
|
+
}
|
|
1540
|
+
else if (match[4]) {
|
|
1541
|
+
// Italic text (_) - matches React UserBubble styling
|
|
1542
|
+
const em = document.createElement("em");
|
|
1543
|
+
em.textContent = match[4];
|
|
1544
|
+
em.style.cssText = `
|
|
1545
|
+
font-style: italic;
|
|
1546
|
+
`;
|
|
1547
|
+
parts.push(em);
|
|
1548
|
+
}
|
|
1549
|
+
lastIndex = match.index + match[0].length;
|
|
1550
|
+
}
|
|
1551
|
+
// Add remaining text
|
|
1552
|
+
if (lastIndex < remaining.length) {
|
|
1553
|
+
const textAfter = remaining.substring(lastIndex);
|
|
1554
|
+
if (textAfter) {
|
|
1555
|
+
parts.push(document.createTextNode(textAfter));
|
|
2141
1556
|
}
|
|
2142
1557
|
}
|
|
2143
1558
|
return parts;
|
|
@@ -2176,7 +1591,7 @@ export function createConvaiWidget(container, options) {
|
|
|
2176
1591
|
height: 100%;
|
|
2177
1592
|
color: ${aeroTheme.colors.text.secondary};
|
|
2178
1593
|
text-align: center;
|
|
2179
|
-
padding:
|
|
1594
|
+
padding: 2rem;
|
|
2180
1595
|
`;
|
|
2181
1596
|
emptyState.innerHTML = `
|
|
2182
1597
|
<p style="font-weight: 500; margin-bottom: 8px;">Start a conversation</p>
|
|
@@ -2198,7 +1613,6 @@ export function createConvaiWidget(container, options) {
|
|
|
2198
1613
|
contain: layout style paint;
|
|
2199
1614
|
`;
|
|
2200
1615
|
const bubble = document.createElement("div");
|
|
2201
|
-
bubble.setAttribute("part", msg.isUser ? "message message-user" : "message message-bot");
|
|
2202
1616
|
// Add data attribute to identify user messages for debugging/styling
|
|
2203
1617
|
if (msg.isUser) {
|
|
2204
1618
|
bubble.setAttribute("data-message-type", "user");
|
|
@@ -2206,10 +1620,6 @@ export function createConvaiWidget(container, options) {
|
|
|
2206
1620
|
// Set base styles first - explicitly set background to match React UserBubble exactly
|
|
2207
1621
|
// CRITICAL: Use the exact same background for both user and bot messages
|
|
2208
1622
|
const backgroundColor = "rgba(252, 252, 253, 0.95)";
|
|
2209
|
-
// NOTE on border-radius: deliberately NOT themable. `--convai-radius` is
|
|
2210
|
-
// the open panel's radius; a bubble's asymmetric tail corner is a
|
|
2211
|
-
// different geometry (`50%` would make ellipses of these). A dedicated
|
|
2212
|
-
// `--convai-bubble-radius` can be added additively later if wanted.
|
|
2213
1623
|
bubble.style.cssText = `
|
|
2214
1624
|
padding: 12px;
|
|
2215
1625
|
border-radius: ${msg.isUser ? "12px 12px 4px 12px" : "12px 12px 12px 4px"};
|
|
@@ -2218,8 +1628,8 @@ export function createConvaiWidget(container, options) {
|
|
|
2218
1628
|
backdrop-filter: blur(20px) saturate(180%);
|
|
2219
1629
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
2220
1630
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8) inset, 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
2221
|
-
color:
|
|
2222
|
-
font-family:
|
|
1631
|
+
color: ${aeroTheme.colors.text.primary};
|
|
1632
|
+
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
2223
1633
|
font-size: 14px;
|
|
2224
1634
|
line-height: 1.5;
|
|
2225
1635
|
word-wrap: break-word;
|
|
@@ -2251,8 +1661,8 @@ export function createConvaiWidget(container, options) {
|
|
|
2251
1661
|
// Logo/Icon - matches React LogoWrapper
|
|
2252
1662
|
const logoContainer = document.createElement("div");
|
|
2253
1663
|
logoContainer.style.cssText = `
|
|
2254
|
-
width:
|
|
2255
|
-
height:
|
|
1664
|
+
width: 1rem;
|
|
1665
|
+
height: 1rem;
|
|
2256
1666
|
display: flex;
|
|
2257
1667
|
align-items: center;
|
|
2258
1668
|
justify-content: center;
|
|
@@ -2279,10 +1689,10 @@ export function createConvaiWidget(container, options) {
|
|
|
2279
1689
|
const nameLabel = document.createElement("span");
|
|
2280
1690
|
nameLabel.textContent = msg.sender === "User" ? "You" : msg.sender;
|
|
2281
1691
|
nameLabel.style.cssText = `
|
|
2282
|
-
font-size:
|
|
1692
|
+
font-size: 0.75rem;
|
|
2283
1693
|
color: ${msg.isUser ? aeroTheme.colors.convai.dark : "#10b981"};
|
|
2284
1694
|
font-weight: 500;
|
|
2285
|
-
font-family:
|
|
1695
|
+
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
2286
1696
|
isolation: isolate;
|
|
2287
1697
|
contain: layout style paint;
|
|
2288
1698
|
`;
|
|
@@ -2304,11 +1714,11 @@ export function createConvaiWidget(container, options) {
|
|
|
2304
1714
|
const timestamp = document.createElement("span");
|
|
2305
1715
|
timestamp.textContent = msg.timestamp;
|
|
2306
1716
|
timestamp.style.cssText = `
|
|
2307
|
-
font-size:
|
|
1717
|
+
font-size: 0.75rem;
|
|
2308
1718
|
color: ${aeroTheme.colors.text.secondary};
|
|
2309
1719
|
margin-top: 4px;
|
|
2310
1720
|
align-self: ${msg.isUser ? "flex-end" : "flex-start"};
|
|
2311
|
-
font-family:
|
|
1721
|
+
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
2312
1722
|
`;
|
|
2313
1723
|
messageWrapper.appendChild(timestamp);
|
|
2314
1724
|
}
|
|
@@ -2352,8 +1762,6 @@ export function createConvaiWidget(container, options) {
|
|
|
2352
1762
|
// Setup event listeners for client state changes
|
|
2353
1763
|
const setupClientListeners = () => {
|
|
2354
1764
|
eventSubscriptions.register((listener) => client.on("stateChange", listener), () => {
|
|
2355
|
-
// Published chat (connectionType "text") has no microphone: never let
|
|
2356
|
-
// a voice-mode transition leave the panel in a mode it cannot honour.
|
|
2357
1765
|
if (client.connectionType === "text") {
|
|
2358
1766
|
if (isVoiceMode) {
|
|
2359
1767
|
isVoiceMode = false;
|
|
@@ -2381,6 +1789,8 @@ export function createConvaiWidget(container, options) {
|
|
|
2381
1789
|
// Reset when not speaking
|
|
2382
1790
|
if (startTime && !client.state.isSpeaking) {
|
|
2383
1791
|
startTime = 0;
|
|
1792
|
+
currentLevels = Array(40).fill(0.05);
|
|
1793
|
+
targetLevels = Array(40).fill(0.05);
|
|
2384
1794
|
}
|
|
2385
1795
|
}
|
|
2386
1796
|
// Auto-collapse when disconnected
|
|
@@ -2393,14 +1803,12 @@ export function createConvaiWidget(container, options) {
|
|
|
2393
1803
|
isVideoVisible = false;
|
|
2394
1804
|
hasEnteredDefaultVoiceMode = false;
|
|
2395
1805
|
updateHeader();
|
|
2396
|
-
|
|
2397
|
-
// mute inside is the one most likely to fail (the room has just
|
|
2398
|
-
// dropped -- that is why we are collapsing).
|
|
2399
|
-
applyVoiceMode();
|
|
1806
|
+
updateVoiceMode();
|
|
2400
1807
|
}
|
|
2401
1808
|
// Auto-enter voice mode on first connection if defaultVoiceMode is true
|
|
2402
1809
|
if (client.state.isConnected &&
|
|
2403
1810
|
defaultVoiceMode &&
|
|
1811
|
+
client.connectionType !== "text" &&
|
|
2404
1812
|
isOpen &&
|
|
2405
1813
|
!hasEnteredDefaultVoiceMode &&
|
|
2406
1814
|
!isVoiceMode) {
|
|
@@ -2409,10 +1817,10 @@ export function createConvaiWidget(container, options) {
|
|
|
2409
1817
|
await client.audioControls.unmuteAudio();
|
|
2410
1818
|
isVoiceMode = true;
|
|
2411
1819
|
hasEnteredDefaultVoiceMode = true;
|
|
2412
|
-
|
|
1820
|
+
updateVoiceMode();
|
|
2413
1821
|
}
|
|
2414
1822
|
catch (error) {
|
|
2415
|
-
|
|
1823
|
+
console.error("Failed to enter voice mode:", error);
|
|
2416
1824
|
}
|
|
2417
1825
|
}, 100);
|
|
2418
1826
|
}
|
|
@@ -2422,9 +1830,9 @@ export function createConvaiWidget(container, options) {
|
|
|
2422
1830
|
eventSubscriptions.register((listener) => client.videoControls.on("videoStateChange", listener), (videoState) => {
|
|
2423
1831
|
if (videoState.isVideoEnabled !== undefined) {
|
|
2424
1832
|
isVideoVisible = videoState.isVideoEnabled;
|
|
2425
|
-
|
|
1833
|
+
updateVoiceMode();
|
|
2426
1834
|
// Update tray button state
|
|
2427
|
-
const videoBtn =
|
|
1835
|
+
const videoBtn = document.getElementById("convai-settings-video-btn");
|
|
2428
1836
|
if (videoBtn) {
|
|
2429
1837
|
const isActive = isVideoVisible;
|
|
2430
1838
|
videoBtn.style.backgroundColor = isActive
|
|
@@ -2432,7 +1840,7 @@ export function createConvaiWidget(container, options) {
|
|
|
2432
1840
|
: "transparent";
|
|
2433
1841
|
videoBtn.style.color = isActive
|
|
2434
1842
|
? "#10b981"
|
|
2435
|
-
:
|
|
1843
|
+
: aeroTheme.colors.text.primary;
|
|
2436
1844
|
// Update Icon - first child is icon container
|
|
2437
1845
|
const iconContainer = videoBtn.querySelector("div");
|
|
2438
1846
|
if (iconContainer) {
|
|
@@ -2446,10 +1854,10 @@ export function createConvaiWidget(container, options) {
|
|
|
2446
1854
|
}
|
|
2447
1855
|
});
|
|
2448
1856
|
// Screen share state change listener
|
|
2449
|
-
client.screenShareControls.on("screenShareStateChange", (screenShareState) => {
|
|
1857
|
+
eventSubscriptions.register((listener) => client.screenShareControls.on("screenShareStateChange", listener), (screenShareState) => {
|
|
2450
1858
|
if (screenShareState.isScreenShareActive !== undefined) {
|
|
2451
1859
|
const isSharing = screenShareState.isScreenShareActive;
|
|
2452
|
-
const shareBtn =
|
|
1860
|
+
const shareBtn = document.getElementById("convai-settings-share-btn");
|
|
2453
1861
|
if (shareBtn) {
|
|
2454
1862
|
const isActive = isSharing;
|
|
2455
1863
|
shareBtn.style.backgroundColor = isActive
|
|
@@ -2457,7 +1865,7 @@ export function createConvaiWidget(container, options) {
|
|
|
2457
1865
|
: "transparent";
|
|
2458
1866
|
shareBtn.style.color = isActive
|
|
2459
1867
|
? "#10b981"
|
|
2460
|
-
:
|
|
1868
|
+
: aeroTheme.colors.text.primary;
|
|
2461
1869
|
// Update Icon - first child is icon container
|
|
2462
1870
|
const iconContainer = shareBtn.querySelector("div");
|
|
2463
1871
|
if (iconContainer) {
|
|
@@ -2474,6 +1882,11 @@ export function createConvaiWidget(container, options) {
|
|
|
2474
1882
|
});
|
|
2475
1883
|
eventSubscriptions.register((listener) => client.on("messagesChange", listener), () => {
|
|
2476
1884
|
updateMessageList();
|
|
1885
|
+
// Call onMessage callback if provided
|
|
1886
|
+
if (onMessage && client.chatMessages.length > 0) {
|
|
1887
|
+
const lastMessage = client.chatMessages[client.chatMessages.length - 1];
|
|
1888
|
+
onMessage(lastMessage);
|
|
1889
|
+
}
|
|
2477
1890
|
});
|
|
2478
1891
|
// Bot ready listener - updates UI when bot becomes ready
|
|
2479
1892
|
eventSubscriptions.register((listener) => client.on("botReady", listener), () => {
|
|
@@ -2483,13 +1896,8 @@ export function createConvaiWidget(container, options) {
|
|
|
2483
1896
|
eventSubscriptions.register((listener) => client.on("connect", listener), () => {
|
|
2484
1897
|
hasConnectedBefore = true;
|
|
2485
1898
|
// Initialize audio renderer on connection
|
|
2486
|
-
// NOTE: the audio elements live under rootElement, so playback ends if
|
|
2487
|
-
// the host *removes or re-parents* its mount container mid-call. Merely
|
|
2488
|
-
// hiding it (display: none / visibility: hidden) does NOT stop
|
|
2489
|
-
// HTMLMediaElement playback in any browser — an earlier version of this
|
|
2490
|
-
// comment claimed otherwise and was wrong.
|
|
2491
1899
|
if (client.room && !audioRenderer) {
|
|
2492
|
-
audioRenderer = new AudioRenderer(client.room
|
|
1900
|
+
audioRenderer = new AudioRenderer(client.room);
|
|
2493
1901
|
}
|
|
2494
1902
|
// Fetch character info
|
|
2495
1903
|
fetchCharacterInfo();
|
|
@@ -2510,127 +1918,25 @@ export function createConvaiWidget(container, options) {
|
|
|
2510
1918
|
onDisconnect();
|
|
2511
1919
|
}
|
|
2512
1920
|
});
|
|
2513
|
-
// `message` carries the row actually updated. That can differ from the
|
|
2514
|
-
// list tail when correlated v2 streams interleave, which is why onMessage
|
|
2515
|
-
// no longer reads the last element of chatMessages.
|
|
2516
|
-
eventSubscriptions.register((listener) => client.on("message", listener), (message) => {
|
|
2517
|
-
const row = message;
|
|
2518
|
-
if (onMessage && row) {
|
|
2519
|
-
onMessage(row);
|
|
2520
|
-
}
|
|
2521
|
-
});
|
|
2522
|
-
// Screen share state change listener
|
|
2523
|
-
eventSubscriptions.register((listener) => client.screenShareControls.on("screenShareStateChange", listener), (screenShareState) => {
|
|
2524
|
-
if (screenShareState.isScreenShareActive !== undefined) {
|
|
2525
|
-
const isSharing = screenShareState.isScreenShareActive;
|
|
2526
|
-
const shareBtn = document.getElementById("convai-settings-share-btn");
|
|
2527
|
-
if (shareBtn) {
|
|
2528
|
-
const isActive = isSharing;
|
|
2529
|
-
shareBtn.style.backgroundColor = isActive
|
|
2530
|
-
? "rgba(16, 185, 129, 0.15)"
|
|
2531
|
-
: "transparent";
|
|
2532
|
-
shareBtn.style.color = isActive
|
|
2533
|
-
? "#10b981"
|
|
2534
|
-
: aeroTheme.colors.text.primary;
|
|
2535
|
-
// Update Icon - first child is icon container
|
|
2536
|
-
const iconContainer = shareBtn.querySelector("div");
|
|
2537
|
-
if (iconContainer) {
|
|
2538
|
-
iconContainer.innerHTML = "";
|
|
2539
|
-
const icon = isActive
|
|
2540
|
-
? Icons.StopScreenShare("md")
|
|
2541
|
-
: Icons.ScreenShare("md");
|
|
2542
|
-
icon.style.width = "18px";
|
|
2543
|
-
icon.style.height = "18px";
|
|
2544
|
-
iconContainer.appendChild(icon);
|
|
2545
|
-
}
|
|
2546
|
-
}
|
|
2547
|
-
}
|
|
2548
|
-
});
|
|
2549
1921
|
};
|
|
2550
1922
|
// Initialize
|
|
2551
1923
|
createDOM();
|
|
2552
1924
|
setupClientListeners();
|
|
2553
|
-
// Paint the connecting overlay's initial state. updateHeader() (which also
|
|
2554
|
-
// calls updateConnectingOverlay()) is otherwise only reached via a client
|
|
2555
|
-
// event or a successful character-info fetch, so a widget constructed
|
|
2556
|
-
// already connected-but-not-ready would show nothing until one of those
|
|
2557
|
-
// fires without this.
|
|
2558
|
-
updateConnectingOverlay();
|
|
2559
1925
|
// Set initial button state
|
|
2560
1926
|
updateSendButton(); // Show voice mode button initially since input is empty
|
|
2561
1927
|
// Set initial voice mode UI if defaultVoiceMode is true
|
|
2562
1928
|
if (defaultVoiceMode) {
|
|
2563
|
-
|
|
1929
|
+
updateVoiceMode();
|
|
2564
1930
|
}
|
|
2565
1931
|
// If already connected, initialize audio renderer and fetch character info
|
|
2566
|
-
// NOTE: same lifetime caveat as the "connect" handler above — the audio
|
|
2567
|
-
// elements are torn down with rootElement if the host detaches it.
|
|
2568
1932
|
if (client.state.isConnected && client.room) {
|
|
2569
|
-
audioRenderer = new AudioRenderer(client.room
|
|
1933
|
+
audioRenderer = new AudioRenderer(client.room);
|
|
2570
1934
|
fetchCharacterInfo();
|
|
2571
1935
|
}
|
|
2572
1936
|
// Return widget instance
|
|
2573
1937
|
const widget = {
|
|
2574
1938
|
element: rootElement,
|
|
2575
1939
|
client: client,
|
|
2576
|
-
// `isOpen` reports *requested* state, which on a warm client is the same
|
|
2577
|
-
// thing as rendered state: `setIsOpen()` runs synchronously. The one case
|
|
2578
|
-
// where they differ is the first open on a disconnected client, where the
|
|
2579
|
-
// panel is committed only after `connect()` resolves; reporting `false`
|
|
2580
|
-
// for those seconds would tell an embed reconciling its own
|
|
2581
|
-
// convai-open/convai-close events that a widget it just opened is closed.
|
|
2582
|
-
// `pendingOpen` is cleared by every cancellation path, so this never
|
|
2583
|
-
// reports open for a request that was called off.
|
|
2584
|
-
get isOpen() {
|
|
2585
|
-
return isOpen || pendingOpen;
|
|
2586
|
-
},
|
|
2587
|
-
// Drives the exact same path the launcher click does: `handleToggle`
|
|
2588
|
-
// (connect-on-first-open, then `setIsOpen(true)`). Idempotent against an
|
|
2589
|
-
// in-flight open as well as an open panel, so repeat calls never start a
|
|
2590
|
-
// second connect.
|
|
2591
|
-
open: () => {
|
|
2592
|
-
if (isOpen || pendingOpen)
|
|
2593
|
-
return;
|
|
2594
|
-
void handleToggle();
|
|
2595
|
-
},
|
|
2596
|
-
// Drives the same path the header's close button does: `handleClose` ->
|
|
2597
|
-
// `setIsOpen(false)`. Also cancels an open that is still waiting on
|
|
2598
|
-
// `connect()`; a redundant `close()` is a no-op.
|
|
2599
|
-
close: () => {
|
|
2600
|
-
if (!isOpen && !pendingOpen)
|
|
2601
|
-
return;
|
|
2602
|
-
handleClose();
|
|
2603
|
-
},
|
|
2604
|
-
toggle: () => {
|
|
2605
|
-
if (isOpen || pendingOpen) {
|
|
2606
|
-
handleClose();
|
|
2607
|
-
}
|
|
2608
|
-
else {
|
|
2609
|
-
void handleToggle();
|
|
2610
|
-
}
|
|
2611
|
-
},
|
|
2612
|
-
// Pushes a synthetic ChatMessage straight onto `client.chatMessages` and
|
|
2613
|
-
// re-renders via the widget's existing `updateMessageList()`. It never
|
|
2614
|
-
// calls a client send method (e.g. `sendUserTextMessage`), so the
|
|
2615
|
-
// message never reaches the character and is never billed as an LLM
|
|
2616
|
-
// turn. The `local-` id keeps it from colliding with a server-issued id.
|
|
2617
|
-
appendMessage: (message) => {
|
|
2618
|
-
// Shared core path, so React and vanilla insert identically. A
|
|
2619
|
-
// hand-rolled IConvaiClient without it falls back to the old local push.
|
|
2620
|
-
if (typeof client.appendMessage === "function") {
|
|
2621
|
-
client.appendMessage(message);
|
|
2622
|
-
}
|
|
2623
|
-
else {
|
|
2624
|
-
const chatMessage = {
|
|
2625
|
-
id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
2626
|
-
type: message.sender === "user" ? "user-llm-text" : "bot-llm-text",
|
|
2627
|
-
content: message.content,
|
|
2628
|
-
timestamp: new Date().toISOString(),
|
|
2629
|
-
};
|
|
2630
|
-
client.chatMessages.push(chatMessage);
|
|
2631
|
-
}
|
|
2632
|
-
updateMessageList();
|
|
2633
|
-
},
|
|
2634
1940
|
destroy: () => {
|
|
2635
1941
|
isDestroyed = true;
|
|
2636
1942
|
// Cleanup audio renderer
|
|
@@ -2639,7 +1945,7 @@ export function createConvaiWidget(container, options) {
|
|
|
2639
1945
|
audioRenderer = null;
|
|
2640
1946
|
}
|
|
2641
1947
|
// Remove event listeners
|
|
2642
|
-
morphingContainer.removeEventListener("click",
|
|
1948
|
+
morphingContainer.removeEventListener("click", handleToggle);
|
|
2643
1949
|
// Remove DOM elements
|
|
2644
1950
|
if (rootElement.parentElement) {
|
|
2645
1951
|
rootElement.remove();
|