@convai/web-sdk 1.8.0-beta.7 → 1.8.0

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