@nextpa/chat-core 0.1.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 (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dist/constants/defaults.d.ts +6 -0
  4. package/dist/constants/defaults.js +69 -0
  5. package/dist/core/chat-manager.d.ts +17 -0
  6. package/dist/core/chat-manager.js +39 -0
  7. package/dist/core/config-manager.d.ts +14 -0
  8. package/dist/core/config-manager.js +75 -0
  9. package/dist/core/connection-manager.d.ts +68 -0
  10. package/dist/core/connection-manager.js +273 -0
  11. package/dist/core/connection-retry-handler.d.ts +11 -0
  12. package/dist/core/connection-retry-handler.js +33 -0
  13. package/dist/core/consent-manager.d.ts +15 -0
  14. package/dist/core/consent-manager.js +40 -0
  15. package/dist/core/conversation-persistence.d.ts +23 -0
  16. package/dist/core/conversation-persistence.js +109 -0
  17. package/dist/core/event-emitter.d.ts +7 -0
  18. package/dist/core/event-emitter.js +27 -0
  19. package/dist/core/greeting-gate.d.ts +16 -0
  20. package/dist/core/greeting-gate.js +53 -0
  21. package/dist/core/index.d.ts +9 -0
  22. package/dist/core/index.js +7 -0
  23. package/dist/core/link-target.d.ts +15 -0
  24. package/dist/core/link-target.js +64 -0
  25. package/dist/core/local-storage-persistence.d.ts +11 -0
  26. package/dist/core/local-storage-persistence.js +111 -0
  27. package/dist/core/navigation-manager.d.ts +24 -0
  28. package/dist/core/navigation-manager.js +132 -0
  29. package/dist/core/popover-manager.d.ts +14 -0
  30. package/dist/core/popover-manager.js +36 -0
  31. package/dist/core/session-beacon.d.ts +11 -0
  32. package/dist/core/session-beacon.js +60 -0
  33. package/dist/core/text-connection-manager.d.ts +58 -0
  34. package/dist/core/text-connection-manager.js +200 -0
  35. package/dist/core/text-mode-manager.d.ts +50 -0
  36. package/dist/core/text-mode-manager.js +154 -0
  37. package/dist/core/text-types.d.ts +20 -0
  38. package/dist/core/text-types.js +1 -0
  39. package/dist/core/text-widget-controller.d.ts +47 -0
  40. package/dist/core/text-widget-controller.js +278 -0
  41. package/dist/core/theme-manager.d.ts +10 -0
  42. package/dist/core/theme-manager.js +25 -0
  43. package/dist/core/types.d.ts +50 -0
  44. package/dist/core/types.js +1 -0
  45. package/dist/core/voice-state-manager.d.ts +85 -0
  46. package/dist/core/voice-state-manager.js +317 -0
  47. package/dist/core/widget-controller.d.ts +58 -0
  48. package/dist/core/widget-controller.js +533 -0
  49. package/dist/core/widget-core.d.ts +51 -0
  50. package/dist/core/widget-core.js +160 -0
  51. package/dist/google.d.ts +1 -0
  52. package/dist/google.js +12 -0
  53. package/dist/index.d.ts +37 -0
  54. package/dist/index.js +40 -0
  55. package/dist/openai.d.ts +1 -0
  56. package/dist/openai.js +7 -0
  57. package/dist/services/api.d.ts +114 -0
  58. package/dist/services/api.js +336 -0
  59. package/dist/services/index.d.ts +3 -0
  60. package/dist/services/index.js +3 -0
  61. package/dist/services/page-interaction/constants.d.ts +1 -0
  62. package/dist/services/page-interaction/constants.js +1 -0
  63. package/dist/services/page-interaction/content-extractor.d.ts +2 -0
  64. package/dist/services/page-interaction/content-extractor.js +104 -0
  65. package/dist/services/page-interaction/deep-query.d.ts +8 -0
  66. package/dist/services/page-interaction/deep-query.js +44 -0
  67. package/dist/services/page-interaction/dom-executor.d.ts +7 -0
  68. package/dist/services/page-interaction/dom-executor.js +172 -0
  69. package/dist/services/page-interaction/dom-extractor.d.ts +8 -0
  70. package/dist/services/page-interaction/dom-extractor.js +252 -0
  71. package/dist/services/page-interaction/extension-bridge.d.ts +10 -0
  72. package/dist/services/page-interaction/extension-bridge.js +86 -0
  73. package/dist/services/page-interaction/iframe-navigation-manager.d.ts +28 -0
  74. package/dist/services/page-interaction/iframe-navigation-manager.js +154 -0
  75. package/dist/services/page-interaction/index.d.ts +5 -0
  76. package/dist/services/page-interaction/index.js +4 -0
  77. package/dist/services/page-interaction/page-interaction-handler.d.ts +21 -0
  78. package/dist/services/page-interaction/page-interaction-handler.js +129 -0
  79. package/dist/services/page-interaction/selector-generator.d.ts +1 -0
  80. package/dist/services/page-interaction/selector-generator.js +48 -0
  81. package/dist/services/page-interaction/types.d.ts +75 -0
  82. package/dist/services/page-interaction/types.js +1 -0
  83. package/dist/services/realtime/google/google-audio.d.ts +44 -0
  84. package/dist/services/realtime/google/google-audio.js +410 -0
  85. package/dist/services/realtime/google/google-provider.d.ts +47 -0
  86. package/dist/services/realtime/google/google-provider.js +465 -0
  87. package/dist/services/realtime/google/google-tool-mapper.d.ts +3 -0
  88. package/dist/services/realtime/google/google-tool-mapper.js +70 -0
  89. package/dist/services/realtime/google/index.d.ts +1 -0
  90. package/dist/services/realtime/google/index.js +1 -0
  91. package/dist/services/realtime/openai/index.d.ts +1 -0
  92. package/dist/services/realtime/openai/index.js +1 -0
  93. package/dist/services/realtime/openai/openai-audio-monitor.d.ts +1 -0
  94. package/dist/services/realtime/openai/openai-audio-monitor.js +55 -0
  95. package/dist/services/realtime/openai/openai-provider.d.ts +50 -0
  96. package/dist/services/realtime/openai/openai-provider.js +579 -0
  97. package/dist/services/realtime/openai/openai-tool-mapper.d.ts +15 -0
  98. package/dist/services/realtime/openai/openai-tool-mapper.js +14 -0
  99. package/dist/services/realtime/provider-factory.d.ts +4 -0
  100. package/dist/services/realtime/provider-factory.js +12 -0
  101. package/dist/services/realtime/types.d.ts +78 -0
  102. package/dist/services/realtime/types.js +1 -0
  103. package/dist/services/realtime/vertexai/index.d.ts +1 -0
  104. package/dist/services/realtime/vertexai/index.js +1 -0
  105. package/dist/services/realtime/vertexai/vertexai-provider.d.ts +53 -0
  106. package/dist/services/realtime/vertexai/vertexai-provider.js +561 -0
  107. package/dist/services/realtime/voice-session-tracker.d.ts +10 -0
  108. package/dist/services/realtime/voice-session-tracker.js +62 -0
  109. package/dist/services/signalr.d.ts +37 -0
  110. package/dist/services/signalr.js +145 -0
  111. package/dist/services/thinking-audio-pulse.d.ts +2 -0
  112. package/dist/services/thinking-audio-pulse.js +72 -0
  113. package/dist/services/thinking-audio.d.ts +2 -0
  114. package/dist/services/thinking-audio.js +105 -0
  115. package/dist/services/voice-health-watchdog.d.ts +42 -0
  116. package/dist/services/voice-health-watchdog.js +90 -0
  117. package/dist/services/webrtc-event-handler.d.ts +4 -0
  118. package/dist/services/webrtc-event-handler.js +42 -0
  119. package/dist/services/webrtc-greeting.d.ts +3 -0
  120. package/dist/services/webrtc-greeting.js +62 -0
  121. package/dist/services/webrtc-microphone.d.ts +15 -0
  122. package/dist/services/webrtc-microphone.js +64 -0
  123. package/dist/services/webrtc.d.ts +44 -0
  124. package/dist/services/webrtc.js +196 -0
  125. package/dist/types/index.d.ts +151 -0
  126. package/dist/types/index.js +1 -0
  127. package/dist/utils/audio-context.d.ts +11 -0
  128. package/dist/utils/audio-context.js +33 -0
  129. package/dist/utils/connect-chime.d.ts +11 -0
  130. package/dist/utils/connect-chime.js +230 -0
  131. package/dist/utils/consent.d.ts +4 -0
  132. package/dist/utils/consent.js +30 -0
  133. package/dist/utils/language-detection.d.ts +1 -0
  134. package/dist/utils/language-detection.js +17 -0
  135. package/dist/utils/logger.d.ts +13 -0
  136. package/dist/utils/logger.js +40 -0
  137. package/dist/utils/mic-level-analyser.d.ts +13 -0
  138. package/dist/utils/mic-level-analyser.js +50 -0
  139. package/dist/utils/proactive-events.d.ts +5 -0
  140. package/dist/utils/proactive-events.js +1 -0
  141. package/dist/utils/styles.d.ts +4 -0
  142. package/dist/utils/styles.js +35 -0
  143. package/dist/utils/theme-detection.d.ts +3 -0
  144. package/dist/utils/theme-detection.js +121 -0
  145. package/dist/utils/tool-status.d.ts +10 -0
  146. package/dist/utils/tool-status.js +61 -0
  147. package/dist/utils/ui-sounds.d.ts +3 -0
  148. package/dist/utils/ui-sounds.js +57 -0
  149. package/package.json +56 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 crosslabs GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @nextpa/chat-core
2
+
3
+ Framework-neutral voice & text chat engine for the NextPA platform:
4
+ realtime voice providers (OpenAI / Google / VertexAI), text + voice conversation
5
+ orchestration, backend API/auth client, SignalR, audio and WebRTC handling.
6
+
7
+ ## Entry points
8
+
9
+ | Import | Contents |
10
+ |---|---|
11
+ | `@nextpa/chat-core` | Headless engine — controllers, API client, SignalR, WebRTC, page interaction, provider registry. **No provider SDK references.** |
12
+ | `@nextpa/chat-core/openai` | Side-effect entry: registers the OpenAI realtime provider (lazy loader — the SDK stays in its own async chunk). |
13
+ | `@nextpa/chat-core/google` | Side-effect entry: registers both Google Live providers (`google` + `vertexai`). |
14
+
15
+ Apps import the provider entries for the providers they use (the internal apps
16
+ import both — "works with whatever the backend says"):
17
+
18
+ ```ts
19
+ import '@nextpa/chat-core/openai';
20
+ import '@nextpa/chat-core/google';
21
+ ```
22
+
23
+ Provider SDKs (`@openai/agents-realtime`, `@google/genai`) are **optional peer
24
+ dependencies** — install only what you import.
25
+
26
+ ## Auth
27
+
28
+ `WidgetApiClient` acquires its bearer token through a pluggable `tokenProvider`:
29
+
30
+ - **Default (turnkey):** pass `apiKey` — the public widget key (public by design,
31
+ see `.claude/docs/widget.md` § Threat Model) is exchanged for a short-lived JWT
32
+ via `/api/widget/authenticate`.
33
+ - **Custom (server-minted):** pass `tokenProvider: (ctx) => Promise<WidgetTokenResult>`
34
+ — e.g. a partner backend that mints/exchanges the token server-side, so no
35
+ long-lived secret ever reaches the browser. Called with `{ baseUrl, clientId }`
36
+ and re-invoked automatically when the token expires.
37
+
38
+ ```ts
39
+ new WidgetApiClient({ apiKey: 'npa_…' }); // turnkey
40
+ new WidgetApiClient({ baseUrl, tokenProvider: myBackendMint }); // partner backend
41
+ ```
42
+
43
+ ⚠ Never add `"sideEffects": false` to `package.json`: provider registration
44
+ runs via side-effect modules and would be tree-shaken away
45
+ (see the `widget-embedding` capability under `openspec/specs/`).
46
+
47
+ ## Targets
48
+
49
+ - `nx test chat-core` — unit tests (Vitest)
50
+ - `nx run chat-core:typecheck` — `tsc --noEmit`
51
+ - `nx run chat-core:build` — emits ESM + `.d.ts` to `dist/` (the `exports` map
52
+ in `package.json` points there; not published yet — consumed via tsconfig
53
+ path aliases)
@@ -0,0 +1,6 @@
1
+ import type { WidgetConfig, WidgetDesignTokens } from '../types';
2
+ export declare const DEFAULT_DESIGN_TOKENS: WidgetDesignTokens;
3
+ export declare const DEFAULT_LOGO_DATA_URL: string;
4
+ export declare let isDemoMode: boolean;
5
+ export declare function setDemoMode(enabled: boolean): void;
6
+ export declare function getDemoConfig(): WidgetConfig | null;
@@ -0,0 +1,69 @@
1
+ export const DEFAULT_DESIGN_TOKENS = {
2
+ primaryColor: '#3B82F6',
3
+ primaryColorHover: '#2563EB',
4
+ textOnPrimary: '#FFFFFF',
5
+ secondaryColor: '#6B7280',
6
+ secondaryColorHover: '#4B5563',
7
+ textOnSecondary: '#FFFFFF',
8
+ };
9
+ const DEFAULT_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 1024"><desc>Created with Fabric.js 5.3.0</desc> <defs> </defs> <g transform="matrix(4.517995572352866 0 0 4.517995572352866 639.1695563366901 511.71527645829366)" id="logo-logo" > <g style="" paint-order="stroke" > <g transform="matrix(0.28513151961095523 0 0 0.28513151961095523 35.70408416229856 -70.09827288048706)" > <g style="" paint-order="stroke" > <g transform="matrix(1 0 0 1 0 0)" > <polygon style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(210,48,94); fill-rule: nonzero; opacity: 1;" paint-order="stroke" points="-100,-100 -100,100 100,100 " /> </g> </g> </g> <g transform="matrix(0.3655894025532038 0 0 0.3655894025532038 52.88960752035342 -52.91281898441855)" > <g style="" paint-order="stroke" > <g transform="matrix(1 0 0 1 0 0)" > <path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(210,48,94); fill-rule: nonzero; opacity: 1;" paint-order="stroke" transform=" translate(-125, -124.99981)" d="M 250 0 L 250 0 C 250 138.07089 138.07127 249.99962 0 249.99962 L 0 143.75606 C 76.0835 143.75606 137.76118 79.39407999999999 137.76118 0 L 137.76118 0 L 250 0 z" stroke-linecap="round" /> </g> </g> </g> <g transform="matrix(0 -0.28513151961095523 0.28513151961095523 0 -70.54771267927691 -35.72729562636363)" > <g style="" paint-order="stroke" > <g transform="matrix(1 0 0 1 0 0)" > <polygon style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(137,33,167); fill-rule: nonzero; opacity: 1;" paint-order="stroke" points="-100,-100 -100,100 100,100 " /> </g> </g> </g> <g transform="matrix(0 -0.3655894025532038 0.3655894025532038 0 -53.36225878320852 -52.91281898441861)" > <g style="" paint-order="stroke" > <g transform="matrix(1 0 0 1 0 0)" > <path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(137,33,167); fill-rule: nonzero; opacity: 1;" paint-order="stroke" transform=" translate(-125, -124.99981)" d="M 250 0 L 250 0 C 250 138.07089 138.07127 249.99962 0 249.99962 L 0 143.75606 C 76.0835 143.75606 137.76118 79.39407999999999 137.76118 0 L 137.76118 0 L 250 0 z" stroke-linecap="round" /> </g> </g> </g> <g transform="matrix(-0.28513151961095523 0 0 -0.28513151961095523 -35.70408416229856 70.09827288048706)" > <g style="" paint-order="stroke" > <g transform="matrix(1 0 0 1 0 0)" > <polygon style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(237,108,45); fill-rule: nonzero; opacity: 1;" paint-order="stroke" points="-100,-100 -100,100 100,100 " /> </g> </g> </g> <g transform="matrix(-0.3655894025532038 0 0 -0.3655894025532038 -52.88960752035342 52.91281898441855)" > <g style="" paint-order="stroke" > <g transform="matrix(1 0 0 1 0 0)" > <path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(237,108,45); fill-rule: nonzero; opacity: 1;" paint-order="stroke" transform=" translate(-125, -124.99981)" d="M 250 0 L 250 0 C 250 138.07089 138.07127 249.99962 0 249.99962 L 0 143.75606 C 76.0835 143.75606 137.76118 79.39407999999999 137.76118 0 L 137.76118 0 L 250 0 z" stroke-linecap="round" /> </g> </g> </g> <g transform="matrix(0 0.28513151961095523 -0.28513151961095523 0 70.54771267927697 35.727295626363684)" > <g style="" paint-order="stroke" > <g transform="matrix(1 0 0 1 0 0)" > <polygon style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(243,177,62); fill-rule: nonzero; opacity: 1;" paint-order="stroke" points="-100,-100 -100,100 100,100 " /> </g> </g> </g> <g transform="matrix(0 0.3655894025532038 -0.3655894025532038 0 53.36225878320852 52.91281898441861)" > <g style="" paint-order="stroke" > <g transform="matrix(1 0 0 1 0 0)" > <path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(243,177,62); fill-rule: nonzero; opacity: 1;" paint-order="stroke" transform=" translate(-125, -124.99981)" d="M 250 0 L 250 0 C 250 138.07089 138.07127 249.99962 0 249.99962 L 0 143.75606 C 76.0835 143.75606 137.76118 79.39407999999999 137.76118 0 L 137.76118 0 L 250 0 z" stroke-linecap="round" /> </g> </g> </g> </g> </g></svg>`;
10
+ export const DEFAULT_LOGO_DATA_URL = `data:image/svg+xml;base64,${btoa(DEFAULT_LOGO_SVG)}`;
11
+ // Demo mode is off by default; the host app opts in at bootstrap (e.g. the
12
+ // widget's dev page via VITE_DEMO_MODE). Exported as a live binding so engine
13
+ // modules that imported it before setDemoMode() ran still see the update.
14
+ export let isDemoMode = false;
15
+ export function setDemoMode(enabled) {
16
+ isDemoMode = enabled;
17
+ }
18
+ function createDemoConfig() {
19
+ if (!isDemoMode) {
20
+ return null;
21
+ }
22
+ return {
23
+ assistantId: '00000000-0000-0000-0000-000000000000',
24
+ tenantId: '00000000-0000-0000-0000-000000000000',
25
+ name: 'Demo Assistant',
26
+ welcomeMessage: 'Hallo! Ich bin ein Demo-Assistent. Wie kann ich Ihnen helfen?',
27
+ logoUrl: DEFAULT_LOGO_DATA_URL,
28
+ theme: 'dark',
29
+ size: 'compact',
30
+ placement: 'bottom-right',
31
+ largeTeaserText: 'Sprechen Sie mich einfach an.',
32
+ largeCtaText: 'Chat starten',
33
+ inputPlaceholder: 'Nachricht eingeben...',
34
+ startButtonText: 'Chat starten',
35
+ connectingText: 'Verbinde...',
36
+ listeningText: 'Ich höre zu..',
37
+ preparingText: 'Einen Moment bitte...',
38
+ sendButtonText: 'Senden',
39
+ disconnectText: 'Gespräch beenden',
40
+ mutedText: 'Stummgeschaltet',
41
+ unmuteText: 'Stummschaltung aufheben',
42
+ minimizeText: 'Minimieren',
43
+ resetText: 'Gespräch zurücksetzen',
44
+ resetConfirmText: 'Gespräch zurücksetzen? Der Assistent wird sich nicht mehr an vorherige Unterhaltungen erinnern.',
45
+ resetConfirmButtonText: 'Zurücksetzen',
46
+ resetCancelButtonText: 'Abbrechen',
47
+ errorText: 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.',
48
+ microphoneBlockedText: 'Mikrofonzugriff blockiert',
49
+ microphoneBlockedHintText: 'Bitte erlauben Sie den Mikrofonzugriff in Ihren Browser-Einstellungen (Klick auf das Schloss-Symbol in der Adressleiste) und versuchen Sie es erneut.',
50
+ voiceButtonText: 'Sprachassistent starten',
51
+ textButtonText: 'Text-Chat starten',
52
+ copyText: 'Kopieren',
53
+ copiedText: 'Kopiert!',
54
+ textChatFallbackText: 'Als Text-Chat fortfahren',
55
+ designTokens: DEFAULT_DESIGN_TOKENS,
56
+ popoverEnabled: true,
57
+ greetingEnabled: true,
58
+ popoverAutoCloseSeconds: 5,
59
+ popoverText: 'Haben Sie Fragen? Ich helfe Ihnen gerne!',
60
+ language: 'de',
61
+ };
62
+ }
63
+ // Computed lazily: setDemoMode() runs at host bootstrap, after this module
64
+ // initialised — a module-init const would be pinned to the default (off).
65
+ let demoConfig;
66
+ export function getDemoConfig() {
67
+ demoConfig ??= createDemoConfig();
68
+ return demoConfig;
69
+ }
@@ -0,0 +1,17 @@
1
+ import type { ChatMessage, FeedbackRating } from '../types';
2
+ import { EventEmitter } from './event-emitter';
3
+ interface ChatEvents {
4
+ messageAdded: (message: ChatMessage) => void;
5
+ messagesCleared: () => void;
6
+ feedbackChanged: (messageId: string, rating: FeedbackRating | undefined) => void;
7
+ }
8
+ export declare class ChatManager extends EventEmitter<ChatEvents> {
9
+ private messages;
10
+ addMessage(role: 'user' | 'assistant', content: string, id?: string): ChatMessage;
11
+ addUserMessage(content: string, id?: string): ChatMessage;
12
+ addAssistantMessage(content: string, id?: string): ChatMessage;
13
+ setFeedback(messageId: string, rating: FeedbackRating | undefined): void;
14
+ getMessages(): ChatMessage[];
15
+ clearMessages(): void;
16
+ }
17
+ export {};
@@ -0,0 +1,39 @@
1
+ import { EventEmitter } from './event-emitter';
2
+ export class ChatManager extends EventEmitter {
3
+ messages = [];
4
+ addMessage(role, content, id) {
5
+ if (!content.trim()) {
6
+ throw new Error('Message content cannot be empty');
7
+ }
8
+ const message = {
9
+ id: id ?? crypto.randomUUID(),
10
+ role,
11
+ content: content.trim(),
12
+ timestamp: new Date(),
13
+ };
14
+ this.messages = [...this.messages, message];
15
+ this.emit('messageAdded', message);
16
+ return message;
17
+ }
18
+ addUserMessage(content, id) {
19
+ return this.addMessage('user', content, id);
20
+ }
21
+ addAssistantMessage(content, id) {
22
+ return this.addMessage('assistant', content, id);
23
+ }
24
+ setFeedback(messageId, rating) {
25
+ const message = this.messages.find(m => m.id === messageId);
26
+ if (!message)
27
+ return;
28
+ message.feedback = rating;
29
+ this.messages = [...this.messages];
30
+ this.emit('feedbackChanged', messageId, rating);
31
+ }
32
+ getMessages() {
33
+ return this.messages;
34
+ }
35
+ clearMessages() {
36
+ this.messages = [];
37
+ this.emit('messagesCleared');
38
+ }
39
+ }
@@ -0,0 +1,14 @@
1
+ import type { WidgetConfig } from '../types';
2
+ import type { WidgetApiClient } from '../services/api';
3
+ export declare class ConfigManager {
4
+ private config;
5
+ private readonly apiClient;
6
+ constructor(apiClient: WidgetApiClient);
7
+ loadConfig(): Promise<WidgetConfig>;
8
+ getConfig(): WidgetConfig | null;
9
+ isDemoMode(): boolean;
10
+ private normalizeConfig;
11
+ private normalizeTheme;
12
+ private normalizeSize;
13
+ private normalizePlacement;
14
+ }
@@ -0,0 +1,75 @@
1
+ import { getDemoConfig, isDemoMode } from '../constants/defaults';
2
+ import { logger } from '../utils/logger';
3
+ export class ConfigManager {
4
+ config = null;
5
+ apiClient;
6
+ constructor(apiClient) {
7
+ this.apiClient = apiClient;
8
+ }
9
+ async loadConfig() {
10
+ const demoConfig = isDemoMode ? getDemoConfig() : null;
11
+ if (demoConfig) {
12
+ logger.debug('[ConfigManager] Running in demo mode');
13
+ this.config = demoConfig;
14
+ return this.config;
15
+ }
16
+ const rawConfig = await this.apiClient.getWidgetConfig();
17
+ this.config = this.normalizeConfig(rawConfig);
18
+ logger.debug('[ConfigManager] Config loaded:', this.config.name);
19
+ return this.config;
20
+ }
21
+ getConfig() {
22
+ return this.config;
23
+ }
24
+ isDemoMode() {
25
+ return isDemoMode;
26
+ }
27
+ normalizeConfig(config) {
28
+ return {
29
+ ...config,
30
+ unmuteText: config.unmuteText || 'Unmute',
31
+ minimizeText: config.minimizeText || 'Minimize',
32
+ voiceButtonText: config.voiceButtonText || config.startButtonText,
33
+ textButtonText: config.textButtonText || config.startButtonText,
34
+ copyText: config.copyText || 'Copy',
35
+ copiedText: config.copiedText || 'Copied!',
36
+ theme: this.normalizeTheme(config.theme),
37
+ size: this.normalizeSize(config.size),
38
+ placement: this.normalizePlacement(config.placement),
39
+ };
40
+ }
41
+ normalizeTheme(theme) {
42
+ const normalized = String(theme).toLowerCase();
43
+ if (normalized === 'light')
44
+ return 'light';
45
+ if (normalized === 'auto')
46
+ return 'auto';
47
+ return 'dark';
48
+ }
49
+ normalizeSize(size) {
50
+ const normalized = String(size).toLowerCase();
51
+ if (normalized === 'small')
52
+ return 'small';
53
+ if (normalized === 'large')
54
+ return 'large';
55
+ return 'compact';
56
+ }
57
+ normalizePlacement(placement) {
58
+ const normalized = String(placement).toLowerCase();
59
+ const mapping = {
60
+ 'topleft': 'top-left',
61
+ 'top-left': 'top-left',
62
+ 'topcenter': 'top-center',
63
+ 'top-center': 'top-center',
64
+ 'topright': 'top-right',
65
+ 'top-right': 'top-right',
66
+ 'bottomleft': 'bottom-left',
67
+ 'bottom-left': 'bottom-left',
68
+ 'bottomcenter': 'bottom-center',
69
+ 'bottom-center': 'bottom-center',
70
+ 'bottomright': 'bottom-right',
71
+ 'bottom-right': 'bottom-right',
72
+ };
73
+ return mapping[normalized] ?? 'bottom-right';
74
+ }
75
+ }
@@ -0,0 +1,68 @@
1
+ import type { ConnectionState, FunctionCall, VoiceUsage, WidgetConfig } from '../types';
2
+ import type { WidgetApiClient } from '../services/api';
3
+ import type { DocumentProvider, WindowProvider, ExternalExecutor } from '../services/page-interaction/page-interaction-handler';
4
+ import { EventEmitter } from './event-emitter';
5
+ interface ConnectionEvents {
6
+ connectionStateChanged: (state: ConnectionState) => void;
7
+ unexpectedDisconnect: () => void;
8
+ voiceUnhealthy: (reason: string) => void;
9
+ voiceHealthy: () => void;
10
+ userSpeechStart: () => void;
11
+ userSpeechStop: () => void;
12
+ userSpeechEnd: () => void;
13
+ audioLevel: (level: number) => void;
14
+ transcriptReceived: (data: {
15
+ isUser: boolean;
16
+ text: string;
17
+ }) => void;
18
+ transcriptDelta: (data: {
19
+ isUser: boolean;
20
+ delta: string;
21
+ }) => void;
22
+ functionCall: (call: FunctionCall) => void;
23
+ usageUpdated: (usage: VoiceUsage) => void;
24
+ toolCall: (data: {
25
+ toolName: string;
26
+ status: string;
27
+ }) => void;
28
+ error: (error: string) => void;
29
+ microphoneBlocked: () => void;
30
+ stopConversation: () => void;
31
+ openUrl: (url: string) => void;
32
+ openFile: (url: string) => void;
33
+ microphoneReady: () => void;
34
+ }
35
+ export declare class ConnectionManager extends EventEmitter<ConnectionEvents> {
36
+ private webrtcService;
37
+ private signalRService;
38
+ private pageInteractionHandler;
39
+ private readonly apiClient;
40
+ private readonly voiceSessionTracker;
41
+ private connectionState;
42
+ private userInitiatedDisconnect;
43
+ private isMuted;
44
+ private servicesInitialized;
45
+ private pendingExternalExecutor;
46
+ private pendingDocumentProvider;
47
+ constructor(apiClient: WidgetApiClient);
48
+ private initializeServices;
49
+ private setupEventHandlers;
50
+ connect(config: WidgetConfig, pageUrl?: string, pageTitle?: string, skipGreeting?: boolean): Promise<void>;
51
+ private setupSignalRAndPageInteraction;
52
+ private simulateDemoConnection;
53
+ disconnect(): Promise<void>;
54
+ sendText(text: string): Promise<void>;
55
+ sendFunctionResult(callId: string, name: string, result: string): Promise<void>;
56
+ setMuted(muted: boolean): void;
57
+ interrupt(): void;
58
+ setDocumentProvider(getDoc: DocumentProvider, getWin: WindowProvider): void;
59
+ setExternalExecutor(executor: ExternalExecutor | null): void;
60
+ getConnectionState(): ConnectionState;
61
+ isConnected(): boolean;
62
+ isMutedState(): boolean;
63
+ waitForResponseComplete(timeoutMs?: number): Promise<void>;
64
+ private static readonly NOISE_WORDS;
65
+ private isNoiseTranscript;
66
+ dispose(): void;
67
+ }
68
+ export {};
@@ -0,0 +1,273 @@
1
+ import { VoiceSessionTracker } from '../services/realtime/voice-session-tracker';
2
+ import { isDemoMode } from '../constants/defaults';
3
+ import { logger } from '../utils/logger';
4
+ import { EventEmitter } from './event-emitter';
5
+ const MIN_TRANSCRIPT_LENGTH = 2;
6
+ const DEMO_CONNECTION_DELAY_MS = 500;
7
+ export class ConnectionManager extends EventEmitter {
8
+ webrtcService = null;
9
+ signalRService = null;
10
+ pageInteractionHandler = null;
11
+ apiClient;
12
+ voiceSessionTracker = new VoiceSessionTracker();
13
+ connectionState = 'disconnected';
14
+ userInitiatedDisconnect = false;
15
+ isMuted = false;
16
+ servicesInitialized = false;
17
+ pendingExternalExecutor = null;
18
+ pendingDocumentProvider = null;
19
+ constructor(apiClient) {
20
+ super();
21
+ this.apiClient = apiClient;
22
+ }
23
+ async initializeServices() {
24
+ if (this.servicesInitialized) {
25
+ return;
26
+ }
27
+ logger.debug('[ConnectionManager] Lazy loading WebRTC and SignalR services...');
28
+ const [{ WebRtcService }, { SignalRService }] = await Promise.all([
29
+ import('../services/webrtc'),
30
+ import('../services/signalr'),
31
+ ]);
32
+ this.webrtcService = new WebRtcService();
33
+ this.signalRService = new SignalRService();
34
+ this.setupEventHandlers();
35
+ this.servicesInitialized = true;
36
+ logger.debug('[ConnectionManager] Services initialized');
37
+ }
38
+ setupEventHandlers() {
39
+ if (!this.webrtcService || !this.signalRService) {
40
+ return;
41
+ }
42
+ this.webrtcService.setEventHandlers({
43
+ onUserSpeechStart: () => this.emit('userSpeechStart'),
44
+ onUserSpeechStop: () => this.emit('userSpeechStop'),
45
+ onUserSpeechEnd: () => this.emit('userSpeechEnd'),
46
+ onAudioLevel: (level) => this.emit('audioLevel', level),
47
+ onTranscriptReceived: (isUser, text) => {
48
+ const trimmed = text.trim();
49
+ if (!trimmed) {
50
+ return;
51
+ }
52
+ if (isUser && this.isNoiseTranscript(trimmed)) {
53
+ logger.debug('[ConnectionManager] Filtered noise transcript:', trimmed);
54
+ return;
55
+ }
56
+ this.emit('transcriptReceived', { isUser, text: trimmed });
57
+ },
58
+ onTranscriptDelta: (isUser, delta) => {
59
+ if (isUser && delta.trim().length <= MIN_TRANSCRIPT_LENGTH) {
60
+ return;
61
+ }
62
+ this.emit('transcriptDelta', { isUser, delta });
63
+ },
64
+ onFunctionCall: (call) => this.emit('functionCall', call),
65
+ onUsageUpdated: (usage) => this.emit('usageUpdated', usage),
66
+ onError: (err) => this.emit('error', err),
67
+ onConnectionStateChanged: (state) => {
68
+ const wasConnected = this.connectionState === 'connected';
69
+ this.connectionState = state;
70
+ this.emit('connectionStateChanged', state);
71
+ if (state === 'disconnected' && wasConnected && !this.userInitiatedDisconnect) {
72
+ logger.debug('[ConnectionManager] Unexpected disconnect detected');
73
+ this.emit('unexpectedDisconnect');
74
+ }
75
+ },
76
+ onMicrophoneReady: () => this.emit('microphoneReady'),
77
+ onVoiceUnhealthy: (reason) => {
78
+ if (!this.userInitiatedDisconnect)
79
+ this.emit('voiceUnhealthy', reason);
80
+ },
81
+ onVoiceHealthy: () => this.emit('voiceHealthy'),
82
+ });
83
+ this.signalRService.setEventHandlers({
84
+ onStopConversation: () => this.emit('stopConversation'),
85
+ onOpenUrl: (url) => this.emit('openUrl', url),
86
+ onOpenFile: (url) => this.emit('openFile', url),
87
+ onError: (title, message) => this.emit('error', `${title}: ${message}`),
88
+ });
89
+ }
90
+ async connect(config, pageUrl, pageTitle, skipGreeting = false) {
91
+ if (isDemoMode) {
92
+ await this.simulateDemoConnection();
93
+ return;
94
+ }
95
+ this.userInitiatedDisconnect = false;
96
+ await this.initializeServices();
97
+ if (!this.webrtcService || !this.signalRService) {
98
+ throw new Error('Services not initialized');
99
+ }
100
+ try {
101
+ const micPermission = await this.webrtcService.requestMicrophonePermission();
102
+ if (micPermission === 'denied') {
103
+ this.emit('microphoneBlocked');
104
+ throw new Error('Microphone permission denied');
105
+ }
106
+ const [tokenResponse, instructions, tools] = await Promise.all([
107
+ this.apiClient.getEphemeralToken().catch((err) => {
108
+ throw new Error(`Ephemeral token request failed: ${err instanceof Error ? err.message : String(err)}`);
109
+ }),
110
+ this.apiClient.generatePrompt(pageUrl, pageTitle).catch((err) => {
111
+ throw new Error(`Prompt generation failed: ${err instanceof Error ? err.message : String(err)}`);
112
+ }),
113
+ this.apiClient.getTools().catch(() => []),
114
+ ]);
115
+ const bearerToken = this.apiClient.getBearerToken();
116
+ if (bearerToken) {
117
+ await this.setupSignalRAndPageInteraction(this.signalRService, bearerToken);
118
+ }
119
+ const providerType = (tokenResponse.provider ?? 'openai');
120
+ const effectiveToken = providerType === 'vertexai'
121
+ ? (this.apiClient.getBearerToken() ?? '')
122
+ : tokenResponse.token;
123
+ let proxyUrl = tokenResponse.proxyUrl;
124
+ let proxyBearerToken;
125
+ if (providerType === 'vertexai' && proxyUrl) {
126
+ const baseUrl = this.apiClient.getBaseUrl().replace(/\/$/, '');
127
+ const wsBase = baseUrl.replace(/^http/, 'ws');
128
+ proxyUrl = `${wsBase}${proxyUrl}`;
129
+ proxyBearerToken = this.apiClient.getBearerToken() ?? '';
130
+ }
131
+ await this.webrtcService.connect({
132
+ token: effectiveToken,
133
+ model: tokenResponse.model,
134
+ instructions,
135
+ voice: config.voice,
136
+ tools,
137
+ speed: config.speechSpeed,
138
+ language: config.language,
139
+ transcribeModel: config.transcribeModel,
140
+ transcriptionMode: config.transcriptionMode,
141
+ transcriptionPrompt: config.transcriptionPrompt,
142
+ provider: providerType,
143
+ proxyUrl,
144
+ proxyBearerToken,
145
+ skipGreeting,
146
+ });
147
+ const voiceSessionId = crypto.randomUUID();
148
+ await this.voiceSessionTracker.startTracking(this.apiClient, voiceSessionId);
149
+ logger.debug('[ConnectionManager] Connected successfully');
150
+ }
151
+ catch (err) {
152
+ const message = err instanceof Error ? err.message : 'Connection failed';
153
+ logger.error('[ConnectionManager] Connection failed:', message);
154
+ throw err;
155
+ }
156
+ }
157
+ async setupSignalRAndPageInteraction(signalR, bearerToken) {
158
+ const claims = this.apiClient.getTokenClaims();
159
+ signalR.setRegistrationInfo(this.apiClient.getClientId(), claims.tenantId ?? '00000000-0000-0000-0000-000000000000', claims.userId ?? '00000000-0000-0000-0000-000000000000');
160
+ const { PageInteractionHandler } = await import('../services/page-interaction/page-interaction-handler');
161
+ this.pageInteractionHandler = new PageInteractionHandler(() => signalR.getConnection() ?? null);
162
+ if (this.pendingExternalExecutor) {
163
+ this.pageInteractionHandler.setExternalExecutor(this.pendingExternalExecutor);
164
+ }
165
+ if (this.pendingDocumentProvider) {
166
+ this.pageInteractionHandler.setDocumentProvider(this.pendingDocumentProvider.getDoc, this.pendingDocumentProvider.getWin);
167
+ }
168
+ signalR.setTextChatHandlers({
169
+ onToolCall: (toolName, status) => {
170
+ this.emit('toolCall', { toolName, status });
171
+ },
172
+ });
173
+ signalR.setPageInteractionHandlers({
174
+ onPageInteractionRequest: (requestJson) => {
175
+ this.pageInteractionHandler?.handleRequest(requestJson);
176
+ },
177
+ });
178
+ await signalR.connect(this.apiClient.getBaseUrl(), bearerToken);
179
+ }
180
+ async simulateDemoConnection() {
181
+ await new Promise((resolve) => setTimeout(resolve, DEMO_CONNECTION_DELAY_MS));
182
+ this.connectionState = 'connected';
183
+ this.emit('connectionStateChanged', 'connected');
184
+ }
185
+ async disconnect() {
186
+ this.userInitiatedDisconnect = true;
187
+ await this.voiceSessionTracker.stopTracking();
188
+ if (isDemoMode) {
189
+ this.connectionState = 'disconnected';
190
+ this.emit('connectionStateChanged', 'disconnected');
191
+ return;
192
+ }
193
+ this.signalRService?.clearPageInteractionHandlers();
194
+ this.pageInteractionHandler = null;
195
+ const disconnectTasks = [];
196
+ if (this.webrtcService)
197
+ disconnectTasks.push(this.webrtcService.disconnect());
198
+ if (this.signalRService)
199
+ disconnectTasks.push(this.signalRService.disconnect());
200
+ await Promise.all(disconnectTasks);
201
+ logger.debug('[ConnectionManager] Disconnected');
202
+ }
203
+ async sendText(text) {
204
+ if (isDemoMode || !this.webrtcService) {
205
+ return;
206
+ }
207
+ await this.webrtcService.sendText(text);
208
+ }
209
+ async sendFunctionResult(callId, name, result) {
210
+ if (this.webrtcService?.isConnected) {
211
+ await this.webrtcService.sendFunctionResult(callId, name, result);
212
+ }
213
+ }
214
+ setMuted(muted) {
215
+ this.isMuted = muted;
216
+ if (!isDemoMode && this.webrtcService) {
217
+ this.webrtcService.setMuted(muted);
218
+ }
219
+ }
220
+ interrupt() {
221
+ if (!isDemoMode) {
222
+ this.webrtcService?.interrupt();
223
+ }
224
+ }
225
+ setDocumentProvider(getDoc, getWin) {
226
+ this.pendingDocumentProvider = { getDoc, getWin };
227
+ this.pageInteractionHandler?.setDocumentProvider(getDoc, getWin);
228
+ }
229
+ setExternalExecutor(executor) {
230
+ this.pendingExternalExecutor = executor;
231
+ this.pageInteractionHandler?.setExternalExecutor(executor);
232
+ }
233
+ getConnectionState() {
234
+ return this.connectionState;
235
+ }
236
+ isConnected() {
237
+ return this.connectionState === 'connected';
238
+ }
239
+ isMutedState() {
240
+ return this.isMuted;
241
+ }
242
+ async waitForResponseComplete(timeoutMs = 30000) {
243
+ if (isDemoMode || !this.webrtcService) {
244
+ return;
245
+ }
246
+ await this.webrtcService.waitForResponseComplete(timeoutMs);
247
+ }
248
+ static NOISE_WORDS = new Set([
249
+ 'uh', 'uhh', 'uhhh', 'uhm', 'uhmm',
250
+ 'hm', 'hmm', 'hmmm', 'mhm', 'mhmm',
251
+ 'ah', 'ahh', 'ahhh', 'oh', 'ohh', 'ohhh',
252
+ 'eh', 'ehh', 'ehhh', 'öh', 'öhh',
253
+ 'äh', 'ähh', 'ähm', 'ähmm',
254
+ 'tja', 'psst', 'huh',
255
+ 'pff', 'pfff', 'tss', 'tsss',
256
+ 'sh', 'shh', 'shhh',
257
+ 'ha', 'haha', 'hahaha',
258
+ 'he', 'hehe', 'hehehe',
259
+ 'hi', 'hihi', 'hihihi',
260
+ ]);
261
+ isNoiseTranscript(text) {
262
+ return text.length <= MIN_TRANSCRIPT_LENGTH || ConnectionManager.NOISE_WORDS.has(text.toLowerCase());
263
+ }
264
+ dispose() {
265
+ this.userInitiatedDisconnect = true;
266
+ void this.voiceSessionTracker.stopTracking();
267
+ this.signalRService?.clearPageInteractionHandlers();
268
+ this.pageInteractionHandler = null;
269
+ this.webrtcService?.disconnect();
270
+ this.signalRService?.disconnect();
271
+ this.removeAllListeners();
272
+ }
273
+ }
@@ -0,0 +1,11 @@
1
+ type RetryOptions = {
2
+ connectFn: () => Promise<void>;
3
+ cleanupFn: () => Promise<void>;
4
+ label: string;
5
+ logPrefix: string;
6
+ onMicrophoneDenied?: () => void;
7
+ };
8
+ export declare class ConnectionRetryHandler {
9
+ retryAsync({ connectFn, cleanupFn, label, logPrefix, onMicrophoneDenied }: RetryOptions): Promise<boolean>;
10
+ }
11
+ export {};