@memori.ai/memori-react 9.0.0-alpha.5 → 9.0.0-alpha.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 (286) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/components/AgeVerificationModal/AgeVerificationModal.js +8 -3
  3. package/dist/components/AgeVerificationModal/AgeVerificationModal.js.map +1 -1
  4. package/dist/components/Auth/Auth.js +3 -1
  5. package/dist/components/Auth/Auth.js.map +1 -1
  6. package/dist/components/Avatar/Avatar.js +5 -10
  7. package/dist/components/Avatar/Avatar.js.map +1 -1
  8. package/dist/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.js +8 -1
  9. package/dist/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.js.map +1 -1
  10. package/dist/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.js +1 -1
  11. package/dist/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.js.map +1 -1
  12. package/dist/components/Avatar/AvatarView/AvatarComponent/constants.d.ts +2 -0
  13. package/dist/components/Avatar/AvatarView/AvatarComponent/constants.js +14 -5
  14. package/dist/components/Avatar/AvatarView/AvatarComponent/constants.js.map +1 -1
  15. package/dist/components/Chat/Chat.js +3 -1
  16. package/dist/components/Chat/Chat.js.map +1 -1
  17. package/dist/components/ChatBubble/ChatBubble.js +21 -15
  18. package/dist/components/ChatBubble/ChatBubble.js.map +1 -1
  19. package/dist/components/ChatHistoryDrawer/ChatHistory.js +3 -1
  20. package/dist/components/ChatHistoryDrawer/ChatHistory.js.map +1 -1
  21. package/dist/components/CompletionProviderStatus/CompletionProviderStatus.js +6 -3
  22. package/dist/components/CompletionProviderStatus/CompletionProviderStatus.js.map +1 -1
  23. package/dist/components/ContentPreviewModal/ContentPreviewModal.js +3 -1
  24. package/dist/components/ContentPreviewModal/ContentPreviewModal.js.map +1 -1
  25. package/dist/components/DateSelector/DateSelector.d.ts +2 -1
  26. package/dist/components/DateSelector/DateSelector.js +17 -7
  27. package/dist/components/DateSelector/DateSelector.js.map +1 -1
  28. package/dist/components/ExpertsDrawer/ExpertsDrawer.js +3 -1
  29. package/dist/components/ExpertsDrawer/ExpertsDrawer.js.map +1 -1
  30. package/dist/components/FilePreview/FilePreview.js +22 -5
  31. package/dist/components/FilePreview/FilePreview.js.map +1 -1
  32. package/dist/components/KnownFacts/KnownFacts.js +5 -3
  33. package/dist/components/KnownFacts/KnownFacts.js.map +1 -1
  34. package/dist/components/LoginDrawer/LoginDrawer.js +3 -1
  35. package/dist/components/LoginDrawer/LoginDrawer.js.map +1 -1
  36. package/dist/components/MediaWidget/MediaItemWidget.js +14 -12
  37. package/dist/components/MediaWidget/MediaItemWidget.js.map +1 -1
  38. package/dist/components/MediaWidget/MediaItemWidget.utils.d.ts +3 -2
  39. package/dist/components/MediaWidget/MediaItemWidget.utils.js +28 -6
  40. package/dist/components/MediaWidget/MediaItemWidget.utils.js.map +1 -1
  41. package/dist/components/MediaWidget/MediaPreviewModal.js +6 -7
  42. package/dist/components/MediaWidget/MediaPreviewModal.js.map +1 -1
  43. package/dist/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.css +19 -5
  44. package/dist/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.js +4 -1
  45. package/dist/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.js.map +1 -1
  46. package/dist/components/MemoriWidget/MemoriWidget.css +4 -2
  47. package/dist/components/MemoriWidget/MemoriWidget.js +760 -404
  48. package/dist/components/MemoriWidget/MemoriWidget.js.map +1 -1
  49. package/dist/components/SettingsDrawer/SettingsDrawer.js +3 -1
  50. package/dist/components/SettingsDrawer/SettingsDrawer.js.map +1 -1
  51. package/dist/components/StartPanel/StartPanel.js +3 -1
  52. package/dist/components/StartPanel/StartPanel.js.map +1 -1
  53. package/dist/components/UploadButton/UploadButton.js +21 -5
  54. package/dist/components/UploadButton/UploadButton.js.map +1 -1
  55. package/dist/components/UploadButton/UploadDocuments/UploadDocuments.js +126 -60
  56. package/dist/components/UploadButton/UploadDocuments/UploadDocuments.js.map +1 -1
  57. package/dist/components/UploadButton/UploadImages/UploadImages.js +3 -1
  58. package/dist/components/UploadButton/UploadImages/UploadImages.js.map +1 -1
  59. package/dist/components/WhyThisAnswer/WhyThisAnswer.js +3 -1
  60. package/dist/components/WhyThisAnswer/WhyThisAnswer.js.map +1 -1
  61. package/dist/components/layouts/WebsiteAssistant/WebsiteAssistant.js +3 -1
  62. package/dist/components/layouts/WebsiteAssistant/WebsiteAssistant.js.map +1 -1
  63. package/dist/components/layouts/WebsiteAssistant/website-assistant.css +21 -1
  64. package/dist/components/layouts/chat.css +2 -1
  65. package/dist/components/layouts/fullpage.css +31 -8
  66. package/dist/components/layouts/hidden-chat.css +1 -0
  67. package/dist/components/layouts/totem.css +2 -1
  68. package/dist/components/layouts/zoomed-full-body.css +32 -7
  69. package/dist/context/widgetSurfaceContext.d.ts +3 -0
  70. package/dist/context/widgetSurfaceContext.js +11 -0
  71. package/dist/context/widgetSurfaceContext.js.map +1 -0
  72. package/dist/helpers/constants.d.ts +3 -0
  73. package/dist/helpers/constants.js +27 -1
  74. package/dist/helpers/constants.js.map +1 -1
  75. package/dist/helpers/credits.d.ts +5 -2
  76. package/dist/helpers/credits.js +5 -1
  77. package/dist/helpers/credits.js.map +1 -1
  78. package/dist/helpers/message.d.ts +1 -1
  79. package/dist/helpers/message.js +4 -4
  80. package/dist/helpers/message.js.map +1 -1
  81. package/dist/helpers/nats/getNatsConfig.d.ts +9 -0
  82. package/dist/helpers/nats/getNatsConfig.js +65 -0
  83. package/dist/helpers/nats/getNatsConfig.js.map +1 -0
  84. package/dist/helpers/nats/isSessionExpiredError.d.ts +3 -0
  85. package/dist/helpers/nats/isSessionExpiredError.js +36 -0
  86. package/dist/helpers/nats/isSessionExpiredError.js.map +1 -0
  87. package/dist/helpers/nats/useNats.d.ts +12 -0
  88. package/dist/helpers/nats/useNats.js +75 -0
  89. package/dist/helpers/nats/useNats.js.map +1 -0
  90. package/dist/helpers/nats/useNatsSession.d.ts +28 -0
  91. package/dist/helpers/nats/useNatsSession.js +212 -0
  92. package/dist/helpers/nats/useNatsSession.js.map +1 -0
  93. package/dist/helpers/utils.d.ts +21 -0
  94. package/dist/helpers/utils.js +75 -4
  95. package/dist/helpers/utils.js.map +1 -1
  96. package/dist/locales/de.json +1 -0
  97. package/dist/locales/en.json +1 -0
  98. package/dist/locales/es.json +1 -0
  99. package/dist/locales/fr.json +1 -0
  100. package/dist/locales/it.json +1 -0
  101. package/dist/styles.css +1 -1
  102. package/dist/types/integration.d.ts +3 -0
  103. package/dist/types/integration.js +16 -0
  104. package/dist/types/integration.js.map +1 -0
  105. package/dist/version.d.ts +1 -1
  106. package/dist/version.js +1 -1
  107. package/esm/components/AgeVerificationModal/AgeVerificationModal.js +8 -3
  108. package/esm/components/AgeVerificationModal/AgeVerificationModal.js.map +1 -1
  109. package/esm/components/Auth/Auth.js +3 -1
  110. package/esm/components/Auth/Auth.js.map +1 -1
  111. package/esm/components/Avatar/Avatar.js +5 -10
  112. package/esm/components/Avatar/Avatar.js.map +1 -1
  113. package/esm/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.js +9 -2
  114. package/esm/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.js.map +1 -1
  115. package/esm/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.js +2 -2
  116. package/esm/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.js.map +1 -1
  117. package/esm/components/Avatar/AvatarView/AvatarComponent/constants.d.ts +2 -0
  118. package/esm/components/Avatar/AvatarView/AvatarComponent/constants.js +13 -4
  119. package/esm/components/Avatar/AvatarView/AvatarComponent/constants.js.map +1 -1
  120. package/esm/components/Chat/Chat.js +3 -1
  121. package/esm/components/Chat/Chat.js.map +1 -1
  122. package/esm/components/ChatBubble/ChatBubble.js +21 -15
  123. package/esm/components/ChatBubble/ChatBubble.js.map +1 -1
  124. package/esm/components/ChatHistoryDrawer/ChatHistory.js +3 -1
  125. package/esm/components/ChatHistoryDrawer/ChatHistory.js.map +1 -1
  126. package/esm/components/CompletionProviderStatus/CompletionProviderStatus.js +6 -3
  127. package/esm/components/CompletionProviderStatus/CompletionProviderStatus.js.map +1 -1
  128. package/esm/components/ContentPreviewModal/ContentPreviewModal.js +3 -1
  129. package/esm/components/ContentPreviewModal/ContentPreviewModal.js.map +1 -1
  130. package/esm/components/DateSelector/DateSelector.d.ts +2 -1
  131. package/esm/components/DateSelector/DateSelector.js +17 -7
  132. package/esm/components/DateSelector/DateSelector.js.map +1 -1
  133. package/esm/components/ExpertsDrawer/ExpertsDrawer.js +3 -1
  134. package/esm/components/ExpertsDrawer/ExpertsDrawer.js.map +1 -1
  135. package/esm/components/FilePreview/FilePreview.js +24 -7
  136. package/esm/components/FilePreview/FilePreview.js.map +1 -1
  137. package/esm/components/KnownFacts/KnownFacts.js +5 -3
  138. package/esm/components/KnownFacts/KnownFacts.js.map +1 -1
  139. package/esm/components/LoginDrawer/LoginDrawer.js +3 -1
  140. package/esm/components/LoginDrawer/LoginDrawer.js.map +1 -1
  141. package/esm/components/MediaWidget/MediaItemWidget.js +16 -14
  142. package/esm/components/MediaWidget/MediaItemWidget.js.map +1 -1
  143. package/esm/components/MediaWidget/MediaItemWidget.utils.d.ts +3 -2
  144. package/esm/components/MediaWidget/MediaItemWidget.utils.js +27 -6
  145. package/esm/components/MediaWidget/MediaItemWidget.utils.js.map +1 -1
  146. package/esm/components/MediaWidget/MediaPreviewModal.js +6 -7
  147. package/esm/components/MediaWidget/MediaPreviewModal.js.map +1 -1
  148. package/esm/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.css +19 -5
  149. package/esm/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.js +4 -1
  150. package/esm/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.js.map +1 -1
  151. package/esm/components/MemoriWidget/MemoriWidget.css +4 -2
  152. package/esm/components/MemoriWidget/MemoriWidget.js +760 -404
  153. package/esm/components/MemoriWidget/MemoriWidget.js.map +1 -1
  154. package/esm/components/SettingsDrawer/SettingsDrawer.js +3 -1
  155. package/esm/components/SettingsDrawer/SettingsDrawer.js.map +1 -1
  156. package/esm/components/StartPanel/StartPanel.js +3 -1
  157. package/esm/components/StartPanel/StartPanel.js.map +1 -1
  158. package/esm/components/UploadButton/UploadButton.js +21 -5
  159. package/esm/components/UploadButton/UploadButton.js.map +1 -1
  160. package/esm/components/UploadButton/UploadDocuments/UploadDocuments.js +126 -60
  161. package/esm/components/UploadButton/UploadDocuments/UploadDocuments.js.map +1 -1
  162. package/esm/components/UploadButton/UploadImages/UploadImages.js +3 -1
  163. package/esm/components/UploadButton/UploadImages/UploadImages.js.map +1 -1
  164. package/esm/components/WhyThisAnswer/WhyThisAnswer.js +3 -1
  165. package/esm/components/WhyThisAnswer/WhyThisAnswer.js.map +1 -1
  166. package/esm/components/layouts/WebsiteAssistant/WebsiteAssistant.js +3 -1
  167. package/esm/components/layouts/WebsiteAssistant/WebsiteAssistant.js.map +1 -1
  168. package/esm/components/layouts/WebsiteAssistant/website-assistant.css +21 -1
  169. package/esm/components/layouts/chat.css +2 -1
  170. package/esm/components/layouts/fullpage.css +31 -8
  171. package/esm/components/layouts/hidden-chat.css +1 -0
  172. package/esm/components/layouts/totem.css +2 -1
  173. package/esm/components/layouts/zoomed-full-body.css +32 -7
  174. package/esm/context/widgetSurfaceContext.d.ts +3 -0
  175. package/esm/context/widgetSurfaceContext.js +7 -0
  176. package/esm/context/widgetSurfaceContext.js.map +1 -0
  177. package/esm/helpers/constants.d.ts +3 -0
  178. package/esm/helpers/constants.js +26 -0
  179. package/esm/helpers/constants.js.map +1 -1
  180. package/esm/helpers/credits.d.ts +5 -2
  181. package/esm/helpers/credits.js +5 -1
  182. package/esm/helpers/credits.js.map +1 -1
  183. package/esm/helpers/message.d.ts +1 -1
  184. package/esm/helpers/message.js +2 -2
  185. package/esm/helpers/message.js.map +1 -1
  186. package/esm/helpers/nats/getNatsConfig.d.ts +9 -0
  187. package/esm/helpers/nats/getNatsConfig.js +61 -0
  188. package/esm/helpers/nats/getNatsConfig.js.map +1 -0
  189. package/esm/helpers/nats/isSessionExpiredError.d.ts +3 -0
  190. package/esm/helpers/nats/isSessionExpiredError.js +32 -0
  191. package/esm/helpers/nats/isSessionExpiredError.js.map +1 -0
  192. package/esm/helpers/nats/useNats.d.ts +12 -0
  193. package/esm/helpers/nats/useNats.js +72 -0
  194. package/esm/helpers/nats/useNats.js.map +1 -0
  195. package/esm/helpers/nats/useNatsSession.d.ts +28 -0
  196. package/esm/helpers/nats/useNatsSession.js +208 -0
  197. package/esm/helpers/nats/useNatsSession.js.map +1 -0
  198. package/esm/helpers/utils.d.ts +21 -0
  199. package/esm/helpers/utils.js +68 -3
  200. package/esm/helpers/utils.js.map +1 -1
  201. package/esm/locales/de.json +1 -0
  202. package/esm/locales/en.json +1 -0
  203. package/esm/locales/es.json +1 -0
  204. package/esm/locales/fr.json +1 -0
  205. package/esm/locales/it.json +1 -0
  206. package/esm/styles.css +1 -1
  207. package/esm/types/integration.d.ts +3 -0
  208. package/esm/types/integration.js +11 -0
  209. package/esm/types/integration.js.map +1 -0
  210. package/esm/version.d.ts +1 -1
  211. package/esm/version.js +1 -1
  212. package/package.json +5 -3
  213. package/src/components/AgeVerificationModal/AgeVerificationModal.tsx +9 -1
  214. package/src/components/Auth/Auth.tsx +3 -0
  215. package/src/components/Avatar/Avatar.stories.tsx +45 -28
  216. package/src/components/Avatar/Avatar.test.tsx +54 -0
  217. package/src/components/Avatar/Avatar.tsx +8 -12
  218. package/src/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.tsx +12 -5
  219. package/src/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.ts +2 -1
  220. package/src/components/Avatar/AvatarView/AvatarComponent/constants.ts +16 -4
  221. package/src/components/Avatar/__snapshots__/Avatar.test.tsx.snap +164 -0
  222. package/src/components/Chat/Chat.tsx +3 -0
  223. package/src/components/ChatBubble/ChatBubble.tsx +40 -12
  224. package/src/components/ChatHistoryDrawer/ChatHistory.tsx +3 -0
  225. package/src/components/CompletionProviderStatus/CompletionProviderStatus.tsx +8 -4
  226. package/src/components/ContentPreviewModal/ContentPreviewModal.tsx +3 -0
  227. package/src/components/DateSelector/DateSelector.stories.tsx +11 -2
  228. package/src/components/DateSelector/DateSelector.tsx +28 -9
  229. package/src/components/ExpertsDrawer/ExpertsDrawer.tsx +3 -0
  230. package/src/components/FilePreview/FilePreview.tsx +28 -7
  231. package/src/components/KnownFacts/KnownFacts.tsx +5 -0
  232. package/src/components/LoginDrawer/LoginDrawer.tsx +3 -0
  233. package/src/components/MediaWidget/MediaItemWidget.tsx +26 -13
  234. package/src/components/MediaWidget/MediaItemWidget.utils.test.ts +45 -2
  235. package/src/components/MediaWidget/MediaItemWidget.utils.ts +39 -6
  236. package/src/components/MediaWidget/MediaPreviewModal.tsx +9 -7
  237. package/src/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.css +19 -5
  238. package/src/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.tsx +4 -0
  239. package/src/components/MemoriWidget/MemoriWidget.css +4 -2
  240. package/src/components/MemoriWidget/MemoriWidget.tsx +818 -349
  241. package/src/components/SettingsDrawer/SettingsDrawer.tsx +3 -0
  242. package/src/components/StartPanel/StartPanel.stories.tsx +22 -0
  243. package/src/components/StartPanel/StartPanel.test.tsx +64 -1
  244. package/src/components/StartPanel/StartPanel.tsx +3 -0
  245. package/src/components/StartPanel/__snapshots__/StartPanel.test.tsx.snap +261 -0
  246. package/src/components/UploadButton/UploadButton.tsx +30 -11
  247. package/src/components/UploadButton/UploadDocuments/UploadDocuments.tsx +151 -61
  248. package/src/components/UploadButton/UploadImages/UploadImages.tsx +3 -0
  249. package/src/components/UploadButton/__snapshots__/UploadButton.test.tsx.snap +2 -2
  250. package/src/components/WhyThisAnswer/WhyThisAnswer.tsx +3 -0
  251. package/src/components/layouts/WebsiteAssistant/WebsiteAssistant.tsx +4 -0
  252. package/src/components/layouts/WebsiteAssistant/__snapshots__/WebsiteAssistant.test.tsx.snap +48 -44
  253. package/src/components/layouts/WebsiteAssistant/website-assistant.css +21 -1
  254. package/src/components/layouts/__snapshots__/Chat.test.tsx.snap +533 -529
  255. package/src/components/layouts/__snapshots__/FullPage.test.tsx.snap +1058 -1050
  256. package/src/components/layouts/__snapshots__/HiddenChat.test.tsx.snap +443 -439
  257. package/src/components/layouts/__snapshots__/Totem.test.tsx.snap +584 -580
  258. package/src/components/layouts/__snapshots__/ZoomedFullBody.test.tsx.snap +528 -524
  259. package/src/components/layouts/chat.css +2 -1
  260. package/src/components/layouts/fullpage.css +31 -8
  261. package/src/components/layouts/hidden-chat.css +1 -0
  262. package/src/components/layouts/layouts.stories.tsx +131 -12
  263. package/src/components/layouts/totem.css +2 -1
  264. package/src/components/layouts/zoomed-full-body.css +32 -7
  265. package/src/context/widgetSurfaceContext.tsx +14 -0
  266. package/src/helpers/constants.ts +32 -1
  267. package/src/helpers/credits.ts +16 -1
  268. package/src/helpers/message.test.ts +28 -1
  269. package/src/helpers/message.ts +4 -4
  270. package/src/helpers/nats/getNatsConfig.ts +145 -0
  271. package/src/helpers/nats/isSessionExpiredError.test.ts +38 -0
  272. package/src/helpers/nats/isSessionExpiredError.ts +51 -0
  273. package/src/helpers/nats/useNats.ts +124 -0
  274. package/src/helpers/nats/useNatsSession.ts +394 -0
  275. package/src/helpers/utils.test.ts +162 -0
  276. package/src/helpers/utils.ts +108 -3
  277. package/src/index.stories.tsx +32 -0
  278. package/src/locales/de.json +1 -0
  279. package/src/locales/en.json +1 -0
  280. package/src/locales/es.json +1 -0
  281. package/src/locales/fr.json +1 -0
  282. package/src/locales/it.json +1 -0
  283. package/src/styles.css +61 -1
  284. package/src/types/integration.test.ts +32 -0
  285. package/src/types/integration.ts +31 -0
  286. package/src/version.ts +1 -1
@@ -0,0 +1,394 @@
1
+ // helpers/nats/useNatsSession.ts - Subscribe to a session's NATS channel.
2
+ //
3
+ // Opens a W3C WebSocket connection (via `wsconnect`), then either:
4
+ // - core NATS: subscribes to the session subject directly, or
5
+ // - JetStream: consumes from the configured stream (ordered consumer by default).
6
+ //
7
+ // When JetStream is enabled, reconnects on tab visibility / network resume and
8
+ // replays the last message per subject to recover events missed while Safari
9
+ // (or other browsers) suspend the WebSocket in background.
10
+ //
11
+ // The cleanup (`consumerMessages.stop()` + `nc.close()`) is essential: without
12
+ // it, every `sessionId` change would leave orphan connections / iterators.
13
+ import { useEffect, useRef, useState, type MutableRefObject } from 'react';
14
+ import { wsconnect, NatsConnection } from '@nats-io/nats-core';
15
+ import {
16
+ jetstream,
17
+ DeliverPolicy,
18
+ type ConsumerMessages,
19
+ } from '@nats-io/jetstream';
20
+ import type { NatsConfig } from './getNatsConfig';
21
+
22
+ /**
23
+ * `progress` event: job advancement updates.
24
+ */
25
+ export interface NatsProgressEvent {
26
+ eventType: 'progress';
27
+ jobId?: string;
28
+ currentStep?: number;
29
+ finalStep?: number;
30
+ message?: string;
31
+ startingTime?: string;
32
+ correlationID?: string;
33
+ }
34
+
35
+ /**
36
+ * `dialog.text_entered_response` event: same body returned by
37
+ * `POST /memori/v2/EnterTextAsync/{sessionId}` (via NATS).
38
+ */
39
+ export interface NatsDialogResponseEvent {
40
+ eventType: 'dialog_text_entered_response';
41
+ requestID?: string;
42
+ resultCode?: number;
43
+ resultMessage?: string;
44
+ currentState?: any;
45
+ correlationID?: string;
46
+ }
47
+
48
+ /**
49
+ * `error` event.
50
+ */
51
+ export interface NatsErrorEvent {
52
+ eventType: 'error';
53
+ errorCode?: string | number;
54
+ errorMessage?: string;
55
+ backtrace?: string;
56
+ correlationID?: string;
57
+ }
58
+
59
+ /**
60
+ * Discriminated union of all events received on the session subject.
61
+ */
62
+ export type NatsSessionEvent =
63
+ | NatsProgressEvent
64
+ | NatsDialogResponseEvent
65
+ | NatsErrorEvent;
66
+
67
+ type JetStreamConsumeMode = 'live' | 'catch-up';
68
+
69
+ function readString(
70
+ raw: Record<string, unknown>,
71
+ camelKey: string,
72
+ snakeKey: string
73
+ ): string | undefined {
74
+ const value = raw[camelKey] ?? raw[snakeKey];
75
+ return typeof value === 'string' ? value : undefined;
76
+ }
77
+
78
+ function readNumber(
79
+ raw: Record<string, unknown>,
80
+ camelKey: string,
81
+ snakeKey: string
82
+ ): number | undefined {
83
+ const value = raw[camelKey] ?? raw[snakeKey];
84
+ return typeof value === 'number' ? value : undefined;
85
+ }
86
+
87
+ /**
88
+ * Unwraps the `currentState` of a `dialog_text_entered_response`.
89
+ *
90
+ * The async (NATS) response wraps the dialog state one level deeper than the
91
+ * synchronous HTTP response: `currentState` is a `ResponseSpec` that *contains*
92
+ * the actual `DialogState` under a nested `currentState` key. We unwrap it so
93
+ * downstream consumers always receive the `DialogState` directly, while still
94
+ * accepting the already-flat shape for backward compatibility.
95
+ */
96
+ function unwrapDialogState(value: unknown): unknown {
97
+ if (
98
+ value &&
99
+ typeof value === 'object' &&
100
+ 'currentState' in (value as Record<string, unknown>)
101
+ ) {
102
+ return (value as Record<string, unknown>).currentState;
103
+ }
104
+ return value;
105
+ }
106
+
107
+ /**
108
+ * Normalizes raw NATS payloads (snake_case or camelCase) into typed events.
109
+ */
110
+ export function normalizeNatsEvent(raw: Record<string, unknown>): NatsSessionEvent {
111
+ const eventType = (readString(raw, 'eventType', 'event_type') ??
112
+ 'unknown') as NatsSessionEvent['eventType'];
113
+
114
+ const correlationID =
115
+ readString(raw, 'correlationID', 'correlation_id') ??
116
+ readString(raw, 'correlationId', 'correlation_id');
117
+
118
+ if (eventType === 'progress') {
119
+ return {
120
+ eventType: 'progress',
121
+ jobId: readString(raw, 'jobId', 'job_id'),
122
+ currentStep: readNumber(raw, 'currentStep', 'current_step'),
123
+ finalStep: readNumber(raw, 'finalStep', 'final_step'),
124
+ message: typeof raw.message === 'string' ? raw.message : undefined,
125
+ startingTime: readString(raw, 'startingTime', 'starting_time'),
126
+ correlationID,
127
+ };
128
+ }
129
+
130
+ if (eventType === 'dialog_text_entered_response') {
131
+ return {
132
+ eventType: 'dialog_text_entered_response',
133
+ requestID:
134
+ typeof raw.requestID === 'string' ? raw.requestID : undefined,
135
+ resultCode:
136
+ typeof raw.resultCode === 'number' ? raw.resultCode : undefined,
137
+ resultMessage:
138
+ typeof raw.resultMessage === 'string' ? raw.resultMessage : undefined,
139
+ currentState: unwrapDialogState(raw.currentState),
140
+ correlationID,
141
+ };
142
+ }
143
+
144
+ if (eventType === 'error') {
145
+ return {
146
+ eventType: 'error',
147
+ errorCode: (raw.errorCode ?? raw.error_code) as string | number | undefined,
148
+ errorMessage: readString(raw, 'errorMessage', 'error_message'),
149
+ backtrace: typeof raw.backtrace === 'string' ? raw.backtrace : undefined,
150
+ correlationID,
151
+ };
152
+ }
153
+
154
+ return {
155
+ eventType,
156
+ correlationID,
157
+ } as NatsSessionEvent;
158
+ }
159
+
160
+ function decodeMessage(
161
+ subject: string,
162
+ received: number,
163
+ raw: Record<string, unknown>,
164
+ onMessage: (event: NatsSessionEvent) => void
165
+ ) {
166
+ const payload = normalizeNatsEvent(raw);
167
+ console.debug(
168
+ `[NATS] message #${received} on ${subject} (eventType=${payload.eventType})`,
169
+ payload
170
+ );
171
+ onMessage(payload);
172
+ }
173
+
174
+ async function consumeCoreSubscription(
175
+ nc: NatsConnection,
176
+ subject: string,
177
+ onMessage: (event: NatsSessionEvent) => void
178
+ ) {
179
+ const sub = nc.subscribe(subject);
180
+ console.info('[NATS] subscribed to subject', subject);
181
+ let received = 0;
182
+ for await (const msg of sub) {
183
+ received += 1;
184
+ try {
185
+ decodeMessage(
186
+ subject,
187
+ received,
188
+ msg.json<Record<string, unknown>>(),
189
+ onMessage
190
+ );
191
+ } catch (err) {
192
+ console.error('[NATS] message decode error', err);
193
+ }
194
+ }
195
+ console.info(
196
+ `[NATS] subscription on ${subject} closed (received ${received} messages)`
197
+ );
198
+ }
199
+
200
+ async function consumeJetStream(
201
+ nc: NatsConnection,
202
+ config: Pick<NatsConfig, 'stream' | 'consumer'>,
203
+ subject: string,
204
+ mode: JetStreamConsumeMode,
205
+ onMessage: (event: NatsSessionEvent) => void,
206
+ setConsumerMessages: (messages: ConsumerMessages) => void
207
+ ) {
208
+ const js = jetstream(nc);
209
+ const consumer = config.consumer
210
+ ? await js.consumers.get(config.stream!, config.consumer)
211
+ : await js.consumers.get(config.stream!, {
212
+ filter_subjects: [subject],
213
+ deliver_policy:
214
+ mode === 'catch-up'
215
+ ? DeliverPolicy.LastPerSubject
216
+ : DeliverPolicy.New,
217
+ });
218
+
219
+ console.info(
220
+ '[NATS] JetStream consumer ready',
221
+ config.consumer
222
+ ? { stream: config.stream, consumer: config.consumer, subject, mode }
223
+ : { stream: config.stream, subject, mode }
224
+ );
225
+
226
+ const messages = await consumer.consume();
227
+ setConsumerMessages(messages);
228
+ let received = 0;
229
+ for await (const msg of messages) {
230
+ received += 1;
231
+ try {
232
+ decodeMessage(subject, received, msg.json(), onMessage);
233
+ } catch (err) {
234
+ console.error('[NATS] message decode error', err);
235
+ }
236
+ }
237
+ console.info(
238
+ `[NATS] JetStream consumer on ${subject} closed (received ${received} messages)`
239
+ );
240
+ }
241
+
242
+ function closeNatsSession(
243
+ consumerMessagesRef: MutableRefObject<ConsumerMessages | null>,
244
+ connRef: MutableRefObject<NatsConnection | null>
245
+ ) {
246
+ consumerMessagesRef.current?.stop();
247
+ consumerMessagesRef.current = null;
248
+ connRef.current?.close();
249
+ connRef.current = null;
250
+ }
251
+
252
+ /**
253
+ * Open a NATS WebSocket connection, subscribe to the session subject and
254
+ * forward decoded events to `onMessage`.
255
+ *
256
+ * `onMessage` is kept in a ref so callers do not need to memoize it: the
257
+ * connection is only (re)opened when `sessionId`, `config` or `resumeGeneration`
258
+ * change.
259
+ *
260
+ * @param sessionId Current session UUID (subscription is skipped when falsy).
261
+ * @param config Connection config from `/api/nats` (url + token required).
262
+ * @param onMessage Callback invoked for each decoded event.
263
+ */
264
+ export function useNatsSession(
265
+ sessionId: string | undefined,
266
+ config: NatsConfig | undefined,
267
+ onMessage: (event: NatsSessionEvent) => void
268
+ ) {
269
+ const connRef = useRef<NatsConnection | null>(null);
270
+ const consumerMessagesRef = useRef<ConsumerMessages | null>(null);
271
+ const onMessageRef = useRef(onMessage);
272
+ const wasHiddenRef = useRef(false);
273
+ const [resumeGeneration, setResumeGeneration] = useState(0);
274
+
275
+ // Keep the latest callback without retriggering the connection effect.
276
+ useEffect(() => {
277
+ onMessageRef.current = onMessage;
278
+ }, [onMessage]);
279
+
280
+ // Reconnect when the tab returns from background or the network comes back.
281
+ useEffect(() => {
282
+ if (!sessionId || !config?.stream) {
283
+ return;
284
+ }
285
+
286
+ const requestResume = () => {
287
+ if (document.visibilityState !== 'visible') {
288
+ return;
289
+ }
290
+ console.info('[NATS] resuming JetStream after visibility/network change');
291
+ setResumeGeneration(generation => generation + 1);
292
+ };
293
+
294
+ const onVisibilityChange = () => {
295
+ if (document.visibilityState === 'hidden') {
296
+ wasHiddenRef.current = true;
297
+ return;
298
+ }
299
+ if (wasHiddenRef.current) {
300
+ wasHiddenRef.current = false;
301
+ requestResume();
302
+ }
303
+ };
304
+
305
+ const onPageShow = (event: PageTransitionEvent) => {
306
+ if (event.persisted) {
307
+ requestResume();
308
+ }
309
+ };
310
+
311
+ document.addEventListener('visibilitychange', onVisibilityChange);
312
+ window.addEventListener('online', requestResume);
313
+ window.addEventListener('pageshow', onPageShow);
314
+
315
+ return () => {
316
+ document.removeEventListener('visibilitychange', onVisibilityChange);
317
+ window.removeEventListener('online', requestResume);
318
+ window.removeEventListener('pageshow', onPageShow);
319
+ };
320
+ }, [sessionId, config?.stream]);
321
+
322
+ useEffect(() => {
323
+ if (!sessionId || !config?.url || !config.token) {
324
+ console.debug(
325
+ '[NATS] subscription skipped (missing sessionId/url/token)',
326
+ {
327
+ sessionId,
328
+ hasUrl: !!config?.url,
329
+ hasToken: !!config?.token,
330
+ }
331
+ );
332
+ return;
333
+ }
334
+
335
+ let closed = false;
336
+ const subject = config.subject ?? `sessions.${sessionId}`;
337
+ const useJetStream = !!config.stream;
338
+ const jetStreamMode: JetStreamConsumeMode =
339
+ useJetStream && resumeGeneration > 0 ? 'catch-up' : 'live';
340
+
341
+ (async () => {
342
+ console.info(
343
+ '[NATS] connecting to',
344
+ config.url,
345
+ 'for session',
346
+ sessionId,
347
+ useJetStream ? `(JetStream/${jetStreamMode})` : '(core)'
348
+ );
349
+ const nc = await wsconnect({
350
+ servers: [config.url],
351
+ token: config.token,
352
+ });
353
+ if (closed) {
354
+ console.debug(
355
+ '[NATS] connection established after cleanup, closing immediately'
356
+ );
357
+ await nc.close();
358
+ return;
359
+ }
360
+ connRef.current = nc;
361
+ console.info('[NATS] connected to', nc.getServer());
362
+
363
+ const dispatch = (event: NatsSessionEvent) => {
364
+ onMessageRef.current(event);
365
+ };
366
+
367
+ if (useJetStream) {
368
+ await consumeJetStream(
369
+ nc,
370
+ config,
371
+ subject,
372
+ jetStreamMode,
373
+ dispatch,
374
+ messages => {
375
+ consumerMessagesRef.current = messages;
376
+ }
377
+ );
378
+ } else {
379
+ await consumeCoreSubscription(nc, subject, dispatch);
380
+ }
381
+ })().catch(err => console.error('[NATS] connection error', err));
382
+
383
+ return () => {
384
+ console.info('[NATS] cleanup: closing connection for subject', subject);
385
+ closed = true;
386
+ closeNatsSession(consumerMessagesRef, connRef);
387
+ };
388
+ }, [sessionId, config, resumeGeneration]);
389
+
390
+ // Reset catch-up generation when the active session changes.
391
+ useEffect(() => {
392
+ setResumeGeneration(0);
393
+ }, [sessionId]);
394
+ }
@@ -1,9 +1,15 @@
1
1
  import {
2
2
  difference,
3
3
  stripEmojis,
4
+ stripHTML,
4
5
  stripMarkdown,
5
6
  stripOutputTags,
6
7
  escapeHTML,
8
+ extractAttachmentLinks,
9
+ extractAttachmentLink,
10
+ isAssetOnlyDocumentAttachment,
11
+ isOfficeNativeFilename,
12
+ parseDocumentAttachmentsFromMessage,
7
13
  } from './utils';
8
14
 
9
15
  describe('Utils/difference', () => {
@@ -141,6 +147,162 @@ describe('utils/stripOutputTags', () => {
141
147
  });
142
148
  });
143
149
 
150
+ describe('utils/attachment helpers', () => {
151
+ it('extracts multiple attachment links in order', () => {
152
+ const input = [
153
+ '<document_attachment filename="a.docx" type="application/vnd.openxmlformats-officedocument.wordprocessingml.document">',
154
+ '</document_attachment>',
155
+ '<attachment_link>',
156
+ 'https://assets.example.com/a.docx',
157
+ '</attachment_link>',
158
+ '<document_attachment filename="b.pdf" type="application/pdf">',
159
+ 'pdf text',
160
+ '</document_attachment>',
161
+ '<attachment_link>https://assets.example.com/b.txt</attachment_link>',
162
+ ].join('\n');
163
+
164
+ expect(extractAttachmentLinks(input)).toEqual([
165
+ 'https://assets.example.com/a.docx',
166
+ 'https://assets.example.com/b.txt',
167
+ ]);
168
+ });
169
+
170
+ it('extracts a single attachment link', () => {
171
+ const input =
172
+ '<attachment_link>\nhttps://assets.example.com/file.docx\n</attachment_link>';
173
+ expect(extractAttachmentLink(input)).toBe(
174
+ 'https://assets.example.com/file.docx'
175
+ );
176
+ });
177
+
178
+ it('detects office native filenames', () => {
179
+ expect(isOfficeNativeFilename('report.doc')).toBe(true);
180
+ expect(isOfficeNativeFilename('report.docx')).toBe(true);
181
+ expect(isOfficeNativeFilename('budget.xls')).toBe(false);
182
+ expect(isOfficeNativeFilename('template.XLTX')).toBe(true);
183
+ expect(isOfficeNativeFilename('slides.potx')).toBe(true);
184
+ expect(isOfficeNativeFilename('notes.pdf')).toBe(false);
185
+ expect(isOfficeNativeFilename('data.xlsx')).toBe(false);
186
+ });
187
+
188
+ it('detects asset-only document attachments', () => {
189
+ expect(
190
+ isAssetOnlyDocumentAttachment({
191
+ title: 'report.docx',
192
+ content: '',
193
+ url: 'https://assets.example.com/file.docx',
194
+ })
195
+ ).toBe(true);
196
+ expect(
197
+ isAssetOnlyDocumentAttachment({
198
+ title: 'report.docx',
199
+ content: [
200
+ '<document_attachment filename="report.docx" type="application/vnd.openxmlformats-officedocument.wordprocessingml.document">',
201
+ '</document_attachment>',
202
+ '<attachment_link>https://assets.example.com/file.docx</attachment_link>',
203
+ ].join('\n'),
204
+ url: '',
205
+ })
206
+ ).toBe(true);
207
+ expect(
208
+ isAssetOnlyDocumentAttachment({
209
+ content: 'extracted text',
210
+ url: 'https://assets.example.com/file.txt',
211
+ })
212
+ ).toBe(false);
213
+ });
214
+
215
+ it('pairs each document attachment with its adjacent link', () => {
216
+ const input = [
217
+ '<attachment_link>https://assets.example.com/orphan.txt</attachment_link>',
218
+ '<document_attachment filename="a.docx" type="application/vnd.openxmlformats-officedocument.wordprocessingml.document">',
219
+ '</document_attachment>',
220
+ '<attachment_link>https://assets.example.com/a.docx</attachment_link>',
221
+ '<document_attachment filename="b.pdf" type="application/pdf">',
222
+ 'pdf text',
223
+ '</document_attachment>',
224
+ '<attachment_link>https://assets.example.com/b.txt</attachment_link>',
225
+ ].join('\n');
226
+
227
+ expect(parseDocumentAttachmentsFromMessage(input)).toEqual([
228
+ {
229
+ filename: 'a.docx',
230
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
231
+ content: '',
232
+ url: 'https://assets.example.com/a.docx',
233
+ },
234
+ {
235
+ filename: 'b.pdf',
236
+ type: 'application/pdf',
237
+ content: 'pdf text',
238
+ url: 'https://assets.example.com/b.txt',
239
+ },
240
+ ]);
241
+ });
242
+
243
+ it('returns an empty url when no adjacent attachment link exists', () => {
244
+ const input =
245
+ '<document_attachment filename="a.docx" type="application/vnd.openxmlformats-officedocument.wordprocessingml.document"></document_attachment>';
246
+
247
+ expect(parseDocumentAttachmentsFromMessage(input)).toEqual([
248
+ {
249
+ filename: 'a.docx',
250
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
251
+ content: '',
252
+ url: '',
253
+ },
254
+ ]);
255
+ });
256
+ });
257
+
258
+ describe('utils/stripHTML', () => {
259
+ it('strips tags and returns text content', () => {
260
+ expect(stripHTML('<p>Hello <strong>world</strong></p>')).toBe(
261
+ 'Hello world'
262
+ );
263
+ });
264
+
265
+ it('does not retain onerror from partner XSS payload', () => {
266
+ const payload =
267
+ '<img src="immagine-inesistente.jpg" onerror="alert(\'XSS Eseguito!\')">';
268
+ const result = stripHTML(payload);
269
+ expect(result).toBe('');
270
+ expect(result).not.toMatch(/onerror/i);
271
+ // Re-parse result: must not introduce an executable handler attribute
272
+ const reparsed = new DOMParser().parseFromString(result, 'text/html');
273
+ expect(reparsed.querySelector('[onerror]')).toBeNull();
274
+ expect(reparsed.querySelector('img')).toBeNull();
275
+ });
276
+
277
+ it('strips svg onload payloads without leaving handlers', () => {
278
+ const payload = '<svg onload="alert(1)"><text>x</text></svg>';
279
+ const result = stripHTML(payload);
280
+ expect(result).toBe('x');
281
+ expect(result).not.toMatch(/onload/i);
282
+ });
283
+
284
+ it('decodes HTML entities without executing nested markup', () => {
285
+ const encoded =
286
+ '&lt;img src="x" onerror="alert(1)"&gt;hello&lt;/img&gt;';
287
+ const result = stripHTML(encoded);
288
+ expect(result).toContain('hello');
289
+ // Entity-decoded text may contain the string "onerror" as text; ensure no live element
290
+ const reparsed = new DOMParser().parseFromString(
291
+ `<div>${result.replace(/</g, '&lt;')}</div>`,
292
+ 'text/html'
293
+ );
294
+ expect(reparsed.querySelector('[onerror]')).toBeNull();
295
+ });
296
+
297
+ it('handles nested tags', () => {
298
+ expect(stripHTML('<div><span>a</span><b>b</b></div>')).toBe('ab');
299
+ });
300
+
301
+ it('returns empty string for empty input', () => {
302
+ expect(stripHTML('')).toBe('');
303
+ });
304
+ });
305
+
144
306
  describe('utils/parsing combined', () => {
145
307
  it('should remove output tag from real message', () => {
146
308
  const result = escapeHTML(
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useRef, useMemo } from 'react';
2
2
  import { Material, MeshStandardMaterial, SkinnedMesh } from 'three';
3
3
  import * as THREE from 'three';
4
+ import { officeNativeExtensions } from './constants';
4
5
 
5
6
  export const hasTouchscreen = (): boolean => {
6
7
  let hasTouchScreen = false;
@@ -237,6 +238,67 @@ export const stripMarkdown = (text: string) => {
237
238
  return text;
238
239
  };
239
240
 
241
+ export const OFFICE_NATIVE_EXTENSIONS = officeNativeExtensions;
242
+
243
+ export const isOfficeNativeFilename = (filename: string): boolean => {
244
+ const ext = `.${filename.split('.').pop()?.toLowerCase() || ''}`;
245
+ return (officeNativeExtensions as readonly string[]).includes(ext);
246
+ };
247
+
248
+ export type ParsedDocumentAttachment = {
249
+ filename: string;
250
+ type: string;
251
+ content: string;
252
+ url: string;
253
+ };
254
+
255
+ const DOCUMENT_ATTACHMENT_REGEX =
256
+ /<document_attachment filename="([^"]+)" type="([^"]+)">([\s\S]*?)<\/document_attachment>/g;
257
+
258
+ const ATTACHMENT_LINK_AFTER_REGEX =
259
+ /<attachment_link>\s*([\s\S]*?)\s*<\/attachment_link>/;
260
+
261
+ export const parseDocumentAttachmentsFromMessage = (
262
+ text: string
263
+ ): ParsedDocumentAttachment[] => {
264
+ if (!text) return [];
265
+
266
+ const attachments: ParsedDocumentAttachment[] = [];
267
+ const regex = new RegExp(DOCUMENT_ATTACHMENT_REGEX.source, 'g');
268
+ let match;
269
+
270
+ while ((match = regex.exec(text)) !== null) {
271
+ const [, filename, type, content] = match;
272
+ const afterTag = text.slice(match.index + match[0].length);
273
+ const linkMatch = afterTag.match(ATTACHMENT_LINK_AFTER_REGEX);
274
+ const rawUrl = linkMatch?.[1]?.trim() || '';
275
+ const url = /^https?:\/\//.test(rawUrl) ? rawUrl : '';
276
+
277
+ attachments.push({
278
+ filename,
279
+ type,
280
+ content: content.trim(),
281
+ url,
282
+ });
283
+ }
284
+
285
+ return attachments;
286
+ };
287
+
288
+ export const extractAttachmentLinks = (content: string): string[] => {
289
+ return parseDocumentAttachmentsFromMessage(content).map(
290
+ attachment => attachment.url
291
+ );
292
+ };
293
+
294
+ export const extractAttachmentLink = (content: string): string | null => {
295
+ const match = content?.match(
296
+ /<attachment_link>\s*([\s\S]*?)\s*<\/attachment_link>/
297
+ );
298
+ const rawUrl = match?.[1]?.trim() || '';
299
+ return /^https?:\/\//.test(rawUrl) ? rawUrl : null;
300
+ };
301
+
240
302
  export const stripDocumentAttachmentTags = (text: string): string => {
241
303
  const documentAttachmentTagRegex = /<document_attachment filename="([^"]+)" type="([^"]+)">([\s\S]*?)<\/document_attachment>/g;
242
304
  return text
@@ -245,6 +307,36 @@ export const stripDocumentAttachmentTags = (text: string): string => {
245
307
  .replace(/<attachment_link>\s*[\s\S]*?\s*<\/attachment_link>/g, '');
246
308
  };
247
309
 
310
+ export const getDocumentAttachmentAssetUrl = (attachment: {
311
+ content?: string | null;
312
+ url?: string | null;
313
+ }): string =>
314
+ attachment.url?.trim() ||
315
+ extractAttachmentLink(attachment.content || '') ||
316
+ '';
317
+
318
+ export const isAssetOnlyDocumentAttachment = (attachment: {
319
+ title?: string;
320
+ name?: string;
321
+ content?: string | null;
322
+ url?: string | null;
323
+ }): boolean => {
324
+ const filename = attachment.title || attachment.name || '';
325
+ const assetUrl = getDocumentAttachmentAssetUrl(attachment);
326
+
327
+ if (!assetUrl) return false;
328
+
329
+ if (isOfficeNativeFilename(filename)) {
330
+ return true;
331
+ }
332
+
333
+ const strippedContent = attachment.content
334
+ ? stripDocumentAttachmentTags(attachment.content).trim()
335
+ : '';
336
+
337
+ return !strippedContent;
338
+ };
339
+
248
340
  export const stripOutputTags = (text: string): string => {
249
341
  const outputTagRegex = /<output.*?<\/output>/gs;
250
342
 
@@ -271,10 +363,23 @@ export const stripReasoningTags = (text: string) => {
271
363
  return strippedText;
272
364
  };
273
365
 
366
+ /**
367
+ * Strip HTML tags / decode entities without executing scripts or event handlers.
368
+ * Uses an inert DOMParser document (no resource loads / onerror), unlike live innerHTML.
369
+ */
274
370
  export const stripHTML = (text: string) => {
275
- const el = document.createElement('div');
276
- el.innerHTML = text;
277
- return el.textContent || '';
371
+ if (typeof DOMParser !== 'undefined') {
372
+ try {
373
+ return (
374
+ new DOMParser().parseFromString(text, 'text/html').body.textContent ||
375
+ ''
376
+ );
377
+ } catch {
378
+ // fall through
379
+ }
380
+ }
381
+ // Non-DOM / parse failure: strip tags only (does not decode entities)
382
+ return text.replace(/<[^>]*>/g, '');
278
383
  };
279
384
 
280
385
  /** Ensures all <a> tags in HTML open in a new tab (target="_blank" rel="noopener noreferrer"). */