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

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 (110) hide show
  1. package/README.md +333 -13
  2. package/dist/core/CharacterRoster.d.ts +84 -0
  3. package/dist/core/CharacterRoster.d.ts.map +1 -0
  4. package/dist/core/CharacterRoster.js +348 -0
  5. package/dist/core/CharacterRoster.js.map +1 -0
  6. package/dist/core/ConvaiClient.d.ts +152 -2
  7. package/dist/core/ConvaiClient.d.ts.map +1 -1
  8. package/dist/core/ConvaiClient.js +1036 -49
  9. package/dist/core/ConvaiClient.js.map +1 -1
  10. package/dist/core/ConvaiRoomError.d.ts +48 -0
  11. package/dist/core/ConvaiRoomError.d.ts.map +1 -0
  12. package/dist/core/ConvaiRoomError.js +111 -0
  13. package/dist/core/ConvaiRoomError.js.map +1 -0
  14. package/dist/core/MessageHandler.d.ts +25 -0
  15. package/dist/core/MessageHandler.d.ts.map +1 -1
  16. package/dist/core/MessageHandler.js +431 -30
  17. package/dist/core/MessageHandler.js.map +1 -1
  18. package/dist/core/SSESession.d.ts +15 -0
  19. package/dist/core/SSESession.d.ts.map +1 -1
  20. package/dist/core/SSESession.js +92 -3
  21. package/dist/core/SSESession.js.map +1 -1
  22. package/dist/core/connectRequest.d.ts +38 -2
  23. package/dist/core/connectRequest.d.ts.map +1 -1
  24. package/dist/core/connectRequest.js +57 -0
  25. package/dist/core/connectRequest.js.map +1 -1
  26. package/dist/core/index.d.ts +6 -1
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/core/index.js +5 -1
  29. package/dist/core/index.js.map +1 -1
  30. package/dist/core/logicalTurn.d.ts +4 -1
  31. package/dist/core/logicalTurn.d.ts.map +1 -1
  32. package/dist/core/logicalTurn.js +35 -4
  33. package/dist/core/logicalTurn.js.map +1 -1
  34. package/dist/core/modelOutput.d.ts +40 -0
  35. package/dist/core/modelOutput.d.ts.map +1 -0
  36. package/dist/core/modelOutput.js +187 -0
  37. package/dist/core/modelOutput.js.map +1 -0
  38. package/dist/core/publishedChat.d.ts +9 -0
  39. package/dist/core/publishedChat.d.ts.map +1 -0
  40. package/dist/core/publishedChat.js +13 -0
  41. package/dist/core/publishedChat.js.map +1 -0
  42. package/dist/core/rosterRequest.d.ts +57 -0
  43. package/dist/core/rosterRequest.d.ts.map +1 -0
  44. package/dist/core/rosterRequest.js +210 -0
  45. package/dist/core/rosterRequest.js.map +1 -0
  46. package/dist/core/types.d.ts +667 -10
  47. package/dist/core/types.d.ts.map +1 -1
  48. package/dist/core/types.js.map +1 -1
  49. package/dist/embed/ConvaiChatElement.d.ts +27 -0
  50. package/dist/embed/ConvaiChatElement.d.ts.map +1 -0
  51. package/dist/embed/ConvaiChatElement.js +300 -0
  52. package/dist/embed/ConvaiChatElement.js.map +1 -0
  53. package/dist/embed/browser.d.ts +3 -0
  54. package/dist/embed/browser.d.ts.map +1 -0
  55. package/dist/embed/browser.js +5 -0
  56. package/dist/embed/browser.js.map +1 -0
  57. package/dist/embed/chat-embed-v1.js +100 -0
  58. package/dist/embed/chat-embed-v1.js.map +7 -0
  59. package/dist/embed/index.d.ts +9 -0
  60. package/dist/embed/index.d.ts.map +1 -0
  61. package/dist/embed/index.js +9 -0
  62. package/dist/embed/index.js.map +1 -0
  63. package/dist/embed/publicationApi.d.ts +23 -0
  64. package/dist/embed/publicationApi.d.ts.map +1 -0
  65. package/dist/embed/publicationApi.js +141 -0
  66. package/dist/embed/publicationApi.js.map +1 -0
  67. package/dist/react/components/ConvaiWidget.d.ts +11 -1
  68. package/dist/react/components/ConvaiWidget.d.ts.map +1 -1
  69. package/dist/react/components/ConvaiWidget.js +67 -11
  70. package/dist/react/components/ConvaiWidget.js.map +1 -1
  71. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
  72. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +39 -44
  73. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
  74. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts +1 -1
  75. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts.map +1 -1
  76. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js +2 -2
  77. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js.map +1 -1
  78. package/dist/react/hooks/useConvaiClient.d.ts +2 -0
  79. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  80. package/dist/react/hooks/useConvaiClient.js +16 -0
  81. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  82. package/dist/react/index.d.ts +1 -1
  83. package/dist/react/index.d.ts.map +1 -1
  84. package/dist/react/index.js +1 -1
  85. package/dist/react/index.js.map +1 -1
  86. package/dist/utils/inlineMarkdown.d.ts +38 -0
  87. package/dist/utils/inlineMarkdown.d.ts.map +1 -0
  88. package/dist/utils/inlineMarkdown.js +106 -0
  89. package/dist/utils/inlineMarkdown.js.map +1 -0
  90. package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
  91. package/dist/vanilla/AudioRenderer.js +6 -0
  92. package/dist/vanilla/AudioRenderer.js.map +1 -1
  93. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  94. package/dist/vanilla/ConvaiWidget.js +224 -89
  95. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  96. package/dist/vanilla/WidgetEventSubscriptions.d.ts +15 -0
  97. package/dist/vanilla/WidgetEventSubscriptions.d.ts.map +1 -0
  98. package/dist/vanilla/WidgetEventSubscriptions.js +37 -0
  99. package/dist/vanilla/WidgetEventSubscriptions.js.map +1 -0
  100. package/dist/vanilla/index.d.ts +2 -1
  101. package/dist/vanilla/index.d.ts.map +1 -1
  102. package/dist/vanilla/index.js +2 -1
  103. package/dist/vanilla/index.js.map +1 -1
  104. package/dist/vanilla/types.d.ts +8 -1
  105. package/dist/vanilla/types.d.ts.map +1 -1
  106. package/dist/vanilla/types.js.map +1 -1
  107. package/dist/version.d.ts +1 -1
  108. package/dist/version.js +1 -1
  109. package/dist/version.js.map +1 -1
  110. package/package.json +19 -6
@@ -5,7 +5,10 @@
5
5
  import { AudioRenderer } from "./AudioRenderer.js";
6
6
  import { aeroTheme, injectGlobalStyles, injectKeyframes, } from "./styles.js";
7
7
  import { Icons } from "./icons.js";
8
+ import { WidgetEventSubscriptions } from "./WidgetEventSubscriptions.js";
8
9
  import { ConvaiClient } from "../core/ConvaiClient.js";
10
+ import { parseInlineMarkdown } from "../utils/inlineMarkdown.js";
11
+ import { freshPublicationGrantOptions } from "../core/publishedChat.js";
9
12
  /**
10
13
  * Positioning declarations for the widget root, keyed by `placement`. The
11
14
  * `bottom-right` entry must stay byte-identical to the literals it replaces --
@@ -170,7 +173,7 @@ export function createConvaiWidget(container, options) {
170
173
  blendshapeConfig: options.blendshapeConfig,
171
174
  });
172
175
  })();
173
- const { showVideo = true, showScreenShare = true, defaultVoiceMode = true, onConnect, onDisconnect, onMessage, } = options;
176
+ const { showVideo = true, showScreenShare = true, defaultVoiceMode = true, requestPublicationGrant, publishedChatConnectOptions, publishedChatDescriptor, onConnect, onDisconnect, onMessage, } = options;
174
177
  // Unknown/typo'd values fall back to the default rather than throwing --
175
178
  // a bad `placement` attribute on a customer's page must not blank the
176
179
  // widget.
@@ -182,12 +185,14 @@ export function createConvaiWidget(container, options) {
182
185
  // State
183
186
  let isOpen = false;
184
187
  let isSettingsOpen = false;
185
- let isVoiceMode = defaultVoiceMode;
188
+ let isVoiceMode = defaultVoiceMode &&
189
+ !requestPublicationGrant &&
190
+ client.connectionType !== "text";
186
191
  let isMuted = false;
187
192
  let isVideoVisible = false;
188
193
  let inputValue = "";
189
- let characterName = "Character";
190
- let characterImage = "";
194
+ let characterName = publishedChatDescriptor?.display_name || "Character";
195
+ let characterImage = publishedChatDescriptor?.image_url || "";
191
196
  let audioRenderer = null;
192
197
  let hasEnteredDefaultVoiceMode = false;
193
198
  /** True after we have connected at least once; used to call reconnect() instead of connect() when opening after disconnect */
@@ -208,6 +213,9 @@ export function createConvaiWidget(container, options) {
208
213
  * so a cancellation during the await is honoured.
209
214
  */
210
215
  let pendingOpen = false;
216
+ /** Prevent an in-flight publication grant request from connecting after teardown. */
217
+ let isDestroyed = false;
218
+ const eventSubscriptions = new WidgetEventSubscriptions(() => isDestroyed);
211
219
  // DOM elements (will be created below)
212
220
  let rootElement;
213
221
  let morphingContainer;
@@ -236,6 +244,8 @@ export function createConvaiWidget(container, options) {
236
244
  let source = null;
237
245
  // Fetch character info - matches React useCharacterInfo hook
238
246
  const fetchCharacterInfo = async () => {
247
+ if (publishedChatDescriptor)
248
+ return;
239
249
  // Prefer authToken over apiKey, matching the precedence ConvaiClient
240
250
  // uses when building /connect headers -- a client carrying both must
241
251
  // behave identically in both places.
@@ -1203,7 +1213,8 @@ export function createConvaiWidget(container, options) {
1203
1213
  if (inputValue.length > 0) {
1204
1214
  handleSend();
1205
1215
  }
1206
- else {
1216
+ else if (!requestPublicationGrant &&
1217
+ client.connectionType !== "text") {
1207
1218
  // Toggle Voice Mode
1208
1219
  client.sendInterruptMessage();
1209
1220
  try {
@@ -1318,7 +1329,9 @@ export function createConvaiWidget(container, options) {
1318
1329
  const resetIcon = Icons.Redo("md");
1319
1330
  resetIcon.style.width = "18px";
1320
1331
  resetIcon.style.height = "18px";
1321
- settingsRow.appendChild(createOption(resetIcon, "Reset", handleReset));
1332
+ if (!client.isPublishedChatSession || requestPublicationGrant) {
1333
+ settingsRow.appendChild(createOption(resetIcon, "Reset", handleReset));
1334
+ }
1322
1335
  // Video - only show if connection type is video
1323
1336
  const connectionType = client.connectionType;
1324
1337
  if (connectionType === "video" && showVideo) {
@@ -1349,7 +1362,9 @@ export function createConvaiWidget(container, options) {
1349
1362
  const disconnectIcon = document.createElement("span");
1350
1363
  disconnectIcon.innerHTML = "⏻";
1351
1364
  disconnectIcon.style.fontSize = "18px";
1352
- settingsRow.appendChild(createOption(disconnectIcon, "Disconnect", handleDisconnect, false, true));
1365
+ if (!client.isPublishedChatSession || requestPublicationGrant) {
1366
+ settingsRow.appendChild(createOption(disconnectIcon, "Disconnect", handleDisconnect, false, true));
1367
+ }
1353
1368
  tray.appendChild(settingsRow);
1354
1369
  return tray;
1355
1370
  };
@@ -1418,7 +1433,9 @@ export function createConvaiWidget(container, options) {
1418
1433
  const resetIcon = Icons.Redo("md");
1419
1434
  resetIcon.style.width = "18px";
1420
1435
  resetIcon.style.height = "18px";
1421
- settingsRow.appendChild(createOption(resetIcon, "Reset", handleReset));
1436
+ if (!client.isPublishedChatSession || requestPublicationGrant) {
1437
+ settingsRow.appendChild(createOption(resetIcon, "Reset", handleReset));
1438
+ }
1422
1439
  // Video - Always show if showVideo is true and connection type is video
1423
1440
  const connectionType = client.connectionType;
1424
1441
  if (connectionType === "video" && showVideo) {
@@ -1449,7 +1466,9 @@ export function createConvaiWidget(container, options) {
1449
1466
  const disconnectIcon = document.createElement("span");
1450
1467
  disconnectIcon.innerHTML = "⏻";
1451
1468
  disconnectIcon.style.fontSize = "18px";
1452
- settingsRow.appendChild(createOption(disconnectIcon, "Disconnect", handleDisconnect, false, true));
1469
+ if (!client.isPublishedChatSession || requestPublicationGrant) {
1470
+ settingsRow.appendChild(createOption(disconnectIcon, "Disconnect", handleDisconnect, false, true));
1471
+ }
1453
1472
  };
1454
1473
  // Create floating video - Matches React FloatingVideo component
1455
1474
  const createFloatingVideo = () => {
@@ -1642,6 +1661,8 @@ export function createConvaiWidget(container, options) {
1642
1661
  };
1643
1662
  // Event handlers
1644
1663
  const handleToggle = async () => {
1664
+ if (isDestroyed)
1665
+ return;
1645
1666
  if (isOpen || pendingOpen) {
1646
1667
  // Already open, or an open is already in flight: nothing to do. The
1647
1668
  // `pendingOpen` half is what keeps a repeat call from falling through
@@ -1653,24 +1674,54 @@ export function createConvaiWidget(container, options) {
1653
1674
  if (!client.state.isConnected && !client.state.isConnecting) {
1654
1675
  pendingOpen = true;
1655
1676
  try {
1656
- // Use reconnect when opening again after a disconnect (reuses stored config)
1657
- if (hasConnectedBefore && typeof client.reconnect === "function") {
1677
+ if (requestPublicationGrant || client.isPublishedChatSession) {
1678
+ if (!requestPublicationGrant) {
1679
+ throw new Error("Published chat widgets require requestPublicationGrant to start a new session");
1680
+ }
1681
+ if (!client.connectWithPublicationGrant) {
1682
+ throw new Error("This Convai client does not support published chat connections");
1683
+ }
1684
+ const launchToken = await requestPublicationGrant();
1685
+ // destroy() can run while the grant endpoint is in flight. A one-time
1686
+ // grant must not start a session for a widget which no longer exists.
1687
+ if (isDestroyed) {
1688
+ return;
1689
+ }
1690
+ await client.connectWithPublicationGrant(launchToken, freshPublicationGrantOptions(publishedChatConnectOptions));
1691
+ }
1692
+ else if (hasConnectedBefore && typeof client.reconnect === "function") {
1658
1693
  await client.reconnect();
1659
1694
  }
1660
1695
  else {
1661
1696
  await client.connect();
1662
1697
  }
1698
+ if (isDestroyed) {
1699
+ // destroy() can also run after the grant is exchanged but before
1700
+ // Core or LiveKit finishes connecting. Disconnect that late session
1701
+ // instead of leaving an invisible room alive.
1702
+ await client.disconnect();
1703
+ return;
1704
+ }
1663
1705
  // Re-checked *after* the await: a close()/toggle()/launcher click
1664
1706
  // during the connect clears the flag, and that cancellation has to
1665
1707
  // win. The client stays connected either way -- only the panel is
1666
1708
  // held back -- so a later open() takes the already-connected branch
1667
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
+ }
1668
1717
  if (!pendingOpen)
1669
1718
  return;
1670
1719
  setIsOpen(true);
1671
1720
  }
1672
1721
  catch (error) {
1673
- console.error("Failed to connect:", error);
1722
+ if (!isDestroyed) {
1723
+ console.error("Failed to connect:", error);
1724
+ }
1674
1725
  }
1675
1726
  finally {
1676
1727
  pendingOpen = false;
@@ -1696,8 +1747,14 @@ export function createConvaiWidget(container, options) {
1696
1747
  pendingOpen = false;
1697
1748
  setIsOpen(false);
1698
1749
  };
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);
1699
1756
  const handleSend = () => {
1700
- if (inputValue.trim() && client.state.isConnected) {
1757
+ if (inputValue.trim() && isTextInputReady()) {
1701
1758
  client.sendUserTextMessage(inputValue);
1702
1759
  inputValue = "";
1703
1760
  inputElement.value = "";
@@ -1804,6 +1861,8 @@ export function createConvaiWidget(container, options) {
1804
1861
  chatContent.style.pointerEvents = "auto";
1805
1862
  // Auto-enter voice mode if defaultVoiceMode is true and not already entered
1806
1863
  if (defaultVoiceMode &&
1864
+ !requestPublicationGrant &&
1865
+ client.connectionType !== "text" &&
1807
1866
  !hasEnteredDefaultVoiceMode &&
1808
1867
  client.state.isConnected) {
1809
1868
  setTimeout(async () => {
@@ -1847,7 +1906,15 @@ export function createConvaiWidget(container, options) {
1847
1906
  return;
1848
1907
  // Clear previous content
1849
1908
  sendButton.innerHTML = "";
1850
- if (inputValue.length > 0) {
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;
1915
+ if (inputValue.length > 0 ||
1916
+ requestPublicationGrant ||
1917
+ client.connectionType === "text") {
1851
1918
  // Show Send Button
1852
1919
  sendButton.appendChild(Icons.Send("md"));
1853
1920
  sendButton.style.background = `var(--convai-accent, ${aeroTheme.colors.text.primary})`;
@@ -1860,7 +1927,20 @@ export function createConvaiWidget(container, options) {
1860
1927
  // must contrast with each other, since this is the one place they meet.
1861
1928
  sendButton.style.color = "var(--convai-panel-bg, white)";
1862
1929
  sendButton.style.border = "none";
1863
- sendButton.title = "Send";
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
+ }
1864
1944
  }
1865
1945
  else {
1866
1946
  // Show Voice Mode Toggle
@@ -1868,6 +1948,8 @@ export function createConvaiWidget(container, options) {
1868
1948
  sendButton.style.background = "transparent";
1869
1949
  sendButton.style.color = `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1870
1950
  sendButton.style.border = `1px solid ${aeroTheme.colors.neutral[300]}`;
1951
+ sendButton.style.opacity = "1";
1952
+ sendButton.disabled = false;
1871
1953
  sendButton.title = "Voice Mode";
1872
1954
  }
1873
1955
  };
@@ -1997,59 +2079,65 @@ export function createConvaiWidget(container, options) {
1997
2079
  const lines = normalizedText.split("\n");
1998
2080
  lines.forEach((line, lineIndex) => {
1999
2081
  // 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
+ };
2000
2097
  const processLine = (text) => {
2001
2098
  const parts = [];
2002
- let remaining = text;
2003
- // Process bold and italic markdown
2004
- // Bold: **text** or __text__
2005
- // Italic: *text* or _text_
2006
- const markdownRegex = /(\*\*|__)(.*?)\1|\*([^*]+)\*|_([^_]+)_/g;
2007
- let lastIndex = 0;
2008
- let match;
2009
- while ((match = markdownRegex.exec(remaining)) !== null) {
2010
- // Add text before the match
2011
- if (match.index > lastIndex) {
2012
- const textBefore = remaining.substring(lastIndex, match.index);
2013
- if (textBefore) {
2014
- parts.push(document.createTextNode(textBefore));
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;
2015
2111
  }
2016
- }
2017
- // Determine if it's bold or italic
2018
- if (match[1] && match[2]) {
2019
- // Bold text (** or __) - matches React UserBubble styling
2020
- const strong = document.createElement("strong");
2021
- strong.textContent = match[2];
2022
- strong.style.cssText = `
2023
- font-weight: 600;
2024
- color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
2025
- `;
2026
- parts.push(strong);
2027
- }
2028
- else if (match[3]) {
2029
- // Italic text (*) - matches React UserBubble styling
2030
- const em = document.createElement("em");
2031
- em.textContent = match[3];
2032
- em.style.cssText = `
2033
- font-style: italic;
2034
- `;
2035
- parts.push(em);
2036
- }
2037
- else if (match[4]) {
2038
- // Italic text (_) - matches React UserBubble styling
2039
- const em = document.createElement("em");
2040
- em.textContent = match[4];
2041
- em.style.cssText = `
2042
- font-style: italic;
2043
- `;
2044
- parts.push(em);
2045
- }
2046
- lastIndex = match.index + match[0].length;
2047
- }
2048
- // Add remaining text
2049
- if (lastIndex < remaining.length) {
2050
- const textAfter = remaining.substring(lastIndex);
2051
- if (textAfter) {
2052
- parts.push(document.createTextNode(textAfter));
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));
2053
2141
  }
2054
2142
  }
2055
2143
  return parts;
@@ -2263,7 +2351,17 @@ export function createConvaiWidget(container, options) {
2263
2351
  };
2264
2352
  // Setup event listeners for client state changes
2265
2353
  const setupClientListeners = () => {
2266
- client.on("stateChange", () => {
2354
+ 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
+ if (client.connectionType === "text") {
2358
+ if (isVoiceMode) {
2359
+ isVoiceMode = false;
2360
+ hasEnteredDefaultVoiceMode = true;
2361
+ void updateVoiceMode();
2362
+ }
2363
+ updateSendButton();
2364
+ }
2267
2365
  updateHeader();
2268
2366
  // Update pulse animation based on connecting state
2269
2367
  if (client.state.isConnecting) {
@@ -2321,7 +2419,7 @@ export function createConvaiWidget(container, options) {
2321
2419
  });
2322
2420
  // Audio state changes now only affect voice mode (microphone button removed)
2323
2421
  // Video state change listener
2324
- client.videoControls.on("videoStateChange", (videoState) => {
2422
+ eventSubscriptions.register((listener) => client.videoControls.on("videoStateChange", listener), (videoState) => {
2325
2423
  if (videoState.isVideoEnabled !== undefined) {
2326
2424
  isVideoVisible = videoState.isVideoEnabled;
2327
2425
  applyVoiceMode(); // synchronous listener; see applyVoiceMode()
@@ -2374,19 +2472,15 @@ export function createConvaiWidget(container, options) {
2374
2472
  }
2375
2473
  }
2376
2474
  });
2377
- client.on("messagesChange", () => {
2475
+ eventSubscriptions.register((listener) => client.on("messagesChange", listener), () => {
2378
2476
  updateMessageList();
2379
- // Call onMessage callback if provided
2380
- if (onMessage && client.chatMessages.length > 0) {
2381
- const lastMessage = client.chatMessages[client.chatMessages.length - 1];
2382
- onMessage(lastMessage);
2383
- }
2384
2477
  });
2385
2478
  // Bot ready listener - updates UI when bot becomes ready
2386
- client.on("botReady", () => {
2479
+ eventSubscriptions.register((listener) => client.on("botReady", listener), () => {
2387
2480
  updateHeader(); // Update header to show green status
2481
+ updateSendButton();
2388
2482
  });
2389
- client.on("connect", () => {
2483
+ eventSubscriptions.register((listener) => client.on("connect", listener), () => {
2390
2484
  hasConnectedBefore = true;
2391
2485
  // Initialize audio renderer on connection
2392
2486
  // NOTE: the audio elements live under rootElement, so playback ends if
@@ -2404,7 +2498,7 @@ export function createConvaiWidget(container, options) {
2404
2498
  onConnect();
2405
2499
  }
2406
2500
  });
2407
- client.on("disconnect", () => {
2501
+ eventSubscriptions.register((listener) => client.on("disconnect", listener), () => {
2408
2502
  // Cleanup audio renderer
2409
2503
  if (audioRenderer) {
2410
2504
  audioRenderer.destroy();
@@ -2416,6 +2510,42 @@ export function createConvaiWidget(container, options) {
2416
2510
  onDisconnect();
2417
2511
  }
2418
2512
  });
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
+ });
2419
2549
  };
2420
2550
  // Initialize
2421
2551
  createDOM();
@@ -2485,16 +2615,24 @@ export function createConvaiWidget(container, options) {
2485
2615
  // message never reaches the character and is never billed as an LLM
2486
2616
  // turn. The `local-` id keeps it from colliding with a server-issued id.
2487
2617
  appendMessage: (message) => {
2488
- const chatMessage = {
2489
- id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
2490
- type: message.sender === "user" ? "user-llm-text" : "bot-llm-text",
2491
- content: message.content,
2492
- timestamp: new Date().toISOString(),
2493
- };
2494
- client.chatMessages.push(chatMessage);
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
+ }
2495
2632
  updateMessageList();
2496
2633
  },
2497
2634
  destroy: () => {
2635
+ isDestroyed = true;
2498
2636
  // Cleanup audio renderer
2499
2637
  if (audioRenderer) {
2500
2638
  audioRenderer.destroy();
@@ -2509,12 +2647,9 @@ export function createConvaiWidget(container, options) {
2509
2647
  if (floatingVideo.parentElement) {
2510
2648
  floatingVideo.remove();
2511
2649
  }
2512
- // Unsubscribe from client events
2513
- client.off("stateChange", () => { });
2514
- client.off("messagesChange", () => { });
2515
- client.off("botReady", () => { });
2516
- client.off("connect", () => { });
2517
- client.off("disconnect", () => { });
2650
+ // Unsubscribe using the exact callbacks registered above. The guards
2651
+ // also suppress an event that was queued just before teardown.
2652
+ eventSubscriptions.unsubscribeAll();
2518
2653
  },
2519
2654
  };
2520
2655
  return widget;