@convai/web-sdk 1.8.0-beta.8 → 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 +45 -213
- 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 -8
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -13
- 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/ConvaiChatElement.d.ts +1 -77
- package/dist/embed/ConvaiChatElement.d.ts.map +1 -1
- package/dist/embed/ConvaiChatElement.js +54 -433
- package/dist/embed/ConvaiChatElement.js.map +1 -1
- package/dist/embed/chat-embed-v1.js +35 -136
- 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 +0 -7
- package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
- package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +46 -135
- package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
- package/dist/react/components/rtc-widget/components/UserMessage.js +1 -1
- package/dist/react/components/rtc-widget/components/UserMessage.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 -4
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -8
- 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 +302 -1122
- 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 -6
- package/dist/vanilla/index.d.ts.map +1 -1
- package/dist/vanilla/index.js +1 -5
- 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 -141
- 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 +17 -44
- 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 -60
- package/dist/utils/inlineMarkdown.d.ts.map +0 -1
- package/dist/utils/inlineMarkdown.js +0 -183
- 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, compactUrl } 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,18 +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(
|
|
185
|
-
// Keyframes were injected lazily by the connecting overlay and the settings
|
|
186
|
-
// tray, but a link-preview skeleton can render before either exists — a
|
|
187
|
-
// replayed transcript paints message bubbles immediately. Idempotent.
|
|
188
|
-
injectKeyframes(styleTarget);
|
|
67
|
+
injectGlobalStyles();
|
|
189
68
|
// State
|
|
190
69
|
let isOpen = false;
|
|
191
70
|
let isSettingsOpen = false;
|
|
@@ -201,22 +80,8 @@ export function createConvaiWidget(container, options) {
|
|
|
201
80
|
let hasEnteredDefaultVoiceMode = false;
|
|
202
81
|
/** True after we have connected at least once; used to call reconnect() instead of connect() when opening after disconnect */
|
|
203
82
|
let hasConnectedBefore = false;
|
|
204
|
-
/**
|
|
205
|
-
|
|
206
|
-
* until that open either completes or is cancelled.
|
|
207
|
-
*
|
|
208
|
-
* `isOpen` alone cannot express this: the first open awaits a real
|
|
209
|
-
* `connect()` (0.5-3s in production) and only flips `isOpen` afterwards, so
|
|
210
|
-
* for that whole window the widget is neither open nor idle. Without this
|
|
211
|
-
* flag `close()` sees `isOpen === false` and no-ops -- and the connect then
|
|
212
|
-
* pops the panel open behind the user -- while `toggle()` sees the same
|
|
213
|
-
* `false`, starts a *second* open, and lands on the already-connecting
|
|
214
|
-
* branch that opens immediately. Every entry point below treats
|
|
215
|
-
* `pendingOpen` as "open" for the purpose of deciding what a close/toggle
|
|
216
|
-
* means, and `handleToggle` re-checks it before committing `setIsOpen(true)`
|
|
217
|
-
* so a cancellation during the await is honoured.
|
|
218
|
-
*/
|
|
219
|
-
let pendingOpen = false;
|
|
83
|
+
/** Prevent repeated open clicks from minting multiple one-time publication grants. */
|
|
84
|
+
let isStartingConnection = false;
|
|
220
85
|
/** Prevent an in-flight publication grant request from connecting after teardown. */
|
|
221
86
|
let isDestroyed = false;
|
|
222
87
|
const eventSubscriptions = new WidgetEventSubscriptions(() => isDestroyed);
|
|
@@ -233,13 +98,6 @@ export function createConvaiWidget(container, options) {
|
|
|
233
98
|
let settingsTray;
|
|
234
99
|
let floatingVideo;
|
|
235
100
|
let voiceModeOverlay;
|
|
236
|
-
let connectingOverlay;
|
|
237
|
-
/**
|
|
238
|
-
* Elements captured at creation. IDs are scoped per DOM tree, so
|
|
239
|
-
* document.getElementById() returns null when the widget is mounted
|
|
240
|
-
* inside a shadow root. Every element here is created in this file.
|
|
241
|
-
*/
|
|
242
|
-
const refs = {};
|
|
243
101
|
// Audio Analysis State
|
|
244
102
|
let audioContext = null;
|
|
245
103
|
let analyzer = null;
|
|
@@ -250,31 +108,15 @@ export function createConvaiWidget(container, options) {
|
|
|
250
108
|
const fetchCharacterInfo = async () => {
|
|
251
109
|
if (publishedChatDescriptor)
|
|
252
110
|
return;
|
|
253
|
-
|
|
254
|
-
// uses when building /connect headers -- a client carrying both must
|
|
255
|
-
// behave identically in both places.
|
|
256
|
-
//
|
|
257
|
-
// The two credentials travel in *different* headers: api.convai.com reads
|
|
258
|
-
// an auth token from API-AUTH-TOKEN and an API key from X-API-Key, and
|
|
259
|
-
// answers an API key sent as API-AUTH-TOKEN with `400 Your session has
|
|
260
|
-
// expired.` Because the failure is swallowed below, sending the wrong one
|
|
261
|
-
// showed up only as a header stuck on the fallback name.
|
|
262
|
-
const credential = client.authToken ?? client.apiKey;
|
|
263
|
-
if (!credential || !client.characterId)
|
|
111
|
+
if (!client.apiKey || !client.characterId)
|
|
264
112
|
return;
|
|
265
|
-
const headers = {
|
|
266
|
-
"Content-Type": "application/json",
|
|
267
|
-
};
|
|
268
|
-
if (client.authToken) {
|
|
269
|
-
headers["API-AUTH-TOKEN"] = client.authToken;
|
|
270
|
-
}
|
|
271
|
-
else {
|
|
272
|
-
headers["X-API-Key"] = credential;
|
|
273
|
-
}
|
|
274
113
|
try {
|
|
275
114
|
const response = await fetch("https://api.convai.com/character/get", {
|
|
276
115
|
method: "POST",
|
|
277
|
-
headers
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
"API-AUTH-TOKEN": client.apiKey,
|
|
119
|
+
},
|
|
278
120
|
body: JSON.stringify({ charID: client.characterId }),
|
|
279
121
|
});
|
|
280
122
|
if (response.ok) {
|
|
@@ -298,34 +140,23 @@ export function createConvaiWidget(container, options) {
|
|
|
298
140
|
rootElement = document.createElement("div");
|
|
299
141
|
rootElement.className = "convai-widget";
|
|
300
142
|
rootElement.style.cssText = `
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
143
|
+
position: fixed;
|
|
144
|
+
bottom: 1.5rem;
|
|
145
|
+
right: 1.5rem;
|
|
146
|
+
z-index: ${aeroTheme.zIndex.modal};
|
|
147
|
+
font-family: ${aeroTheme.typography.fontFamily.primary};
|
|
304
148
|
`;
|
|
305
149
|
// Morphing container
|
|
306
150
|
morphingContainer = document.createElement("div");
|
|
307
|
-
// `part="launcher"` is precise only while collapsed: this same element
|
|
308
|
-
// morphs into the open panel (400x600, capped to the viewport — see PANEL_MAX_W)
|
|
309
|
-
// and carries that panel's background,
|
|
310
|
-
// radius and shadow. See the `part="panel"` call site below for the full
|
|
311
|
-
// semantics a host needs to know.
|
|
312
|
-
morphingContainer.setAttribute("part", "launcher");
|
|
313
|
-
// NOTE on border-radius: deliberately NOT themable. `--convai-radius`
|
|
314
|
-
// drives the open panel only. One token cannot serve both geometries --
|
|
315
|
-
// `--convai-radius: 8px` would squircle the circular launcher, and `50%`
|
|
316
|
-
// would turn the panel into an ellipse. A dedicated
|
|
317
|
-
// `--convai-launcher-radius` can be added additively later if wanted.
|
|
318
151
|
morphingContainer.style.cssText = `
|
|
319
152
|
position: relative;
|
|
320
|
-
width:
|
|
321
|
-
height:
|
|
322
|
-
|
|
323
|
-
max-height: ${PANEL_MAX_H};
|
|
324
|
-
background: var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop});
|
|
153
|
+
width: 4rem;
|
|
154
|
+
height: 4rem;
|
|
155
|
+
background: ${aeroTheme.colors.glass.backdrop};
|
|
325
156
|
backdrop-filter: ${aeroTheme.glass.backdrop};
|
|
326
157
|
border: ${aeroTheme.glass.border};
|
|
327
158
|
border-radius: 50%;
|
|
328
|
-
box-shadow:
|
|
159
|
+
box-shadow: ${aeroTheme.shadows.glass};
|
|
329
160
|
transition: all 0.3s ease-in-out;
|
|
330
161
|
overflow: hidden;
|
|
331
162
|
display: flex;
|
|
@@ -346,39 +177,10 @@ export function createConvaiWidget(container, options) {
|
|
|
346
177
|
transform: scale(1);
|
|
347
178
|
`;
|
|
348
179
|
const convaiLogo = Icons.ConvaiLogo("xl", "idle");
|
|
349
|
-
convaiLogo.style.color =
|
|
180
|
+
convaiLogo.style.color = aeroTheme.colors.convai.light;
|
|
350
181
|
buttonContent.appendChild(convaiLogo);
|
|
351
182
|
// Chat content
|
|
352
183
|
chatContent = document.createElement("div");
|
|
353
|
-
// ----------------------------------------------------------------------
|
|
354
|
-
// `launcher` / `panel` part semantics. Read before writing theming docs.
|
|
355
|
-
//
|
|
356
|
-
// This widget morphs rather than swapping trees: `morphingContainer` IS
|
|
357
|
-
// the 64px circle AND the open panel, animating between them (see
|
|
358
|
-
// setIsOpen). It therefore owns every painted surface property of both
|
|
359
|
-
// states -- background, border, border-radius, box-shadow. `buttonContent`
|
|
360
|
-
// and `chatContent` are transparent `inset: 0` overlays that only
|
|
361
|
-
// cross-fade their contents.
|
|
362
|
-
//
|
|
363
|
-
// So, precisely:
|
|
364
|
-
// ::part(launcher) -> morphingContainer. Paints the collapsed circle
|
|
365
|
-
// AND the open panel; there is no rule that hits
|
|
366
|
-
// only one. This is the surface part.
|
|
367
|
-
// ::part(panel) -> chatContent. The open state's *content layer*:
|
|
368
|
-
// useful for padding, colour, font, opacity. It has
|
|
369
|
-
// no background, no border and no radius of its
|
|
370
|
-
// own, so `::part(panel){border-radius}` does
|
|
371
|
-
// nothing -- set radius via ::part(launcher) or
|
|
372
|
-
// `--convai-radius` (which is panel-scoped).
|
|
373
|
-
//
|
|
374
|
-
// Assessed and deliberately not "fixed": moving `launcher` onto
|
|
375
|
-
// buttonContent would make `::part(launcher){background}` state-specific
|
|
376
|
-
// but would silently break `border-radius` and `box-shadow` on it, since
|
|
377
|
-
// buttonContent is clipped by the container's own radius + overflow:
|
|
378
|
-
// hidden. Correcting this properly needs a separate launcher element
|
|
379
|
-
// instead of a morph -- a rendering change, out of scope here.
|
|
380
|
-
// ----------------------------------------------------------------------
|
|
381
|
-
chatContent.setAttribute("part", "panel");
|
|
382
184
|
chatContent.style.cssText = `
|
|
383
185
|
position: absolute;
|
|
384
186
|
inset: 0;
|
|
@@ -399,19 +201,11 @@ export function createConvaiWidget(container, options) {
|
|
|
399
201
|
contentElement.style.cssText = `
|
|
400
202
|
flex: 1;
|
|
401
203
|
overflow-y: auto;
|
|
402
|
-
padding:
|
|
204
|
+
padding: 1rem;
|
|
403
205
|
background: transparent;
|
|
404
|
-
position: relative;
|
|
405
206
|
`;
|
|
406
207
|
messageListElement = createMessageList();
|
|
407
208
|
contentElement.appendChild(messageListElement);
|
|
408
|
-
// Connecting overlay -- shown until the bot is ready. Appended last, not
|
|
409
|
-
// to match DOM order (position: absolute + z-index: 100, set inside
|
|
410
|
-
// createConnectingOverlay, is what keeps it on top regardless of source
|
|
411
|
-
// order) but so inserting it doesn't shift every existing sibling's
|
|
412
|
-
// index-based path in the inline-style-parity fixture.
|
|
413
|
-
connectingOverlay = createConnectingOverlay();
|
|
414
|
-
contentElement.appendChild(connectingOverlay);
|
|
415
209
|
// Footer
|
|
416
210
|
footerElement = createFooter();
|
|
417
211
|
chatContent.appendChild(headerElement);
|
|
@@ -428,31 +222,9 @@ export function createConvaiWidget(container, options) {
|
|
|
428
222
|
container.appendChild(floatingVideo);
|
|
429
223
|
container.appendChild(rootElement);
|
|
430
224
|
// Event listeners
|
|
431
|
-
morphingContainer.addEventListener("click",
|
|
225
|
+
morphingContainer.addEventListener("click", handleToggle);
|
|
432
226
|
};
|
|
433
227
|
// Create Voice Mode Overlay
|
|
434
|
-
/**
|
|
435
|
-
* Voice mode, matched to the React overlay (VoiceModeOverlay.tsx) rather
|
|
436
|
-
* than to what this file used to draw.
|
|
437
|
-
*
|
|
438
|
-
* The two had diverged badly: React centres the character's avatar inside
|
|
439
|
-
* two counter-rotating liquid-gradient layers and paints the user's
|
|
440
|
-
* microphone as three overlapping sine waves on a canvas, while this file
|
|
441
|
-
* drew forty grey vertical bars and no avatar at all. Same product, two
|
|
442
|
-
* unrelated screens.
|
|
443
|
-
*
|
|
444
|
-
* What is reproduced here, value for value: the 120px avatar with its 3px
|
|
445
|
-
* rgba(255,255,255,0.2) ring and letter fallback, the 126px conic and
|
|
446
|
-
* radial gradient layers (20s clockwise / 15s anticlockwise, blurred 8px
|
|
447
|
-
* and 6px), the 250x80 canvas carrying three waves, and the status text at
|
|
448
|
-
* 16px/600 over 13px with React's wording.
|
|
449
|
-
*
|
|
450
|
-
* The one deliberate omission is the `AudioVisualizer` ring that React
|
|
451
|
-
* fades in behind the avatar while the character speaks: it is a 364-line
|
|
452
|
-
* component wired directly to a LiveKit `Room`, and porting it is its own
|
|
453
|
-
* change. The avatar still breathes while the character speaks, so the
|
|
454
|
-
* speaking state reads without it.
|
|
455
|
-
*/
|
|
456
228
|
const createVoiceModeOverlay = () => {
|
|
457
229
|
const overlay = document.createElement("div");
|
|
458
230
|
overlay.style.cssText = `
|
|
@@ -461,383 +233,171 @@ export function createConvaiWidget(container, options) {
|
|
|
461
233
|
left: 50%;
|
|
462
234
|
transform: translate(-50%, -50%);
|
|
463
235
|
text-align: center;
|
|
464
|
-
padding:
|
|
236
|
+
padding: 1rem;
|
|
465
237
|
z-index: 10;
|
|
466
238
|
pointer-events: auto;
|
|
467
239
|
display: none;
|
|
468
240
|
flex-direction: column;
|
|
469
241
|
align-items: center;
|
|
470
|
-
gap:
|
|
471
|
-
`;
|
|
472
|
-
// --- Avatar, with the two rotating gradient layers behind it ----------
|
|
473
|
-
const avatarWrap = document.createElement("div");
|
|
474
|
-
avatarWrap.style.cssText = `
|
|
475
|
-
position: relative;
|
|
476
|
-
display: flex;
|
|
477
|
-
align-items: center;
|
|
478
|
-
justify-content: center;
|
|
479
|
-
isolation: isolate;
|
|
480
|
-
`;
|
|
481
|
-
const avatarStack = document.createElement("div");
|
|
482
|
-
avatarStack.className = "convai-voice-avatar";
|
|
483
|
-
refs.voiceModeAvatar = avatarStack;
|
|
484
|
-
avatarStack.style.cssText = `
|
|
485
|
-
position: relative;
|
|
486
|
-
z-index: 10;
|
|
487
|
-
border-radius: 50%;
|
|
488
|
-
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
489
|
-
background: white;
|
|
490
|
-
display: flex;
|
|
491
|
-
align-items: center;
|
|
492
|
-
justify-content: center;
|
|
493
|
-
`;
|
|
494
|
-
const accent = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
|
|
495
|
-
const layer1 = document.createElement("div");
|
|
496
|
-
layer1.className = "convai-voice-gradient";
|
|
497
|
-
layer1.style.cssText = `
|
|
498
|
-
position: absolute;
|
|
499
|
-
width: 126px;
|
|
500
|
-
height: 126px;
|
|
501
|
-
border-radius: 50%;
|
|
502
|
-
background: conic-gradient(
|
|
503
|
-
from 0deg,
|
|
504
|
-
${aeroTheme.colors.convai.light}00 0%,
|
|
505
|
-
${aeroTheme.colors.convai.light}40 25%,
|
|
506
|
-
${aeroTheme.colors.convai.dark}60 50%,
|
|
507
|
-
${aeroTheme.colors.convai.light}40 75%,
|
|
508
|
-
${aeroTheme.colors.convai.light}00 100%
|
|
509
|
-
);
|
|
510
|
-
opacity: 0.8;
|
|
511
|
-
filter: blur(8px);
|
|
512
|
-
z-index: -2;
|
|
513
|
-
animation: convaiVoiceSpin 20s linear infinite;
|
|
514
|
-
`;
|
|
515
|
-
const layer2 = document.createElement("div");
|
|
516
|
-
layer2.className = "convai-voice-gradient";
|
|
517
|
-
layer2.style.cssText = `
|
|
518
|
-
position: absolute;
|
|
519
|
-
width: 126px;
|
|
520
|
-
height: 126px;
|
|
521
|
-
border-radius: 50%;
|
|
522
|
-
background:
|
|
523
|
-
radial-gradient(ellipse 80% 100% at 30% 40%,
|
|
524
|
-
${aeroTheme.colors.convai.light}50,
|
|
525
|
-
${aeroTheme.colors.convai.light}30 40%,
|
|
526
|
-
transparent 70%),
|
|
527
|
-
radial-gradient(ellipse 80% 100% at 70% 60%,
|
|
528
|
-
${aeroTheme.colors.convai.dark}50,
|
|
529
|
-
${aeroTheme.colors.convai.dark}30 40%,
|
|
530
|
-
transparent 70%);
|
|
531
|
-
opacity: 0.9;
|
|
532
|
-
filter: blur(6px);
|
|
533
|
-
z-index: -1;
|
|
534
|
-
animation: convaiVoiceSpinReverse 15s linear infinite;
|
|
242
|
+
gap: 1.5rem;
|
|
535
243
|
`;
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
position: relative;
|
|
541
|
-
border-radius: 50%;
|
|
542
|
-
overflow: hidden;
|
|
543
|
-
border: 3px solid rgba(255, 255, 255, 0.2);
|
|
544
|
-
width: 120px;
|
|
545
|
-
height: 120px;
|
|
244
|
+
// Bars Container
|
|
245
|
+
const barsContainer = document.createElement("div");
|
|
246
|
+
barsContainer.id = "voice-bars-container";
|
|
247
|
+
barsContainer.style.cssText = `
|
|
546
248
|
display: flex;
|
|
547
249
|
align-items: center;
|
|
548
250
|
justify-content: center;
|
|
251
|
+
gap: 2px;
|
|
252
|
+
height: 80px;
|
|
253
|
+
max-width: 300px;
|
|
549
254
|
`;
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
561
271
|
const statusContainer = document.createElement("div");
|
|
562
272
|
const statusTitle = document.createElement("div");
|
|
563
273
|
statusTitle.id = "voice-mode-title";
|
|
564
|
-
refs.voiceModeTitle = statusTitle;
|
|
565
274
|
statusTitle.style.cssText = `
|
|
566
|
-
font-size:
|
|
567
|
-
font-weight:
|
|
568
|
-
color:
|
|
569
|
-
margin-bottom:
|
|
275
|
+
font-size: 14px;
|
|
276
|
+
font-weight: 500;
|
|
277
|
+
color: ${aeroTheme.colors.text.primary};
|
|
278
|
+
margin-bottom: 0.5rem;
|
|
570
279
|
`;
|
|
571
|
-
statusTitle.textContent = "Voice Mode";
|
|
280
|
+
statusTitle.textContent = "Voice Only Mode";
|
|
572
281
|
const statusSubtitle = document.createElement("div");
|
|
573
282
|
statusSubtitle.id = "voice-mode-subtitle";
|
|
574
|
-
refs.voiceModeSubtitle = statusSubtitle;
|
|
575
283
|
statusSubtitle.style.cssText = `
|
|
576
|
-
font-size:
|
|
284
|
+
font-size: 12px;
|
|
577
285
|
color: ${aeroTheme.colors.text.secondary};
|
|
578
286
|
`;
|
|
579
|
-
statusSubtitle.textContent = "
|
|
580
|
-
statusContainer.
|
|
287
|
+
statusSubtitle.textContent = "Press and hold the microphone to talk";
|
|
288
|
+
statusContainer.appendChild(statusTitle);
|
|
289
|
+
statusContainer.appendChild(statusSubtitle);
|
|
581
290
|
overlay.appendChild(statusContainer);
|
|
582
|
-
void accent;
|
|
583
291
|
return overlay;
|
|
584
292
|
};
|
|
585
|
-
/**
|
|
586
|
-
* Fills the voice-mode avatar from the character info fetch, or falls back
|
|
587
|
-
* to the character's initial the way React does. Called from
|
|
588
|
-
* updateHeader(), so it tracks a late `fetchCharacterInfo()` rather than
|
|
589
|
-
* being fixed at construction.
|
|
590
|
-
*/
|
|
591
|
-
const updateVoiceModeAvatar = () => {
|
|
592
|
-
const clip = refs.voiceModeAvatarClip;
|
|
593
|
-
if (!clip)
|
|
594
|
-
return;
|
|
595
|
-
const wanted = characterImage || `letter:${characterName}`;
|
|
596
|
-
if (clip.dataset.filledWith === wanted)
|
|
597
|
-
return;
|
|
598
|
-
clip.dataset.filledWith = wanted;
|
|
599
|
-
clip.replaceChildren();
|
|
600
|
-
if (characterImage) {
|
|
601
|
-
const img = document.createElement("img");
|
|
602
|
-
img.src = characterImage;
|
|
603
|
-
img.alt = characterName;
|
|
604
|
-
img.style.cssText = `width: 120px; height: 120px; object-fit: cover; display: block;`;
|
|
605
|
-
clip.appendChild(img);
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
const letter = document.createElement("div");
|
|
609
|
-
letter.style.cssText = `
|
|
610
|
-
width: 120px;
|
|
611
|
-
height: 120px;
|
|
612
|
-
background-color: ${aeroTheme.colors.convai.light}20;
|
|
613
|
-
display: flex;
|
|
614
|
-
align-items: center;
|
|
615
|
-
justify-content: center;
|
|
616
|
-
font-size: 48px;
|
|
617
|
-
font-weight: 600;
|
|
618
|
-
color: var(--convai-accent, ${aeroTheme.colors.convai.light});
|
|
619
|
-
`;
|
|
620
|
-
letter.textContent = characterName[0]?.toUpperCase() || "C";
|
|
621
|
-
clip.appendChild(letter);
|
|
622
|
-
};
|
|
623
|
-
// Create Connecting Overlay -- matches React's inline AnimatePresence
|
|
624
|
-
// block exactly (ConvaiWidget.tsx:681-737): shown whenever
|
|
625
|
-
// `isConnected && !isBotReady`, covering just the content area (not the
|
|
626
|
-
// header/footer) so the chrome stays interactive while the character
|
|
627
|
-
// joins. Persistent in the DOM like voiceModeOverlay above -- toggled via
|
|
628
|
-
// opacity/pointer-events rather than mounted/unmounted, since this file has
|
|
629
|
-
// no AnimatePresence-style exit-animation machinery. Visibility is driven
|
|
630
|
-
// by updateConnectingOverlay().
|
|
631
|
-
const createConnectingOverlay = () => {
|
|
632
|
-
const overlay = document.createElement("div");
|
|
633
|
-
overlay.id = "convai-connecting-overlay";
|
|
634
|
-
overlay.style.cssText = `
|
|
635
|
-
position: absolute;
|
|
636
|
-
inset: 0;
|
|
637
|
-
z-index: 100;
|
|
638
|
-
display: flex;
|
|
639
|
-
flex-direction: column;
|
|
640
|
-
align-items: center;
|
|
641
|
-
justify-content: center;
|
|
642
|
-
gap: 16px;
|
|
643
|
-
background: rgba(255, 255, 255, 0.95);
|
|
644
|
-
opacity: 0;
|
|
645
|
-
pointer-events: none;
|
|
646
|
-
transition: opacity 0.3s;
|
|
647
|
-
`;
|
|
648
|
-
// Keyframes live in the shadow-safe injector (styles.ts), not
|
|
649
|
-
// document.head -- @keyframes defined there do not resolve for shadow
|
|
650
|
-
// content. Idempotent; also called from createSettingsTray().
|
|
651
|
-
injectKeyframes(styleTarget);
|
|
652
|
-
// Spinning logo: a full turn every 2s, linear, infinite.
|
|
653
|
-
const spinWrapper = document.createElement("div");
|
|
654
|
-
spinWrapper.className = "convai-connecting-spin";
|
|
655
|
-
spinWrapper.style.cssText = `
|
|
656
|
-
display: inline-flex;
|
|
657
|
-
animation: convaiConnectingSpin 2s linear infinite;
|
|
658
|
-
`;
|
|
659
|
-
const logo = Icons.ConvaiLogo("xl", "connecting");
|
|
660
|
-
// Themable, like every other accent usage in this file (see
|
|
661
|
-
// theming.test.ts) -- var() with the exact literal React passes
|
|
662
|
-
// (`aeroTheme.colors.convai.light`) as the fallback, so an unset
|
|
663
|
-
// --convai-accent renders identically to the React value.
|
|
664
|
-
logo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
|
|
665
|
-
spinWrapper.appendChild(logo);
|
|
666
|
-
overlay.appendChild(spinWrapper);
|
|
667
|
-
// Pulsing "Connecting to {characterName}..." label.
|
|
668
|
-
const label = document.createElement("div");
|
|
669
|
-
label.id = "convai-connecting-text";
|
|
670
|
-
label.className = "convai-connecting-pulse";
|
|
671
|
-
label.style.cssText = `
|
|
672
|
-
font-size: 14px;
|
|
673
|
-
font-weight: 500;
|
|
674
|
-
color: ${aeroTheme.colors.text.secondary};
|
|
675
|
-
animation: convaiConnectingPulse 2s ease-in-out infinite;
|
|
676
|
-
`;
|
|
677
|
-
refs.connectingLabel = label;
|
|
678
|
-
overlay.appendChild(label);
|
|
679
|
-
return overlay;
|
|
680
|
-
};
|
|
681
|
-
/** Shows/hides the connecting overlay and refreshes its label. Called
|
|
682
|
-
* from updateHeader() so every existing call site (stateChange, botReady,
|
|
683
|
-
* connect/disconnect via stateChange, character-info fetch, ...) stays in
|
|
684
|
-
* sync automatically. */
|
|
685
|
-
const updateConnectingOverlay = () => {
|
|
686
|
-
if (!connectingOverlay)
|
|
687
|
-
return;
|
|
688
|
-
const label = refs.connectingLabel;
|
|
689
|
-
if (label) {
|
|
690
|
-
label.textContent = `Connecting to ${characterName}...`;
|
|
691
|
-
}
|
|
692
|
-
// `isConnecting` matters as much as `isConnected` here. The panel opens
|
|
693
|
-
// on the click that *starts* the connect, so between that click and the
|
|
694
|
-
// transport coming up (a real network round trip — ~3s against the live
|
|
695
|
-
// service) `isConnected` is still false. Gating on it alone left the
|
|
696
|
-
// whole of that window with no overlay at all: an empty message area and
|
|
697
|
-
// a nameless header, i.e. the widget looked broken for exactly as long as
|
|
698
|
-
// it was doing the one thing the user asked for. Then the label appeared
|
|
699
|
-
// *after* the transport was already up, which reads backwards.
|
|
700
|
-
//
|
|
701
|
-
// The embed makes this the normal path rather than an edge case: its
|
|
702
|
-
// launcher click opens the panel immediately by design, so the pre-connect
|
|
703
|
-
// window is always visible to the user.
|
|
704
|
-
const shouldShow = (client.state.isConnected || client.state.isConnecting) && !client.isBotReady;
|
|
705
|
-
connectingOverlay.style.opacity = shouldShow ? "1" : "0";
|
|
706
|
-
connectingOverlay.style.pointerEvents = shouldShow ? "auto" : "none";
|
|
707
|
-
};
|
|
708
293
|
// Audio Analysis State for Voice Mode
|
|
709
294
|
let audioLevels = Array(40).fill(0);
|
|
295
|
+
let targetLevels = Array(40).fill(0.05);
|
|
296
|
+
let currentLevels = Array(40).fill(0.05);
|
|
710
297
|
let startTime = 0;
|
|
711
|
-
|
|
712
|
-
* Paints the three overlapping sine waves React draws for the user's
|
|
713
|
-
* microphone (VoiceModeOverlay.tsx's `drawWaves`), including its two
|
|
714
|
-
* performance guards, which matter more here than there: this canvas sits
|
|
715
|
-
* over whatever the host page is rendering, and every repaint forces the
|
|
716
|
-
* compositor to re-blend the whole panel. So it runs at 30fps rather than
|
|
717
|
-
* 60, and stops entirely while the input is silent — the static line is
|
|
718
|
-
* already on the canvas at that point.
|
|
719
|
-
*/
|
|
720
|
-
let waveFrameParity = 0;
|
|
721
|
-
let waveWasSilent = false;
|
|
722
|
-
const drawWaves = () => {
|
|
723
|
-
const canvas = refs.voiceModeCanvas;
|
|
724
|
-
if (!canvas)
|
|
725
|
-
return;
|
|
726
|
-
waveFrameParity ^= 1;
|
|
727
|
-
if (waveFrameParity)
|
|
728
|
-
return;
|
|
729
|
-
let sum = 0;
|
|
730
|
-
for (let i = 0; i < audioLevels.length; i++)
|
|
731
|
-
sum += audioLevels[i];
|
|
732
|
-
const silent = sum / audioLevels.length < 0.012;
|
|
733
|
-
if (silent && waveWasSilent)
|
|
734
|
-
return;
|
|
735
|
-
waveWasSilent = silent;
|
|
736
|
-
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
737
|
-
if (canvas.width !== 250 * dpr) {
|
|
738
|
-
canvas.width = 250 * dpr;
|
|
739
|
-
canvas.height = 80 * dpr;
|
|
740
|
-
}
|
|
741
|
-
const ctx = canvas.getContext("2d");
|
|
742
|
-
if (!ctx)
|
|
743
|
-
return;
|
|
744
|
-
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
745
|
-
ctx.clearRect(0, 0, 250, 80);
|
|
746
|
-
const centerY = 40;
|
|
747
|
-
const waveWidth = 250;
|
|
748
|
-
const segments = 40;
|
|
749
|
-
const avgLevel = sum / audioLevels.length;
|
|
750
|
-
const waveColors = [
|
|
751
|
-
aeroTheme.colors.convai.light,
|
|
752
|
-
aeroTheme.colors.convai.light,
|
|
753
|
-
"#34d399",
|
|
754
|
-
];
|
|
755
|
-
ctx.lineCap = "round";
|
|
756
|
-
ctx.lineJoin = "round";
|
|
757
|
-
for (let waveIndex = 2; waveIndex >= 0; waveIndex--) {
|
|
758
|
-
ctx.beginPath();
|
|
759
|
-
for (let i = 0; i <= segments; i++) {
|
|
760
|
-
const x = (i / segments) * waveWidth;
|
|
761
|
-
const levelIndex = Math.floor((i / segments) * (audioLevels.length - 1));
|
|
762
|
-
const level = audioLevels[levelIndex] || 0;
|
|
763
|
-
const phase = (Date.now() / 500 + i * 0.2 + waveIndex * 1.5) % (Math.PI * 2);
|
|
764
|
-
const baseAmplitude = avgLevel > 0.05 ? 2 + waveIndex * 1.5 : 0;
|
|
765
|
-
const amplitude = baseAmplitude + level * (25 - baseAmplitude);
|
|
766
|
-
const y = centerY + Math.sin(phase) * amplitude;
|
|
767
|
-
if (i === 0)
|
|
768
|
-
ctx.moveTo(x, y);
|
|
769
|
-
else
|
|
770
|
-
ctx.lineTo(x, y);
|
|
771
|
-
}
|
|
772
|
-
ctx.strokeStyle = waveColors[waveIndex];
|
|
773
|
-
ctx.globalAlpha = 0.18 * (0.3 + avgLevel);
|
|
774
|
-
ctx.lineWidth = 2.5 - waveIndex * 0.4 + 8 * avgLevel + 3;
|
|
775
|
-
ctx.stroke();
|
|
776
|
-
ctx.globalAlpha = 0.95 - waveIndex * 0.15;
|
|
777
|
-
ctx.lineWidth = 2.5 - waveIndex * 0.4;
|
|
778
|
-
ctx.stroke();
|
|
779
|
-
}
|
|
780
|
-
ctx.globalAlpha = 1;
|
|
781
|
-
};
|
|
782
|
-
/**
|
|
783
|
-
* The voice-mode frame loop. Keeps the name `updateAudioBars` because
|
|
784
|
-
* every call site refers to it; what it drives is now the avatar, the
|
|
785
|
-
* canvas and the status text rather than forty bars.
|
|
786
|
-
*/
|
|
298
|
+
// Audio Analysis Logic
|
|
787
299
|
const updateAudioBars = () => {
|
|
788
300
|
if (!voiceModeOverlay)
|
|
789
301
|
return;
|
|
302
|
+
const bars = voiceModeOverlay.querySelectorAll(".voice-bar");
|
|
790
303
|
const isTalking = client.state.isSpeaking;
|
|
791
|
-
const isListening = client.state.isListening;
|
|
792
|
-
const
|
|
793
|
-
//
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
}
|
|
801
|
-
//
|
|
802
|
-
|
|
803
|
-
const
|
|
804
|
-
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");
|
|
805
317
|
if (title) {
|
|
806
318
|
title.textContent = isTalking
|
|
807
|
-
?
|
|
808
|
-
: isListening
|
|
319
|
+
? "Character Speaking..."
|
|
320
|
+
: isListening
|
|
809
321
|
? "Listening..."
|
|
810
|
-
: "Voice Mode";
|
|
322
|
+
: "Voice Only Mode";
|
|
811
323
|
}
|
|
812
324
|
if (subtitle) {
|
|
813
325
|
subtitle.textContent =
|
|
814
|
-
isListening ||
|
|
326
|
+
isListening || isTalking
|
|
327
|
+
? "Audio active"
|
|
328
|
+
: "Press and hold the microphone to talk";
|
|
815
329
|
}
|
|
816
|
-
|
|
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
|
|
817
333
|
// @ts-ignore - TypeScript strict mode issue with Uint8Array type
|
|
818
334
|
analyzer.getByteTimeDomainData(dataArray);
|
|
335
|
+
// Calculate RMS (Root Mean Square) for volume
|
|
819
336
|
let sum = 0;
|
|
820
337
|
for (let i = 0; i < dataArray.length; i++) {
|
|
821
|
-
const normalized = (dataArray[i] - 128) / 128;
|
|
338
|
+
const normalized = (dataArray[i] - 128) / 128; // Center around 0
|
|
822
339
|
sum += normalized * normalized;
|
|
823
340
|
}
|
|
824
341
|
const rms = Math.sqrt(sum / dataArray.length);
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
|
828
350
|
const wavePhase = Date.now() / 300 + position * Math.PI * 2;
|
|
829
|
-
const waveVariation = Math.sin(wavePhase) * 0.15 + 0.85;
|
|
830
|
-
|
|
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
|
+
});
|
|
831
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
|
+
});
|
|
832
394
|
}
|
|
833
395
|
else {
|
|
834
|
-
//
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
audioLevels[i] = 0;
|
|
396
|
+
// Reset to idle state
|
|
397
|
+
bars.forEach((bar) => {
|
|
398
|
+
bar.style.height = "15px";
|
|
399
|
+
});
|
|
839
400
|
}
|
|
840
|
-
drawWaves();
|
|
841
401
|
rafId = requestAnimationFrame(updateAudioBars);
|
|
842
402
|
};
|
|
843
403
|
const startAudioAnalysis = async () => {
|
|
@@ -859,15 +419,7 @@ export function createConvaiWidget(container, options) {
|
|
|
859
419
|
updateAudioBars();
|
|
860
420
|
}
|
|
861
421
|
catch (e) {
|
|
862
|
-
|
|
863
|
-
// of client.audioControls (it's a separate analyser-only stream, not
|
|
864
|
-
// routed through AudioManager), but a denial here is the identical
|
|
865
|
-
// failure mode -- most commonly a mid-session permission revocation,
|
|
866
|
-
// since by the time voice mode is entered the mic has usually already
|
|
867
|
-
// been granted via unmuteAudio(). Same helper, same channel, so a
|
|
868
|
-
// consumer sees one consistent signal regardless of which internal
|
|
869
|
-
// path acquired the mic.
|
|
870
|
-
reportMicFailure(client, "Audio analysis setup failed:", e);
|
|
422
|
+
console.error("Audio analysis setup failed:", e);
|
|
871
423
|
}
|
|
872
424
|
};
|
|
873
425
|
const stopAudioAnalysis = () => {
|
|
@@ -884,14 +436,13 @@ export function createConvaiWidget(container, options) {
|
|
|
884
436
|
// Create header
|
|
885
437
|
const createHeader = () => {
|
|
886
438
|
const header = document.createElement("div");
|
|
887
|
-
header.setAttribute("part", "header");
|
|
888
439
|
header.style.cssText = `
|
|
889
440
|
display: flex;
|
|
890
441
|
align-items: center;
|
|
891
442
|
justify-content: space-between;
|
|
892
|
-
padding:
|
|
443
|
+
padding: 1rem;
|
|
893
444
|
border-bottom: 1px solid ${aeroTheme.colors.neutral[200]};
|
|
894
|
-
background:
|
|
445
|
+
background: white;
|
|
895
446
|
position: relative;
|
|
896
447
|
`;
|
|
897
448
|
// Close button on the left
|
|
@@ -899,10 +450,10 @@ export function createConvaiWidget(container, options) {
|
|
|
899
450
|
const chevronIcon = Icons.ChevronDown("md");
|
|
900
451
|
closeButton.appendChild(chevronIcon);
|
|
901
452
|
closeButton.style.cssText = `
|
|
902
|
-
font-size:
|
|
453
|
+
font-size: 1.25rem;
|
|
903
454
|
color: ${aeroTheme.colors.text.secondary};
|
|
904
455
|
cursor: pointer;
|
|
905
|
-
padding:
|
|
456
|
+
padding: 0.25rem;
|
|
906
457
|
transition: ${aeroTheme.transitions.fast};
|
|
907
458
|
background: transparent;
|
|
908
459
|
border: none;
|
|
@@ -919,22 +470,21 @@ export function createConvaiWidget(container, options) {
|
|
|
919
470
|
transform: translateX(-50%);
|
|
920
471
|
display: flex;
|
|
921
472
|
align-items: center;
|
|
922
|
-
gap:
|
|
473
|
+
gap: 0.5rem;
|
|
923
474
|
font-size: ${aeroTheme.typography.fontSize.base};
|
|
924
475
|
font-weight: ${aeroTheme.typography.fontWeight.semibold};
|
|
925
|
-
color:
|
|
476
|
+
color: ${aeroTheme.colors.text.primary};
|
|
926
477
|
`;
|
|
927
478
|
titleSection.id = "convai-widget-title";
|
|
928
|
-
refs.widgetTitle = titleSection;
|
|
929
479
|
// Settings button on the right
|
|
930
480
|
const settingsButton = document.createElement("button");
|
|
931
481
|
const moreIcon = Icons.MoreVertical("md");
|
|
932
482
|
settingsButton.appendChild(moreIcon);
|
|
933
483
|
settingsButton.style.cssText = `
|
|
934
|
-
font-size:
|
|
484
|
+
font-size: 1.5rem;
|
|
935
485
|
color: ${aeroTheme.colors.text.secondary};
|
|
936
486
|
cursor: pointer;
|
|
937
|
-
padding:
|
|
487
|
+
padding: 0.25rem;
|
|
938
488
|
transition: ${aeroTheme.transitions.fast};
|
|
939
489
|
background: transparent;
|
|
940
490
|
border: none;
|
|
@@ -951,7 +501,7 @@ export function createConvaiWidget(container, options) {
|
|
|
951
501
|
};
|
|
952
502
|
// Update header with character info
|
|
953
503
|
const updateHeader = () => {
|
|
954
|
-
const titleSection =
|
|
504
|
+
const titleSection = document.getElementById("convai-widget-title");
|
|
955
505
|
if (!titleSection)
|
|
956
506
|
return;
|
|
957
507
|
titleSection.innerHTML = "";
|
|
@@ -960,8 +510,8 @@ export function createConvaiWidget(container, options) {
|
|
|
960
510
|
img.src = characterImage;
|
|
961
511
|
img.alt = characterName;
|
|
962
512
|
img.style.cssText = `
|
|
963
|
-
width:
|
|
964
|
-
height:
|
|
513
|
+
width: 1.5rem;
|
|
514
|
+
height: 1.5rem;
|
|
965
515
|
border-radius: 50%;
|
|
966
516
|
object-fit: cover;
|
|
967
517
|
border: 1.5px solid ${getBotStatusColor().color};
|
|
@@ -991,7 +541,7 @@ export function createConvaiWidget(container, options) {
|
|
|
991
541
|
padding: 0;
|
|
992
542
|
display: inline-flex;
|
|
993
543
|
align-items: center;
|
|
994
|
-
margin-left:
|
|
544
|
+
margin-left: 0.5rem;
|
|
995
545
|
outline: none;
|
|
996
546
|
transition: transform 0.1s ease-out;
|
|
997
547
|
`;
|
|
@@ -1012,7 +562,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1012
562
|
voiceBadge.textContent = "VOICE";
|
|
1013
563
|
voiceBadge.style.cssText = `
|
|
1014
564
|
font-size: 10px;
|
|
1015
|
-
color:
|
|
565
|
+
color: ${aeroTheme.colors.convai.light};
|
|
1016
566
|
font-weight: 500;
|
|
1017
567
|
margin-left: 8px;
|
|
1018
568
|
padding: 2px 6px;
|
|
@@ -1077,22 +627,15 @@ export function createConvaiWidget(container, options) {
|
|
|
1077
627
|
listeningTag.insertBefore(dot, listeningTag.firstChild);
|
|
1078
628
|
titleSection.appendChild(listeningTag);
|
|
1079
629
|
}
|
|
1080
|
-
// Every call site that refreshes the header (stateChange, botReady,
|
|
1081
|
-
// connect/disconnect via stateChange, character-info fetch, ...) also
|
|
1082
|
-
// needs the connecting overlay re-evaluated -- it depends on the same
|
|
1083
|
-
// `isConnected`/`isBotReady`/`characterName` inputs.
|
|
1084
|
-
updateConnectingOverlay();
|
|
1085
|
-
updateVoiceModeAvatar();
|
|
1086
630
|
};
|
|
1087
631
|
// Create message list
|
|
1088
632
|
const createMessageList = () => {
|
|
1089
633
|
const list = document.createElement("div");
|
|
1090
634
|
list.id = "convai-message-list";
|
|
1091
|
-
list.setAttribute("part", "messages");
|
|
1092
635
|
list.style.cssText = `
|
|
1093
636
|
display: flex;
|
|
1094
637
|
flex-direction: column;
|
|
1095
|
-
gap:
|
|
638
|
+
gap: 0.75rem;
|
|
1096
639
|
min-height: 100%;
|
|
1097
640
|
`;
|
|
1098
641
|
return list;
|
|
@@ -1101,23 +644,22 @@ export function createConvaiWidget(container, options) {
|
|
|
1101
644
|
const createFooter = () => {
|
|
1102
645
|
const footer = document.createElement("div");
|
|
1103
646
|
footer.style.cssText = `
|
|
1104
|
-
padding:
|
|
647
|
+
padding: 1rem;
|
|
1105
648
|
border-top: 1px solid ${aeroTheme.colors.neutral[200]};
|
|
1106
|
-
background:
|
|
649
|
+
background: white;
|
|
1107
650
|
display: flex;
|
|
1108
|
-
gap:
|
|
651
|
+
gap: 0.5rem;
|
|
1109
652
|
align-items: center;
|
|
1110
653
|
position: relative;
|
|
1111
654
|
`;
|
|
1112
655
|
// Voice Mode Exit Button (Initially hidden)
|
|
1113
656
|
const voiceExitButton = document.createElement("button");
|
|
1114
657
|
voiceExitButton.id = "convai-voice-exit-btn";
|
|
1115
|
-
refs.voiceExitBtn = voiceExitButton;
|
|
1116
658
|
const exitIcon = Icons.PhoneOff("md");
|
|
1117
659
|
voiceExitButton.appendChild(exitIcon);
|
|
1118
660
|
voiceExitButton.style.cssText = `
|
|
1119
|
-
width:
|
|
1120
|
-
height:
|
|
661
|
+
width: 2.25rem;
|
|
662
|
+
height: 2.25rem;
|
|
1121
663
|
border-radius: 50%;
|
|
1122
664
|
background: ${aeroTheme.colors.error[500]};
|
|
1123
665
|
color: white;
|
|
@@ -1128,31 +670,19 @@ export function createConvaiWidget(container, options) {
|
|
|
1128
670
|
margin: 0 auto;
|
|
1129
671
|
border: none;
|
|
1130
672
|
`;
|
|
1131
|
-
voiceExitButton.addEventListener("click", () => {
|
|
1132
|
-
// Deliberately a *sync* listener. It used to be `async` with a bare
|
|
1133
|
-
// `await client.audioControls.muteAudio()` and no try/catch: on the
|
|
1134
|
-
// LiveKit path that call rethrows when the room has dropped, so a
|
|
1135
|
-
// network blip mid-call plus a tap on exit leaked an unhandled
|
|
1136
|
-
// rejection onto the host page *and* skipped the two lines below,
|
|
1137
|
-
// stranding the user in the voice overlay with no way out.
|
|
1138
|
-
//
|
|
1139
|
-
// The mute is not dropped -- `applyVoiceMode()` -> `updateVoiceMode()`
|
|
1140
|
-
// performs it (conditionally, if the mic is live) inside the exit
|
|
1141
|
-
// branch, where a failure is now reported rather than fatal. Exiting
|
|
1142
|
-
// the UI is unconditional either way: the overlay must never outlive
|
|
1143
|
-
// the user's decision to leave it.
|
|
673
|
+
voiceExitButton.addEventListener("click", async () => {
|
|
1144
674
|
client.sendInterruptMessage();
|
|
675
|
+
await client.audioControls.muteAudio(); // Mute on exit
|
|
1145
676
|
isVoiceMode = false;
|
|
1146
|
-
|
|
677
|
+
updateVoiceMode();
|
|
1147
678
|
});
|
|
1148
679
|
footer.appendChild(voiceExitButton);
|
|
1149
680
|
// Standard Footer Content (Mic + Input)
|
|
1150
681
|
const standardContent = document.createElement("div");
|
|
1151
682
|
standardContent.id = "convai-footer-standard";
|
|
1152
|
-
refs.footerStandard = standardContent;
|
|
1153
683
|
standardContent.style.cssText = `
|
|
1154
684
|
display: flex;
|
|
1155
|
-
gap:
|
|
685
|
+
gap: 0.5rem;
|
|
1156
686
|
align-items: center;
|
|
1157
687
|
width: 100%;
|
|
1158
688
|
`;
|
|
@@ -1165,16 +695,15 @@ export function createConvaiWidget(container, options) {
|
|
|
1165
695
|
align-items: center;
|
|
1166
696
|
`;
|
|
1167
697
|
inputElement = document.createElement("input");
|
|
1168
|
-
inputElement.setAttribute("part", "input");
|
|
1169
698
|
inputElement.type = "text";
|
|
1170
699
|
inputElement.placeholder = "Conversation";
|
|
1171
700
|
inputElement.style.cssText = `
|
|
1172
701
|
width: 100%;
|
|
1173
|
-
padding:
|
|
702
|
+
padding: 0.75rem 2.75rem 0.75rem 1rem;
|
|
1174
703
|
border-radius: ${aeroTheme.borderRadius.full};
|
|
1175
704
|
border: 1px solid ${aeroTheme.colors.neutral[300]};
|
|
1176
705
|
background: ${aeroTheme.colors.glass.medium};
|
|
1177
|
-
color:
|
|
706
|
+
color: ${aeroTheme.colors.text.primary};
|
|
1178
707
|
font-size: ${aeroTheme.typography.fontSize.sm};
|
|
1179
708
|
transition: ${aeroTheme.transitions.fast};
|
|
1180
709
|
outline: none;
|
|
@@ -1190,22 +719,16 @@ export function createConvaiWidget(container, options) {
|
|
|
1190
719
|
// Send button
|
|
1191
720
|
const sendButton = document.createElement("button");
|
|
1192
721
|
sendButton.id = "convai-send-button";
|
|
1193
|
-
// This button is dual-role -- it sends text when the input has a draft,
|
|
1194
|
-
// and toggles voice mode when empty -- but there is no separate mic
|
|
1195
|
-
// element, so only the send-button part applies. `mic-button` is
|
|
1196
|
-
// reserved for a future version that splits the two; see parts.test.ts.
|
|
1197
|
-
sendButton.setAttribute("part", "send-button");
|
|
1198
|
-
refs.sendButton = sendButton;
|
|
1199
722
|
const sendIcon = Icons.Send("md");
|
|
1200
723
|
sendButton.appendChild(sendIcon);
|
|
1201
724
|
sendButton.style.cssText = `
|
|
1202
725
|
position: absolute;
|
|
1203
|
-
right:
|
|
1204
|
-
width:
|
|
1205
|
-
height:
|
|
726
|
+
right: 0.375rem;
|
|
727
|
+
width: 2.25rem;
|
|
728
|
+
height: 2.25rem;
|
|
1206
729
|
border-radius: 50%;
|
|
1207
730
|
background: transparent; /* Initial transparent for voice toggle */
|
|
1208
|
-
color:
|
|
731
|
+
color: ${aeroTheme.colors.text.primary};
|
|
1209
732
|
display: flex;
|
|
1210
733
|
align-items: center;
|
|
1211
734
|
justify-content: center;
|
|
@@ -1221,19 +744,9 @@ export function createConvaiWidget(container, options) {
|
|
|
1221
744
|
client.connectionType !== "text") {
|
|
1222
745
|
// Toggle Voice Mode
|
|
1223
746
|
client.sendInterruptMessage();
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
// Awaited so this try/catch actually covers it -- bare, the promise
|
|
1228
|
-
// escaped the block it looks like it belongs to.
|
|
1229
|
-
await updateVoiceMode();
|
|
1230
|
-
}
|
|
1231
|
-
catch (error) {
|
|
1232
|
-
// isVoiceMode is only flipped after a successful unmute, so a
|
|
1233
|
-
// denial here leaves the widget in text mode, not stranded
|
|
1234
|
-
// mid-transition into voice mode.
|
|
1235
|
-
reportMicFailure(client, "Failed to enable microphone:", error);
|
|
1236
|
-
}
|
|
747
|
+
await client.audioControls.unmuteAudio(); // Unmute on enter
|
|
748
|
+
isVoiceMode = true;
|
|
749
|
+
updateVoiceMode();
|
|
1237
750
|
}
|
|
1238
751
|
});
|
|
1239
752
|
inputContainer.appendChild(inputElement);
|
|
@@ -1250,7 +763,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1250
763
|
position: absolute;
|
|
1251
764
|
top: 60px;
|
|
1252
765
|
right: 16px;
|
|
1253
|
-
background:
|
|
766
|
+
background: white;
|
|
1254
767
|
border-radius: ${aeroTheme.borderRadius.xl};
|
|
1255
768
|
box-shadow: ${aeroTheme.shadows.xl};
|
|
1256
769
|
padding: 0;
|
|
@@ -1265,7 +778,17 @@ export function createConvaiWidget(container, options) {
|
|
|
1265
778
|
overflow: hidden;
|
|
1266
779
|
`;
|
|
1267
780
|
// Add animation keyframes if not present
|
|
1268
|
-
|
|
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
|
+
}
|
|
1269
792
|
// Horizontal Settings Row Container
|
|
1270
793
|
const settingsRow = document.createElement("div");
|
|
1271
794
|
settingsRow.style.cssText = `
|
|
@@ -1294,7 +817,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1294
817
|
? "#10b981"
|
|
1295
818
|
: isDestructive
|
|
1296
819
|
? "#ef4444"
|
|
1297
|
-
:
|
|
820
|
+
: aeroTheme.colors.text.primary};
|
|
1298
821
|
transition: ${aeroTheme.transitions.fast};
|
|
1299
822
|
min-width: 50px;
|
|
1300
823
|
`;
|
|
@@ -1346,7 +869,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1346
869
|
videoIcon.style.height = "18px";
|
|
1347
870
|
const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
|
|
1348
871
|
videoBtn.id = "convai-settings-video-btn";
|
|
1349
|
-
refs.settingsVideoBtn = videoBtn;
|
|
1350
872
|
settingsRow.appendChild(videoBtn);
|
|
1351
873
|
}
|
|
1352
874
|
// Screen Share - only show if connection type is video
|
|
@@ -1359,7 +881,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1359
881
|
shareIcon.style.height = "18px";
|
|
1360
882
|
const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
|
|
1361
883
|
shareBtn.id = "convai-settings-share-btn";
|
|
1362
|
-
refs.settingsShareBtn = shareBtn;
|
|
1363
884
|
settingsRow.appendChild(shareBtn);
|
|
1364
885
|
}
|
|
1365
886
|
// Disconnect
|
|
@@ -1398,7 +919,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1398
919
|
? "#10b981"
|
|
1399
920
|
: isDestructive
|
|
1400
921
|
? "#ef4444"
|
|
1401
|
-
:
|
|
922
|
+
: aeroTheme.colors.text.primary};
|
|
1402
923
|
transition: ${aeroTheme.transitions.fast};
|
|
1403
924
|
min-width: 50px;
|
|
1404
925
|
`;
|
|
@@ -1450,7 +971,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1450
971
|
videoIcon.style.height = "18px";
|
|
1451
972
|
const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
|
|
1452
973
|
videoBtn.id = "convai-settings-video-btn";
|
|
1453
|
-
refs.settingsVideoBtn = videoBtn;
|
|
1454
974
|
settingsRow.appendChild(videoBtn);
|
|
1455
975
|
}
|
|
1456
976
|
// Screen Share - Always show if showScreenShare is true and connection type is video
|
|
@@ -1463,7 +983,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1463
983
|
shareIcon.style.height = "18px";
|
|
1464
984
|
const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
|
|
1465
985
|
shareBtn.id = "convai-settings-share-btn";
|
|
1466
|
-
refs.settingsShareBtn = shareBtn;
|
|
1467
986
|
settingsRow.appendChild(shareBtn);
|
|
1468
987
|
}
|
|
1469
988
|
// Disconnect
|
|
@@ -1478,12 +997,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1478
997
|
const createFloatingVideo = () => {
|
|
1479
998
|
const container = document.createElement("div");
|
|
1480
999
|
container.id = "floating-video-container";
|
|
1481
|
-
// NOTE on z-index: deliberately NOT themable. `--convai-z-index` drives the
|
|
1482
|
-
// widget root only. Both elements are `position: fixed` and this one is
|
|
1483
|
-
// appended *before* the widget root, so on a tie the widget paints on top:
|
|
1484
|
-
// a host raising the token to clear their nav bar would hide the user's own
|
|
1485
|
-
// camera preview behind the panel. Video/screen share are also out of scope
|
|
1486
|
-
// for the embed, which is chat-only.
|
|
1487
1000
|
container.style.cssText = `
|
|
1488
1001
|
position: fixed;
|
|
1489
1002
|
left: 20px;
|
|
@@ -1665,18 +1178,16 @@ export function createConvaiWidget(container, options) {
|
|
|
1665
1178
|
};
|
|
1666
1179
|
// Event handlers
|
|
1667
1180
|
const handleToggle = async () => {
|
|
1668
|
-
if (isDestroyed)
|
|
1181
|
+
if (isDestroyed || isStartingConnection) {
|
|
1669
1182
|
return;
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
//
|
|
1673
|
-
// to the `isConnecting` branch below and opening the panel *before* the
|
|
1674
|
-
// connect it is waiting on has finished.
|
|
1183
|
+
}
|
|
1184
|
+
if (isOpen) {
|
|
1185
|
+
// Just close if already open
|
|
1675
1186
|
return;
|
|
1676
1187
|
}
|
|
1677
1188
|
// Connect on first click if not already connected/connecting
|
|
1678
1189
|
if (!client.state.isConnected && !client.state.isConnecting) {
|
|
1679
|
-
|
|
1190
|
+
isStartingConnection = true;
|
|
1680
1191
|
try {
|
|
1681
1192
|
if (requestPublicationGrant || client.isPublishedChatSession) {
|
|
1682
1193
|
if (!requestPublicationGrant) {
|
|
@@ -1706,20 +1217,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1706
1217
|
await client.disconnect();
|
|
1707
1218
|
return;
|
|
1708
1219
|
}
|
|
1709
|
-
// Re-checked *after* the await: a close()/toggle()/launcher click
|
|
1710
|
-
// during the connect clears the flag, and that cancellation has to
|
|
1711
|
-
// win. The client stays connected either way -- only the panel is
|
|
1712
|
-
// held back -- so a later open() takes the already-connected branch
|
|
1713
|
-
// and opens instantly.
|
|
1714
|
-
if (isDestroyed) {
|
|
1715
|
-
// destroy() can also run after the grant is exchanged but before
|
|
1716
|
-
// Core or LiveKit finishes connecting. Disconnect that late session
|
|
1717
|
-
// instead of leaving an invisible room alive.
|
|
1718
|
-
await client.disconnect();
|
|
1719
|
-
return;
|
|
1720
|
-
}
|
|
1721
|
-
if (!pendingOpen)
|
|
1722
|
-
return;
|
|
1723
1220
|
setIsOpen(true);
|
|
1724
1221
|
}
|
|
1725
1222
|
catch (error) {
|
|
@@ -1728,7 +1225,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1728
1225
|
}
|
|
1729
1226
|
}
|
|
1730
1227
|
finally {
|
|
1731
|
-
|
|
1228
|
+
isStartingConnection = false;
|
|
1732
1229
|
}
|
|
1733
1230
|
}
|
|
1734
1231
|
else {
|
|
@@ -1736,29 +1233,11 @@ export function createConvaiWidget(container, options) {
|
|
|
1736
1233
|
setIsOpen(!isOpen);
|
|
1737
1234
|
}
|
|
1738
1235
|
};
|
|
1739
|
-
/** A launcher click while an open is pending means "cancel", same as `toggle()`. */
|
|
1740
|
-
const handleLauncherClick = () => {
|
|
1741
|
-
if (pendingOpen) {
|
|
1742
|
-
handleClose();
|
|
1743
|
-
return;
|
|
1744
|
-
}
|
|
1745
|
-
void handleToggle();
|
|
1746
|
-
};
|
|
1747
1236
|
const handleClose = () => {
|
|
1748
|
-
// Cancels an in-flight open as well as closing an open panel; see
|
|
1749
|
-
// `pendingOpen`. `setIsOpen(false)` on an already-closed widget just
|
|
1750
|
-
// re-applies the collapsed styles, so this is safe in both cases.
|
|
1751
|
-
pendingOpen = false;
|
|
1752
1237
|
setIsOpen(false);
|
|
1753
1238
|
};
|
|
1754
|
-
// Published sessions must wait for Core's bot-ready handshake. A normal
|
|
1755
|
-
// authenticated text session still gets the text-only UI, but can send as
|
|
1756
|
-
// soon as it connects.
|
|
1757
|
-
const requiresBotReadyForText = () => Boolean(requestPublicationGrant) || Boolean(client.isPublishedChatSession);
|
|
1758
|
-
const isTextInputReady = () => client.state.isConnected &&
|
|
1759
|
-
(!requiresBotReadyForText() || client.isBotReady);
|
|
1760
1239
|
const handleSend = () => {
|
|
1761
|
-
if (inputValue.trim() &&
|
|
1240
|
+
if (inputValue.trim() && client.state.isConnected && client.isBotReady) {
|
|
1762
1241
|
client.sendUserTextMessage(inputValue);
|
|
1763
1242
|
inputValue = "";
|
|
1764
1243
|
inputElement.value = "";
|
|
@@ -1789,7 +1268,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1789
1268
|
isVoiceMode = false;
|
|
1790
1269
|
hasEnteredDefaultVoiceMode = false;
|
|
1791
1270
|
updateHeader();
|
|
1792
|
-
|
|
1271
|
+
updateVoiceMode();
|
|
1793
1272
|
updateSendButton();
|
|
1794
1273
|
}
|
|
1795
1274
|
catch (error) {
|
|
@@ -1803,11 +1282,7 @@ export function createConvaiWidget(container, options) {
|
|
|
1803
1282
|
isVoiceMode = false;
|
|
1804
1283
|
hasEnteredDefaultVoiceMode = false;
|
|
1805
1284
|
updateHeader();
|
|
1806
|
-
|
|
1807
|
-
// straight to a click, so awaiting would only move a rejection from one
|
|
1808
|
-
// unguarded place to another. See the note on `handleDisconnect`'s own
|
|
1809
|
-
// `await client.disconnect()` in the fix report.
|
|
1810
|
-
applyVoiceMode();
|
|
1285
|
+
updateVoiceMode();
|
|
1811
1286
|
updateSendButton();
|
|
1812
1287
|
};
|
|
1813
1288
|
const handleToggleVideo = async () => {
|
|
@@ -1846,17 +1321,10 @@ export function createConvaiWidget(container, options) {
|
|
|
1846
1321
|
const setIsOpen = async (open) => {
|
|
1847
1322
|
isOpen = open;
|
|
1848
1323
|
if (open) {
|
|
1849
|
-
morphingContainer.style.width =
|
|
1850
|
-
morphingContainer.style.height =
|
|
1851
|
-
morphingContainer.style.borderRadius =
|
|
1324
|
+
morphingContainer.style.width = "400px";
|
|
1325
|
+
morphingContainer.style.height = "600px";
|
|
1326
|
+
morphingContainer.style.borderRadius = aeroTheme.borderRadius.xl;
|
|
1852
1327
|
morphingContainer.style.cursor = "default";
|
|
1853
|
-
// The open panel is a *different* glass treatment from the launcher
|
|
1854
|
-
// bubble, matching the React widget: near-opaque and saturated, so the
|
|
1855
|
-
// host page does not bleed through. At the bubble's 0.8 alpha a dark
|
|
1856
|
-
// host page shows through the panel and reads as grey.
|
|
1857
|
-
morphingContainer.style.background =
|
|
1858
|
-
"var(--convai-panel-bg, rgba(252, 252, 253, 0.95))";
|
|
1859
|
-
morphingContainer.style.backdropFilter = "blur(20px) saturate(180%)";
|
|
1860
1328
|
buttonContent.style.opacity = "0";
|
|
1861
1329
|
buttonContent.style.transform = "scale(0.8)";
|
|
1862
1330
|
buttonContent.style.pointerEvents = "none";
|
|
@@ -1874,24 +1342,19 @@ export function createConvaiWidget(container, options) {
|
|
|
1874
1342
|
await client.audioControls.unmuteAudio();
|
|
1875
1343
|
isVoiceMode = true;
|
|
1876
1344
|
hasEnteredDefaultVoiceMode = true;
|
|
1877
|
-
|
|
1345
|
+
updateVoiceMode();
|
|
1878
1346
|
}
|
|
1879
1347
|
catch (error) {
|
|
1880
|
-
|
|
1348
|
+
console.error("Failed to enter voice mode on open:", error);
|
|
1881
1349
|
}
|
|
1882
1350
|
}, 100);
|
|
1883
1351
|
}
|
|
1884
1352
|
}
|
|
1885
1353
|
else {
|
|
1886
|
-
morphingContainer.style.width = "
|
|
1887
|
-
morphingContainer.style.height = "
|
|
1888
|
-
// Literal, not `--convai-radius` — see the creation site: that token is
|
|
1889
|
-
// the *panel* radius, and reusing it here would squircle the launcher.
|
|
1354
|
+
morphingContainer.style.width = "4rem";
|
|
1355
|
+
morphingContainer.style.height = "4rem";
|
|
1890
1356
|
morphingContainer.style.borderRadius = "50%";
|
|
1891
1357
|
morphingContainer.style.cursor = "pointer";
|
|
1892
|
-
// Back to the launcher bubble's lighter glass — see the open branch.
|
|
1893
|
-
morphingContainer.style.background = `var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop})`;
|
|
1894
|
-
morphingContainer.style.backdropFilter = aeroTheme.glass.backdrop;
|
|
1895
1358
|
buttonContent.style.opacity = "1";
|
|
1896
1359
|
buttonContent.style.transform = "scale(1)";
|
|
1897
1360
|
buttonContent.style.pointerEvents = "auto";
|
|
@@ -1905,52 +1368,35 @@ export function createConvaiWidget(container, options) {
|
|
|
1905
1368
|
settingsTray.style.display = open ? "flex" : "none";
|
|
1906
1369
|
};
|
|
1907
1370
|
const updateSendButton = () => {
|
|
1908
|
-
const sendButton =
|
|
1371
|
+
const sendButton = document.getElementById("convai-send-button");
|
|
1909
1372
|
if (!sendButton)
|
|
1910
1373
|
return;
|
|
1911
1374
|
// Clear previous content
|
|
1912
1375
|
sendButton.innerHTML = "";
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
// for authenticated voice/video sessions.
|
|
1916
|
-
const isPublishedTextChat = requiresBotReadyForText();
|
|
1917
|
-
const textInputReady = isTextInputReady();
|
|
1918
|
-
inputElement.disabled = isPublishedTextChat && !textInputReady;
|
|
1376
|
+
const canSendText = client.state.isConnected && client.isBotReady;
|
|
1377
|
+
inputElement.disabled = !canSendText;
|
|
1919
1378
|
if (inputValue.length > 0 ||
|
|
1920
1379
|
requestPublicationGrant ||
|
|
1921
1380
|
client.connectionType === "text") {
|
|
1922
1381
|
// Show Send Button
|
|
1923
1382
|
sendButton.appendChild(Icons.Send("md"));
|
|
1924
|
-
sendButton.style.background =
|
|
1925
|
-
|
|
1926
|
-
// surface colour rather than stay a hardcoded "white" -- a host setting
|
|
1927
|
-
// a pale --convai-accent would otherwise get white-on-white. `white` is
|
|
1928
|
-
// byte-identical to the fallback --convai-panel-bg already carries at
|
|
1929
|
-
// its three other sites, so the default rendering is unchanged.
|
|
1930
|
-
// Pairing requirement for hosts: --convai-accent and --convai-panel-bg
|
|
1931
|
-
// must contrast with each other, since this is the one place they meet.
|
|
1932
|
-
sendButton.style.color = "var(--convai-panel-bg, white)";
|
|
1383
|
+
sendButton.style.background = aeroTheme.colors.text.primary;
|
|
1384
|
+
sendButton.style.color = "white";
|
|
1933
1385
|
sendButton.style.border = "none";
|
|
1934
|
-
sendButton.disabled =
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
: "Type a message"
|
|
1943
|
-
: "Waiting for character";
|
|
1944
|
-
}
|
|
1945
|
-
else {
|
|
1946
|
-
sendButton.title = "Send";
|
|
1947
|
-
}
|
|
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";
|
|
1948
1394
|
}
|
|
1949
1395
|
else {
|
|
1950
1396
|
// Show Voice Mode Toggle
|
|
1951
1397
|
sendButton.appendChild(Icons.Waveform("md"));
|
|
1952
1398
|
sendButton.style.background = "transparent";
|
|
1953
|
-
sendButton.style.color =
|
|
1399
|
+
sendButton.style.color = aeroTheme.colors.text.primary;
|
|
1954
1400
|
sendButton.style.border = `1px solid ${aeroTheme.colors.neutral[300]}`;
|
|
1955
1401
|
sendButton.style.opacity = "1";
|
|
1956
1402
|
sendButton.disabled = false;
|
|
@@ -1983,8 +1429,8 @@ export function createConvaiWidget(container, options) {
|
|
|
1983
1429
|
}
|
|
1984
1430
|
};
|
|
1985
1431
|
const updateVoiceMode = async () => {
|
|
1986
|
-
const standardFooter =
|
|
1987
|
-
const voiceExitBtn =
|
|
1432
|
+
const standardFooter = document.getElementById("convai-footer-standard");
|
|
1433
|
+
const voiceExitBtn = document.getElementById("convai-voice-exit-btn");
|
|
1988
1434
|
if (isVoiceMode) {
|
|
1989
1435
|
// Show Voice Overlay
|
|
1990
1436
|
if (voiceModeOverlay)
|
|
@@ -2004,26 +1450,15 @@ export function createConvaiWidget(container, options) {
|
|
|
2004
1450
|
updateHeader();
|
|
2005
1451
|
// Reset animation state for voice mode
|
|
2006
1452
|
startTime = Date.now();
|
|
1453
|
+
currentLevels = Array(40).fill(0.05);
|
|
1454
|
+
targetLevels = Array(40).fill(0.05);
|
|
2007
1455
|
// Start Audio Analysis
|
|
2008
1456
|
startAudioAnalysis();
|
|
2009
1457
|
}
|
|
2010
1458
|
else {
|
|
2011
|
-
// Ensure microphone is muted when not in voice mode
|
|
2012
|
-
//
|
|
2013
|
-
// This await is the only one in this function that can reject, and
|
|
2014
|
-
// everything below it -- hiding the overlay, restoring the standard
|
|
2015
|
-
// footer, updating the header, stopping the analyser -- is the only
|
|
2016
|
-
// route out of the voice UI. So a failed mute must never short-circuit
|
|
2017
|
-
// it: report it and carry on tearing the overlay down. (LiveKit's
|
|
2018
|
-
// setMicrophoneEnabled(false) rethrows on a dropped room, which is
|
|
2019
|
-
// exactly when the user is most likely to be hitting exit.)
|
|
1459
|
+
// Ensure microphone is muted when not in voice mode
|
|
2020
1460
|
if (!client.audioControls.isAudioMuted) {
|
|
2021
|
-
|
|
2022
|
-
await client.audioControls.muteAudio();
|
|
2023
|
-
}
|
|
2024
|
-
catch (error) {
|
|
2025
|
-
reportMicFailure(client, "Failed to mute microphone:", error);
|
|
2026
|
-
}
|
|
1461
|
+
await client.audioControls.muteAudio();
|
|
2027
1462
|
}
|
|
2028
1463
|
// Hide Overlay
|
|
2029
1464
|
if (voiceModeOverlay)
|
|
@@ -2055,31 +1490,9 @@ export function createConvaiWidget(container, options) {
|
|
|
2055
1490
|
stopAudioAnalysis();
|
|
2056
1491
|
}
|
|
2057
1492
|
};
|
|
2058
|
-
/**
|
|
2059
|
-
* Fire-and-forget `updateVoiceMode()` for the call sites that are
|
|
2060
|
-
* synchronous (DOM listeners, the constructor tail) and therefore have no
|
|
2061
|
-
* caller to catch anything it rejects with.
|
|
2062
|
-
*
|
|
2063
|
-
* `updateVoiceMode()` is `async`, so a bare call there would put a floating
|
|
2064
|
-
* promise on the host page. Its one rejecting await is now guarded
|
|
2065
|
-
* internally, which makes this belt-and-braces -- but it is the guarantee
|
|
2066
|
-
* that keeps a future await added inside `updateVoiceMode()` from silently
|
|
2067
|
-
* reintroducing the unhandled rejection this replaces. Routed through
|
|
2068
|
-
* `reportMicFailure` so a failure is observable on the client's `error`
|
|
2069
|
-
* channel rather than console-only.
|
|
2070
|
-
*/
|
|
2071
|
-
const applyVoiceMode = () => {
|
|
2072
|
-
void updateVoiceMode().catch((error) => {
|
|
2073
|
-
reportMicFailure(client, "Failed to apply voice mode:", error);
|
|
2074
|
-
});
|
|
2075
|
-
};
|
|
2076
1493
|
// Microphone button removed - only controlled via voice mode now
|
|
2077
1494
|
// Helper for Markdown - matches React MarkdownRenderer.tsx
|
|
2078
|
-
|
|
2079
|
-
// want them; the user's own bubble does not — you already know what you typed,
|
|
2080
|
-
// and a preview there would let a URL a user pasted embed remote media into
|
|
2081
|
-
// the transcript. Links stay clickable either way.
|
|
2082
|
-
const renderMarkdown = (text, container, { media = true } = {}) => {
|
|
1495
|
+
const renderMarkdown = (text, container) => {
|
|
2083
1496
|
if (!text)
|
|
2084
1497
|
return;
|
|
2085
1498
|
// Handle both actual newlines and \n escape sequences
|
|
@@ -2087,183 +1500,59 @@ export function createConvaiWidget(container, options) {
|
|
|
2087
1500
|
const lines = normalizedText.split("\n");
|
|
2088
1501
|
lines.forEach((line, lineIndex) => {
|
|
2089
1502
|
// Process markdown within each line
|
|
2090
|
-
// Bold, italic, links, images and bare URLs — see utils/inlineMarkdown.
|
|
2091
|
-
// `cdn.example.com/a/b/c.png` -> `cdn.example.com · c.png`
|
|
2092
|
-
const describeUrl = (url) => {
|
|
2093
|
-
try {
|
|
2094
|
-
const { host, pathname } = new URL(url);
|
|
2095
|
-
const file = pathname.split("/").filter(Boolean).pop();
|
|
2096
|
-
return file ? `${host} · ${file}` : host;
|
|
2097
|
-
}
|
|
2098
|
-
catch {
|
|
2099
|
-
return url;
|
|
2100
|
-
}
|
|
2101
|
-
};
|
|
2102
|
-
/**
|
|
2103
|
-
* A preview card that reserves its space before the media arrives, so the
|
|
2104
|
-
* bubble does not jump when an image decodes, and collapses to one muted
|
|
2105
|
-
* line if the URL does not resolve — a character can easily invent one,
|
|
2106
|
-
* and an empty player in the transcript reads as a broken widget.
|
|
2107
|
-
*
|
|
2108
|
-
* Chrome is derived from the inherited text colour: the widget is embedded
|
|
2109
|
-
* in someone else's page, on a background this code cannot know.
|
|
2110
|
-
*/
|
|
2111
|
-
const mediaPreview = (src, alt, media) => {
|
|
2112
|
-
const card = document.createElement("span");
|
|
2113
|
-
card.setAttribute("part", "message-media-card");
|
|
2114
|
-
card.style.cssText = `
|
|
2115
|
-
display: block;
|
|
2116
|
-
position: relative;
|
|
2117
|
-
margin-top: 6px;
|
|
2118
|
-
max-width: 320px;
|
|
2119
|
-
aspect-ratio: 16 / 10;
|
|
2120
|
-
border-radius: 8px;
|
|
2121
|
-
overflow: hidden;
|
|
2122
|
-
background: color-mix(in srgb, currentColor 6%, transparent);
|
|
2123
|
-
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 10%, transparent);
|
|
2124
|
-
`;
|
|
2125
|
-
const el = media === "image"
|
|
2126
|
-
? Object.assign(document.createElement("img"), { src, alt, loading: "lazy" })
|
|
2127
|
-
: Object.assign(document.createElement("video"), { src, controls: true, preload: "metadata" });
|
|
2128
|
-
el.setAttribute("part", "message-media");
|
|
2129
|
-
el.style.cssText = `
|
|
2130
|
-
display: block;
|
|
2131
|
-
width: 100%;
|
|
2132
|
-
height: 100%;
|
|
2133
|
-
object-fit: ${media === "image" ? "cover" : "contain"};
|
|
2134
|
-
border-radius: 8px;
|
|
2135
|
-
opacity: 0;
|
|
2136
|
-
transition: opacity .18s ease;
|
|
2137
|
-
`;
|
|
2138
|
-
const skeleton = document.createElement("span");
|
|
2139
|
-
skeleton.setAttribute("part", "message-media-skeleton");
|
|
2140
|
-
skeleton.setAttribute("aria-hidden", "true");
|
|
2141
|
-
skeleton.dataset.convaiSkeleton = "";
|
|
2142
|
-
skeleton.style.cssText = `
|
|
2143
|
-
position: absolute;
|
|
2144
|
-
inset: 0;
|
|
2145
|
-
border-radius: 8px;
|
|
2146
|
-
background: color-mix(in srgb, currentColor 8%, transparent);
|
|
2147
|
-
background-image: linear-gradient(90deg, transparent, color-mix(in srgb, currentColor 12%, transparent), transparent);
|
|
2148
|
-
background-size: 200% 100%;
|
|
2149
|
-
animation: convai-preview-shimmer 1.4s ease-in-out infinite;
|
|
2150
|
-
`;
|
|
2151
|
-
const caption = document.createElement("span");
|
|
2152
|
-
caption.setAttribute("part", "message-media-caption");
|
|
2153
|
-
caption.textContent = describeUrl(src);
|
|
2154
|
-
caption.style.cssText = `
|
|
2155
|
-
display: block;
|
|
2156
|
-
margin-top: 4px;
|
|
2157
|
-
font-size: 11px;
|
|
2158
|
-
opacity: .7;
|
|
2159
|
-
white-space: nowrap;
|
|
2160
|
-
overflow: hidden;
|
|
2161
|
-
text-overflow: ellipsis;
|
|
2162
|
-
`;
|
|
2163
|
-
const settled = () => {
|
|
2164
|
-
el.style.opacity = "1";
|
|
2165
|
-
skeleton.remove();
|
|
2166
|
-
};
|
|
2167
|
-
el.addEventListener(media === "image" ? "load" : "loadedmetadata", settled);
|
|
2168
|
-
el.addEventListener("error", () => {
|
|
2169
|
-
const dead = document.createElement("span");
|
|
2170
|
-
dead.setAttribute("part", "message-media-unavailable");
|
|
2171
|
-
dead.textContent = `Preview unavailable — ${describeUrl(src)}`;
|
|
2172
|
-
dead.style.cssText = `
|
|
2173
|
-
display: block;
|
|
2174
|
-
margin-top: 6px;
|
|
2175
|
-
font-size: 11px;
|
|
2176
|
-
opacity: .55;
|
|
2177
|
-
font-style: italic;
|
|
2178
|
-
white-space: nowrap;
|
|
2179
|
-
overflow: hidden;
|
|
2180
|
-
text-overflow: ellipsis;
|
|
2181
|
-
`;
|
|
2182
|
-
card.replaceWith(dead);
|
|
2183
|
-
caption.remove();
|
|
2184
|
-
});
|
|
2185
|
-
card.appendChild(el);
|
|
2186
|
-
card.appendChild(skeleton);
|
|
2187
|
-
const frag = document.createDocumentFragment();
|
|
2188
|
-
frag.appendChild(card);
|
|
2189
|
-
frag.appendChild(caption);
|
|
2190
|
-
return frag;
|
|
2191
|
-
};
|
|
2192
1503
|
const processLine = (text) => {
|
|
2193
1504
|
const parts = [];
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
case "em": {
|
|
2208
|
-
const em = document.createElement("em");
|
|
2209
|
-
em.textContent = node.text;
|
|
2210
|
-
em.style.cssText = `font-style: italic;`;
|
|
2211
|
-
parts.push(em);
|
|
2212
|
-
break;
|
|
2213
|
-
}
|
|
2214
|
-
case "link": {
|
|
2215
|
-
const a = document.createElement("a");
|
|
2216
|
-
a.href = node.href;
|
|
2217
|
-
a.textContent = node.label === node.href ? compactUrl(node.label) : node.label;
|
|
2218
|
-
a.target = "_blank";
|
|
2219
|
-
a.rel = "noopener noreferrer";
|
|
2220
|
-
a.setAttribute("part", "message-link");
|
|
2221
|
-
a.style.cssText = `
|
|
2222
|
-
color: #10b981;
|
|
2223
|
-
text-decoration: underline;
|
|
2224
|
-
word-break: break-all;
|
|
2225
|
-
`;
|
|
2226
|
-
parts.push(a);
|
|
2227
|
-
if (media && node.media)
|
|
2228
|
-
parts.push(mediaPreview(node.href, node.label, node.media));
|
|
2229
|
-
break;
|
|
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));
|
|
2230
1518
|
}
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
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));
|
|
2267
1556
|
}
|
|
2268
1557
|
}
|
|
2269
1558
|
return parts;
|
|
@@ -2302,7 +1591,7 @@ export function createConvaiWidget(container, options) {
|
|
|
2302
1591
|
height: 100%;
|
|
2303
1592
|
color: ${aeroTheme.colors.text.secondary};
|
|
2304
1593
|
text-align: center;
|
|
2305
|
-
padding:
|
|
1594
|
+
padding: 2rem;
|
|
2306
1595
|
`;
|
|
2307
1596
|
emptyState.innerHTML = `
|
|
2308
1597
|
<p style="font-weight: 500; margin-bottom: 8px;">Start a conversation</p>
|
|
@@ -2324,7 +1613,6 @@ export function createConvaiWidget(container, options) {
|
|
|
2324
1613
|
contain: layout style paint;
|
|
2325
1614
|
`;
|
|
2326
1615
|
const bubble = document.createElement("div");
|
|
2327
|
-
bubble.setAttribute("part", msg.isUser ? "message message-user" : "message message-bot");
|
|
2328
1616
|
// Add data attribute to identify user messages for debugging/styling
|
|
2329
1617
|
if (msg.isUser) {
|
|
2330
1618
|
bubble.setAttribute("data-message-type", "user");
|
|
@@ -2332,10 +1620,6 @@ export function createConvaiWidget(container, options) {
|
|
|
2332
1620
|
// Set base styles first - explicitly set background to match React UserBubble exactly
|
|
2333
1621
|
// CRITICAL: Use the exact same background for both user and bot messages
|
|
2334
1622
|
const backgroundColor = "rgba(252, 252, 253, 0.95)";
|
|
2335
|
-
// NOTE on border-radius: deliberately NOT themable. `--convai-radius` is
|
|
2336
|
-
// the open panel's radius; a bubble's asymmetric tail corner is a
|
|
2337
|
-
// different geometry (`50%` would make ellipses of these). A dedicated
|
|
2338
|
-
// `--convai-bubble-radius` can be added additively later if wanted.
|
|
2339
1623
|
bubble.style.cssText = `
|
|
2340
1624
|
padding: 12px;
|
|
2341
1625
|
border-radius: ${msg.isUser ? "12px 12px 4px 12px" : "12px 12px 12px 4px"};
|
|
@@ -2344,8 +1628,8 @@ export function createConvaiWidget(container, options) {
|
|
|
2344
1628
|
backdrop-filter: blur(20px) saturate(180%);
|
|
2345
1629
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
2346
1630
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8) inset, 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
2347
|
-
color:
|
|
2348
|
-
font-family:
|
|
1631
|
+
color: ${aeroTheme.colors.text.primary};
|
|
1632
|
+
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
2349
1633
|
font-size: 14px;
|
|
2350
1634
|
line-height: 1.5;
|
|
2351
1635
|
word-wrap: break-word;
|
|
@@ -2377,8 +1661,8 @@ export function createConvaiWidget(container, options) {
|
|
|
2377
1661
|
// Logo/Icon - matches React LogoWrapper
|
|
2378
1662
|
const logoContainer = document.createElement("div");
|
|
2379
1663
|
logoContainer.style.cssText = `
|
|
2380
|
-
width:
|
|
2381
|
-
height:
|
|
1664
|
+
width: 1rem;
|
|
1665
|
+
height: 1rem;
|
|
2382
1666
|
display: flex;
|
|
2383
1667
|
align-items: center;
|
|
2384
1668
|
justify-content: center;
|
|
@@ -2405,10 +1689,10 @@ export function createConvaiWidget(container, options) {
|
|
|
2405
1689
|
const nameLabel = document.createElement("span");
|
|
2406
1690
|
nameLabel.textContent = msg.sender === "User" ? "You" : msg.sender;
|
|
2407
1691
|
nameLabel.style.cssText = `
|
|
2408
|
-
font-size:
|
|
1692
|
+
font-size: 0.75rem;
|
|
2409
1693
|
color: ${msg.isUser ? aeroTheme.colors.convai.dark : "#10b981"};
|
|
2410
1694
|
font-weight: 500;
|
|
2411
|
-
font-family:
|
|
1695
|
+
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
2412
1696
|
isolation: isolate;
|
|
2413
1697
|
contain: layout style paint;
|
|
2414
1698
|
`;
|
|
@@ -2422,7 +1706,7 @@ export function createConvaiWidget(container, options) {
|
|
|
2422
1706
|
white-space: pre-wrap;
|
|
2423
1707
|
word-wrap: break-word;
|
|
2424
1708
|
`;
|
|
2425
|
-
renderMarkdown(msg.text, contentDiv
|
|
1709
|
+
renderMarkdown(msg.text, contentDiv);
|
|
2426
1710
|
bubble.appendChild(contentDiv);
|
|
2427
1711
|
messageWrapper.appendChild(bubble);
|
|
2428
1712
|
// Timestamp - matches React exactly
|
|
@@ -2430,11 +1714,11 @@ export function createConvaiWidget(container, options) {
|
|
|
2430
1714
|
const timestamp = document.createElement("span");
|
|
2431
1715
|
timestamp.textContent = msg.timestamp;
|
|
2432
1716
|
timestamp.style.cssText = `
|
|
2433
|
-
font-size:
|
|
1717
|
+
font-size: 0.75rem;
|
|
2434
1718
|
color: ${aeroTheme.colors.text.secondary};
|
|
2435
1719
|
margin-top: 4px;
|
|
2436
1720
|
align-self: ${msg.isUser ? "flex-end" : "flex-start"};
|
|
2437
|
-
font-family:
|
|
1721
|
+
font-family: ${aeroTheme.typography.fontFamily.body};
|
|
2438
1722
|
`;
|
|
2439
1723
|
messageWrapper.appendChild(timestamp);
|
|
2440
1724
|
}
|
|
@@ -2478,8 +1762,6 @@ export function createConvaiWidget(container, options) {
|
|
|
2478
1762
|
// Setup event listeners for client state changes
|
|
2479
1763
|
const setupClientListeners = () => {
|
|
2480
1764
|
eventSubscriptions.register((listener) => client.on("stateChange", listener), () => {
|
|
2481
|
-
// Published chat (connectionType "text") has no microphone: never let
|
|
2482
|
-
// a voice-mode transition leave the panel in a mode it cannot honour.
|
|
2483
1765
|
if (client.connectionType === "text") {
|
|
2484
1766
|
if (isVoiceMode) {
|
|
2485
1767
|
isVoiceMode = false;
|
|
@@ -2507,6 +1789,8 @@ export function createConvaiWidget(container, options) {
|
|
|
2507
1789
|
// Reset when not speaking
|
|
2508
1790
|
if (startTime && !client.state.isSpeaking) {
|
|
2509
1791
|
startTime = 0;
|
|
1792
|
+
currentLevels = Array(40).fill(0.05);
|
|
1793
|
+
targetLevels = Array(40).fill(0.05);
|
|
2510
1794
|
}
|
|
2511
1795
|
}
|
|
2512
1796
|
// Auto-collapse when disconnected
|
|
@@ -2519,14 +1803,12 @@ export function createConvaiWidget(container, options) {
|
|
|
2519
1803
|
isVideoVisible = false;
|
|
2520
1804
|
hasEnteredDefaultVoiceMode = false;
|
|
2521
1805
|
updateHeader();
|
|
2522
|
-
|
|
2523
|
-
// mute inside is the one most likely to fail (the room has just
|
|
2524
|
-
// dropped -- that is why we are collapsing).
|
|
2525
|
-
applyVoiceMode();
|
|
1806
|
+
updateVoiceMode();
|
|
2526
1807
|
}
|
|
2527
1808
|
// Auto-enter voice mode on first connection if defaultVoiceMode is true
|
|
2528
1809
|
if (client.state.isConnected &&
|
|
2529
1810
|
defaultVoiceMode &&
|
|
1811
|
+
client.connectionType !== "text" &&
|
|
2530
1812
|
isOpen &&
|
|
2531
1813
|
!hasEnteredDefaultVoiceMode &&
|
|
2532
1814
|
!isVoiceMode) {
|
|
@@ -2535,10 +1817,10 @@ export function createConvaiWidget(container, options) {
|
|
|
2535
1817
|
await client.audioControls.unmuteAudio();
|
|
2536
1818
|
isVoiceMode = true;
|
|
2537
1819
|
hasEnteredDefaultVoiceMode = true;
|
|
2538
|
-
|
|
1820
|
+
updateVoiceMode();
|
|
2539
1821
|
}
|
|
2540
1822
|
catch (error) {
|
|
2541
|
-
|
|
1823
|
+
console.error("Failed to enter voice mode:", error);
|
|
2542
1824
|
}
|
|
2543
1825
|
}, 100);
|
|
2544
1826
|
}
|
|
@@ -2548,9 +1830,9 @@ export function createConvaiWidget(container, options) {
|
|
|
2548
1830
|
eventSubscriptions.register((listener) => client.videoControls.on("videoStateChange", listener), (videoState) => {
|
|
2549
1831
|
if (videoState.isVideoEnabled !== undefined) {
|
|
2550
1832
|
isVideoVisible = videoState.isVideoEnabled;
|
|
2551
|
-
|
|
1833
|
+
updateVoiceMode();
|
|
2552
1834
|
// Update tray button state
|
|
2553
|
-
const videoBtn =
|
|
1835
|
+
const videoBtn = document.getElementById("convai-settings-video-btn");
|
|
2554
1836
|
if (videoBtn) {
|
|
2555
1837
|
const isActive = isVideoVisible;
|
|
2556
1838
|
videoBtn.style.backgroundColor = isActive
|
|
@@ -2558,7 +1840,7 @@ export function createConvaiWidget(container, options) {
|
|
|
2558
1840
|
: "transparent";
|
|
2559
1841
|
videoBtn.style.color = isActive
|
|
2560
1842
|
? "#10b981"
|
|
2561
|
-
:
|
|
1843
|
+
: aeroTheme.colors.text.primary;
|
|
2562
1844
|
// Update Icon - first child is icon container
|
|
2563
1845
|
const iconContainer = videoBtn.querySelector("div");
|
|
2564
1846
|
if (iconContainer) {
|
|
@@ -2572,10 +1854,10 @@ export function createConvaiWidget(container, options) {
|
|
|
2572
1854
|
}
|
|
2573
1855
|
});
|
|
2574
1856
|
// Screen share state change listener
|
|
2575
|
-
client.screenShareControls.on("screenShareStateChange", (screenShareState) => {
|
|
1857
|
+
eventSubscriptions.register((listener) => client.screenShareControls.on("screenShareStateChange", listener), (screenShareState) => {
|
|
2576
1858
|
if (screenShareState.isScreenShareActive !== undefined) {
|
|
2577
1859
|
const isSharing = screenShareState.isScreenShareActive;
|
|
2578
|
-
const shareBtn =
|
|
1860
|
+
const shareBtn = document.getElementById("convai-settings-share-btn");
|
|
2579
1861
|
if (shareBtn) {
|
|
2580
1862
|
const isActive = isSharing;
|
|
2581
1863
|
shareBtn.style.backgroundColor = isActive
|
|
@@ -2583,7 +1865,7 @@ export function createConvaiWidget(container, options) {
|
|
|
2583
1865
|
: "transparent";
|
|
2584
1866
|
shareBtn.style.color = isActive
|
|
2585
1867
|
? "#10b981"
|
|
2586
|
-
:
|
|
1868
|
+
: aeroTheme.colors.text.primary;
|
|
2587
1869
|
// Update Icon - first child is icon container
|
|
2588
1870
|
const iconContainer = shareBtn.querySelector("div");
|
|
2589
1871
|
if (iconContainer) {
|
|
@@ -2600,6 +1882,11 @@ export function createConvaiWidget(container, options) {
|
|
|
2600
1882
|
});
|
|
2601
1883
|
eventSubscriptions.register((listener) => client.on("messagesChange", listener), () => {
|
|
2602
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
|
+
}
|
|
2603
1890
|
});
|
|
2604
1891
|
// Bot ready listener - updates UI when bot becomes ready
|
|
2605
1892
|
eventSubscriptions.register((listener) => client.on("botReady", listener), () => {
|
|
@@ -2609,13 +1896,8 @@ export function createConvaiWidget(container, options) {
|
|
|
2609
1896
|
eventSubscriptions.register((listener) => client.on("connect", listener), () => {
|
|
2610
1897
|
hasConnectedBefore = true;
|
|
2611
1898
|
// Initialize audio renderer on connection
|
|
2612
|
-
// NOTE: the audio elements live under rootElement, so playback ends if
|
|
2613
|
-
// the host *removes or re-parents* its mount container mid-call. Merely
|
|
2614
|
-
// hiding it (display: none / visibility: hidden) does NOT stop
|
|
2615
|
-
// HTMLMediaElement playback in any browser — an earlier version of this
|
|
2616
|
-
// comment claimed otherwise and was wrong.
|
|
2617
1899
|
if (client.room && !audioRenderer) {
|
|
2618
|
-
audioRenderer = new AudioRenderer(client.room
|
|
1900
|
+
audioRenderer = new AudioRenderer(client.room);
|
|
2619
1901
|
}
|
|
2620
1902
|
// Fetch character info
|
|
2621
1903
|
fetchCharacterInfo();
|
|
@@ -2636,127 +1918,25 @@ export function createConvaiWidget(container, options) {
|
|
|
2636
1918
|
onDisconnect();
|
|
2637
1919
|
}
|
|
2638
1920
|
});
|
|
2639
|
-
// `message` carries the row actually updated. That can differ from the
|
|
2640
|
-
// list tail when correlated v2 streams interleave, which is why onMessage
|
|
2641
|
-
// no longer reads the last element of chatMessages.
|
|
2642
|
-
eventSubscriptions.register((listener) => client.on("message", listener), (message) => {
|
|
2643
|
-
const row = message;
|
|
2644
|
-
if (onMessage && row) {
|
|
2645
|
-
onMessage(row);
|
|
2646
|
-
}
|
|
2647
|
-
});
|
|
2648
|
-
// Screen share state change listener
|
|
2649
|
-
eventSubscriptions.register((listener) => client.screenShareControls.on("screenShareStateChange", listener), (screenShareState) => {
|
|
2650
|
-
if (screenShareState.isScreenShareActive !== undefined) {
|
|
2651
|
-
const isSharing = screenShareState.isScreenShareActive;
|
|
2652
|
-
const shareBtn = document.getElementById("convai-settings-share-btn");
|
|
2653
|
-
if (shareBtn) {
|
|
2654
|
-
const isActive = isSharing;
|
|
2655
|
-
shareBtn.style.backgroundColor = isActive
|
|
2656
|
-
? "rgba(16, 185, 129, 0.15)"
|
|
2657
|
-
: "transparent";
|
|
2658
|
-
shareBtn.style.color = isActive
|
|
2659
|
-
? "#10b981"
|
|
2660
|
-
: aeroTheme.colors.text.primary;
|
|
2661
|
-
// Update Icon - first child is icon container
|
|
2662
|
-
const iconContainer = shareBtn.querySelector("div");
|
|
2663
|
-
if (iconContainer) {
|
|
2664
|
-
iconContainer.innerHTML = "";
|
|
2665
|
-
const icon = isActive
|
|
2666
|
-
? Icons.StopScreenShare("md")
|
|
2667
|
-
: Icons.ScreenShare("md");
|
|
2668
|
-
icon.style.width = "18px";
|
|
2669
|
-
icon.style.height = "18px";
|
|
2670
|
-
iconContainer.appendChild(icon);
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2673
|
-
}
|
|
2674
|
-
});
|
|
2675
1921
|
};
|
|
2676
1922
|
// Initialize
|
|
2677
1923
|
createDOM();
|
|
2678
1924
|
setupClientListeners();
|
|
2679
|
-
// Paint the connecting overlay's initial state. updateHeader() (which also
|
|
2680
|
-
// calls updateConnectingOverlay()) is otherwise only reached via a client
|
|
2681
|
-
// event or a successful character-info fetch, so a widget constructed
|
|
2682
|
-
// already connected-but-not-ready would show nothing until one of those
|
|
2683
|
-
// fires without this.
|
|
2684
|
-
updateConnectingOverlay();
|
|
2685
1925
|
// Set initial button state
|
|
2686
1926
|
updateSendButton(); // Show voice mode button initially since input is empty
|
|
2687
1927
|
// Set initial voice mode UI if defaultVoiceMode is true
|
|
2688
1928
|
if (defaultVoiceMode) {
|
|
2689
|
-
|
|
1929
|
+
updateVoiceMode();
|
|
2690
1930
|
}
|
|
2691
1931
|
// If already connected, initialize audio renderer and fetch character info
|
|
2692
|
-
// NOTE: same lifetime caveat as the "connect" handler above — the audio
|
|
2693
|
-
// elements are torn down with rootElement if the host detaches it.
|
|
2694
1932
|
if (client.state.isConnected && client.room) {
|
|
2695
|
-
audioRenderer = new AudioRenderer(client.room
|
|
1933
|
+
audioRenderer = new AudioRenderer(client.room);
|
|
2696
1934
|
fetchCharacterInfo();
|
|
2697
1935
|
}
|
|
2698
1936
|
// Return widget instance
|
|
2699
1937
|
const widget = {
|
|
2700
1938
|
element: rootElement,
|
|
2701
1939
|
client: client,
|
|
2702
|
-
// `isOpen` reports *requested* state, which on a warm client is the same
|
|
2703
|
-
// thing as rendered state: `setIsOpen()` runs synchronously. The one case
|
|
2704
|
-
// where they differ is the first open on a disconnected client, where the
|
|
2705
|
-
// panel is committed only after `connect()` resolves; reporting `false`
|
|
2706
|
-
// for those seconds would tell an embed reconciling its own
|
|
2707
|
-
// convai-open/convai-close events that a widget it just opened is closed.
|
|
2708
|
-
// `pendingOpen` is cleared by every cancellation path, so this never
|
|
2709
|
-
// reports open for a request that was called off.
|
|
2710
|
-
get isOpen() {
|
|
2711
|
-
return isOpen || pendingOpen;
|
|
2712
|
-
},
|
|
2713
|
-
// Drives the exact same path the launcher click does: `handleToggle`
|
|
2714
|
-
// (connect-on-first-open, then `setIsOpen(true)`). Idempotent against an
|
|
2715
|
-
// in-flight open as well as an open panel, so repeat calls never start a
|
|
2716
|
-
// second connect.
|
|
2717
|
-
open: () => {
|
|
2718
|
-
if (isOpen || pendingOpen)
|
|
2719
|
-
return;
|
|
2720
|
-
void handleToggle();
|
|
2721
|
-
},
|
|
2722
|
-
// Drives the same path the header's close button does: `handleClose` ->
|
|
2723
|
-
// `setIsOpen(false)`. Also cancels an open that is still waiting on
|
|
2724
|
-
// `connect()`; a redundant `close()` is a no-op.
|
|
2725
|
-
close: () => {
|
|
2726
|
-
if (!isOpen && !pendingOpen)
|
|
2727
|
-
return;
|
|
2728
|
-
handleClose();
|
|
2729
|
-
},
|
|
2730
|
-
toggle: () => {
|
|
2731
|
-
if (isOpen || pendingOpen) {
|
|
2732
|
-
handleClose();
|
|
2733
|
-
}
|
|
2734
|
-
else {
|
|
2735
|
-
void handleToggle();
|
|
2736
|
-
}
|
|
2737
|
-
},
|
|
2738
|
-
// Pushes a synthetic ChatMessage straight onto `client.chatMessages` and
|
|
2739
|
-
// re-renders via the widget's existing `updateMessageList()`. It never
|
|
2740
|
-
// calls a client send method (e.g. `sendUserTextMessage`), so the
|
|
2741
|
-
// message never reaches the character and is never billed as an LLM
|
|
2742
|
-
// turn. The `local-` id keeps it from colliding with a server-issued id.
|
|
2743
|
-
appendMessage: (message) => {
|
|
2744
|
-
// Shared core path, so React and vanilla insert identically. A
|
|
2745
|
-
// hand-rolled IConvaiClient without it falls back to the old local push.
|
|
2746
|
-
if (typeof client.appendMessage === "function") {
|
|
2747
|
-
client.appendMessage(message);
|
|
2748
|
-
}
|
|
2749
|
-
else {
|
|
2750
|
-
const chatMessage = {
|
|
2751
|
-
id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
2752
|
-
type: message.sender === "user" ? "user-llm-text" : "bot-llm-text",
|
|
2753
|
-
content: message.content,
|
|
2754
|
-
timestamp: new Date().toISOString(),
|
|
2755
|
-
};
|
|
2756
|
-
client.chatMessages.push(chatMessage);
|
|
2757
|
-
}
|
|
2758
|
-
updateMessageList();
|
|
2759
|
-
},
|
|
2760
1940
|
destroy: () => {
|
|
2761
1941
|
isDestroyed = true;
|
|
2762
1942
|
// Cleanup audio renderer
|
|
@@ -2765,7 +1945,7 @@ export function createConvaiWidget(container, options) {
|
|
|
2765
1945
|
audioRenderer = null;
|
|
2766
1946
|
}
|
|
2767
1947
|
// Remove event listeners
|
|
2768
|
-
morphingContainer.removeEventListener("click",
|
|
1948
|
+
morphingContainer.removeEventListener("click", handleToggle);
|
|
2769
1949
|
// Remove DOM elements
|
|
2770
1950
|
if (rootElement.parentElement) {
|
|
2771
1951
|
rootElement.remove();
|