@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
@@ -0,0 +1,533 @@
1
+ import { isDemoMode } from '../constants/defaults';
2
+ import { logger } from '../utils/logger';
3
+ import { primeAudioOnGesture } from '../utils/audio-context';
4
+ import { playConnectChime } from '../utils/connect-chime';
5
+ import { playEndCallSound, playMuteSound, playUnmuteSound } from '../utils/ui-sounds';
6
+ import { initExtensionBridge, refreshExtensionContext } from '../services/page-interaction/extension-bridge';
7
+ import { ConnectionManager } from './connection-manager';
8
+ import { EventEmitter } from './event-emitter';
9
+ import { WidgetCore } from './widget-core';
10
+ import { VoiceStateManager } from './voice-state-manager';
11
+ import { TextModeManager } from './text-mode-manager';
12
+ import { ConnectionRetryHandler } from './connection-retry-handler';
13
+ import { SessionBeacon } from './session-beacon';
14
+ const LOG_PREFIX = '[WidgetController]';
15
+ const DEMO_MESSAGE_DELAY_MS = 1000;
16
+ const TEXT_STOP_DELAY_MS = 3000;
17
+ const RECONNECT_DELAY_MS = 1500;
18
+ const MAX_VOICE_RECOVERY_ATTEMPTS = 2;
19
+ export class WidgetController extends EventEmitter {
20
+ core;
21
+ voiceConnectionManager;
22
+ voiceState;
23
+ textMode;
24
+ retryHandler;
25
+ sessionBeacon;
26
+ currentMode = 'voice';
27
+ pendingConnectMode = null;
28
+ isReconnecting = false;
29
+ showReconnecting = false;
30
+ voiceRecoveryAttempts = 0;
31
+ greetingOverride;
32
+ // UI feedback sounds (connect chime, mute/unmute, end-call) are a chat-app
33
+ // surface affordance; the embedded customer widget stays silent — shipping
34
+ // new sounds to customer sites is a product decision, not a refactor side-effect.
35
+ get uiSoundsEnabled() {
36
+ return this.core.source === 'chat-app';
37
+ }
38
+ constructor(options) {
39
+ super();
40
+ const notify = () => this.notifyStateChanged();
41
+ this.core = new WidgetCore(options, notify, (getDoc, getWin) => this.updateDocumentProviders(getDoc, getWin), () => this.connect(), LOG_PREFIX);
42
+ this.voiceConnectionManager = new ConnectionManager(this.core.apiClient);
43
+ this.voiceState = new VoiceStateManager(notify);
44
+ this.textMode = new TextModeManager(this.core.apiClient, notify);
45
+ this.retryHandler = new ConnectionRetryHandler();
46
+ this.sessionBeacon = new SessionBeacon();
47
+ this.voiceState.setupEventHandlers({
48
+ connectionManager: this.voiceConnectionManager,
49
+ apiClient: this.core.apiClient,
50
+ chatManager: this.core.chatManager,
51
+ persistence: this.core.persistence,
52
+ navigationManager: this.core.navigationManager,
53
+ onStopConversation: () => this.handleVoiceStopConversation(),
54
+ logPrefix: LOG_PREFIX,
55
+ });
56
+ this.voiceConnectionManager.on('unexpectedDisconnect', () => this.handleUnexpectedDisconnect());
57
+ this.voiceConnectionManager.on('voiceUnhealthy', (reason) => this.handleVoiceUnhealthy(reason));
58
+ this.voiceConnectionManager.on('voiceHealthy', () => { this.voiceRecoveryAttempts = 0; });
59
+ this.setupExtensionBridge();
60
+ }
61
+ async initialize() {
62
+ await this.core.initialize();
63
+ }
64
+ async connect(options) {
65
+ this.greetingOverride = options?.greeting;
66
+ if (this.currentMode === 'text') {
67
+ await this.connectText(false, options?.waitForGreeting ?? true);
68
+ }
69
+ else {
70
+ await this.connectVoice();
71
+ }
72
+ }
73
+ // The greeting is on when this connect's `greeting` override says so, otherwise per the
74
+ // per-assistant config.greetingEnabled. The chat-app / prior-conversation guards apply on top.
75
+ greetingEnabled(config) {
76
+ return this.greetingOverride ?? config.greetingEnabled !== false;
77
+ }
78
+ // Skip the spoken greeting when the greeting is off (override or config), on Chat-App surfaces
79
+ // (the operator is testing — the first user input is the opener), or when a prior conversation
80
+ // is already on screen (reconnect/resume must not re-greet).
81
+ shouldSkipVoiceGreeting(config) {
82
+ return !this.greetingEnabled(config)
83
+ || this.core.source === 'chat-app'
84
+ || this.core.chatManager.getMessages().length > 0;
85
+ }
86
+ async connectVoice() {
87
+ // Unlock audio while we still hold the user gesture, before any await —
88
+ // guarantees the later voice playback context can run on iOS.
89
+ primeAudioOnGesture();
90
+ const config = this.core.configManager.getConfig();
91
+ if (!config)
92
+ return;
93
+ if (!this.core.requireConsentOrConnect(config)) {
94
+ this.pendingConnectMode = 'voice';
95
+ return;
96
+ }
97
+ // Tear down the other channel before opening voice. Both managers
98
+ // open their own SignalR hub connection registered under the same
99
+ // ClientId — leaving the text hub up while voice opens its own
100
+ // produces two parallel registrations and the server kicks one of
101
+ // them, surfacing as an unexpected mid-call disconnect.
102
+ await this.textMode.disconnect();
103
+ this.currentMode = 'voice';
104
+ this.core.isConnecting = true;
105
+ this.core.hasError = false;
106
+ this.voiceRecoveryAttempts = 0;
107
+ this.voiceState.setLanguage(config.language);
108
+ this.voiceState.prepareForConnection();
109
+ this.notifyStateChanged();
110
+ const refreshedContext = await refreshExtensionContext();
111
+ if (refreshedContext) {
112
+ this.core.pageUrl = refreshedContext.url;
113
+ this.core.pageTitle = refreshedContext.title;
114
+ }
115
+ const connected = await this.retryHandler.retryAsync({
116
+ connectFn: async () => {
117
+ // Only rehydrate from storage if the panel is empty.
118
+ // initialize() already loaded history; clearing and reloading
119
+ // here would visibly blink the panel during the connect step.
120
+ if (this.core.chatManager.getMessages().length === 0) {
121
+ await this.core.persistence.loadHistory(this.core.chatManager);
122
+ }
123
+ await this.voiceConnectionManager.connect(config, this.core.pageUrl, this.core.pageTitle, this.shouldSkipVoiceGreeting(config));
124
+ },
125
+ cleanupFn: () => this.voiceConnectionManager.disconnect(),
126
+ label: 'voice',
127
+ logPrefix: LOG_PREFIX,
128
+ onMicrophoneDenied: () => {
129
+ this.core.isConnecting = false;
130
+ this.notifyStateChanged();
131
+ },
132
+ });
133
+ this.handleRetryResult(connected);
134
+ if (connected && this.uiSoundsEnabled) {
135
+ // Short two-note chime once the realtime channel is up — same
136
+ // 'session-ready' cue as Skype/Discord, without shipping an asset.
137
+ playConnectChime();
138
+ }
139
+ if (connected && isDemoMode) {
140
+ this.core.showDemoWelcome(config);
141
+ }
142
+ }
143
+ async connectText(skipGreeting = false, waitForGreeting = true) {
144
+ const config = this.core.configManager.getConfig();
145
+ if (!config)
146
+ return;
147
+ this.textMode.setWaitForGreeting(waitForGreeting);
148
+ if (!this.core.requireConsentOrConnect(config)) {
149
+ this.pendingConnectMode = 'text';
150
+ return;
151
+ }
152
+ // Tear down the other channel — see connectVoice for the reason.
153
+ await this.voiceConnectionManager.disconnect();
154
+ this.voiceState.resetState();
155
+ this.currentMode = 'text';
156
+ this.core.isConnecting = true;
157
+ this.core.hasError = false;
158
+ this.textMode.setLanguage(config.language);
159
+ this.textMode.resetStreamState();
160
+ this.notifyStateChanged();
161
+ const connected = await this.retryHandler.retryAsync({
162
+ connectFn: async () => {
163
+ const textMgr = await this.textMode.ensureConnection();
164
+ this.textMode.setupEventHandlers({
165
+ chatManager: this.core.chatManager,
166
+ persistence: this.core.persistence,
167
+ navigationManager: this.core.navigationManager,
168
+ onError: () => { this.core.hasError = true; },
169
+ onStopConversation: () => this.handleTextStopConversation(),
170
+ logPrefix: LOG_PREFIX,
171
+ });
172
+ // Only rehydrate from storage if the panel is empty.
173
+ // initialize() already loaded history; clearing and reloading
174
+ // here would visibly blink the panel during the connect step.
175
+ if (this.core.chatManager.getMessages().length === 0) {
176
+ await this.core.persistence.loadHistory(this.core.chatManager);
177
+ }
178
+ await textMgr.connect();
179
+ },
180
+ cleanupFn: () => this.textMode.disconnect(),
181
+ label: 'text',
182
+ logPrefix: LOG_PREFIX,
183
+ });
184
+ this.handleRetryResult(connected);
185
+ if (!connected)
186
+ return;
187
+ if (isDemoMode) {
188
+ this.core.showDemoWelcome(config);
189
+ return;
190
+ }
191
+ // Chat-App surfaces never want a greeting — see connectVoice. The greeting is otherwise on
192
+ // per the connect `greeting` override or config.greetingEnabled.
193
+ const suppressGreeting = skipGreeting || this.core.source === 'chat-app' || !this.greetingEnabled(config);
194
+ if (!suppressGreeting && this.core.chatManager.getMessages().length === 0) {
195
+ this.textMode.requestGreeting(this.core.pageUrl, this.core.pageTitle);
196
+ }
197
+ }
198
+ async disconnect(trimFarewell = false) {
199
+ this.isReconnecting = false;
200
+ this.showReconnecting = false;
201
+ this.sessionBeacon.unregister();
202
+ this.core.persistence.saveToLocalStorage(this.core.chatManager);
203
+ this.core.persistence.endSession(trimFarewell);
204
+ await this.disconnectCurrentMode();
205
+ this.notifyStateChanged();
206
+ }
207
+ async endCall() {
208
+ if (this.uiSoundsEnabled) {
209
+ playEndCallSound();
210
+ }
211
+ await this.disconnect();
212
+ }
213
+ async resetConversation() {
214
+ logger.debug(`${LOG_PREFIX} Resetting conversation...`);
215
+ await this.disconnectCurrentMode();
216
+ this.core.chatManager.clearMessages();
217
+ await this.core.persistence.resetMemory();
218
+ this.notifyStateChanged();
219
+ // 'Neuer Chat' always coming back in TEXT mode is a chat-app surface
220
+ // affordance (a reset mid-voice, or after a hang-up that left
221
+ // currentMode='voice', must not silently reopen the mic there; the
222
+ // greeting is skipped — the assistant greets with the first message).
223
+ // The embedded customer widget resets back into the CURRENT mode: a
224
+ // visitor who resets during a voice call expects to stay in voice.
225
+ if (this.core.source === 'chat-app') {
226
+ await this.connectText(true);
227
+ return;
228
+ }
229
+ await this.connect();
230
+ }
231
+ submitSessionFeedback(rating) {
232
+ this.core.apiClient.submitSessionFeedback(rating).catch((err) => {
233
+ logger.debug(`${LOG_PREFIX} Failed to submit session feedback:`, err);
234
+ });
235
+ }
236
+ submitFeedback(messageId, rating) {
237
+ if (rating == null) {
238
+ this.core.chatManager.setFeedback(messageId, undefined);
239
+ this.notifyStateChanged();
240
+ return;
241
+ }
242
+ this.core.chatManager.setFeedback(messageId, rating);
243
+ this.notifyStateChanged();
244
+ this.core.apiClient.submitFeedback(messageId, rating).catch((err) => {
245
+ logger.debug(`${LOG_PREFIX} Failed to submit feedback:`, err);
246
+ });
247
+ }
248
+ async sendMessage(text) {
249
+ if (!text.trim())
250
+ return;
251
+ if (isDemoMode) {
252
+ this.core.chatManager.addUserMessage(text);
253
+ setTimeout(() => {
254
+ this.core.chatManager.addAssistantMessage(`Dies ist eine Demo-Antwort auf: "${text}"\n\nIm Produktivbetrieb würde hier die KI-Antwort erscheinen.`);
255
+ }, DEMO_MESSAGE_DELAY_MS);
256
+ return;
257
+ }
258
+ const refreshedContext = await refreshExtensionContext();
259
+ if (refreshedContext) {
260
+ this.core.pageUrl = refreshedContext.url;
261
+ this.core.pageTitle = refreshedContext.title;
262
+ }
263
+ if (this.currentMode === 'text') {
264
+ try {
265
+ await this.textMode.sendMessage(text, this.core.pageUrl, this.core.pageTitle);
266
+ }
267
+ catch (err) {
268
+ logger.debug(`${LOG_PREFIX} Failed to send text message:`, err);
269
+ this.core.hasError = true;
270
+ this.notifyStateChanged();
271
+ }
272
+ }
273
+ else {
274
+ this.core.chatManager.addUserMessage(text);
275
+ this.core.persistence.persistMessage('User', text, this.voiceState.language, undefined, undefined, undefined, 'Typed');
276
+ this.notifyStateChanged();
277
+ await this.voiceConnectionManager.sendText(text);
278
+ }
279
+ }
280
+ toggleMute() {
281
+ const wasMuted = this.voiceState.isMuted;
282
+ this.voiceState.toggleMute();
283
+ if (!this.uiSoundsEnabled)
284
+ return;
285
+ if (wasMuted) {
286
+ playUnmuteSound();
287
+ }
288
+ else {
289
+ playMuteSound();
290
+ }
291
+ }
292
+ stopVoiceResponse() {
293
+ if (this.currentMode !== 'voice')
294
+ return;
295
+ this.voiceState.stop();
296
+ }
297
+ transcribe(audio, signal) {
298
+ return this.core.apiClient.transcribeAudio(audio, signal);
299
+ }
300
+ toggleMinimized() {
301
+ this.core.toggleMinimized();
302
+ }
303
+ setMinimized(minimized) {
304
+ this.core.setMinimized(minimized);
305
+ }
306
+ acceptTerms() {
307
+ this.core.acceptTerms(() => {
308
+ const mode = this.pendingConnectMode ?? this.currentMode;
309
+ this.pendingConnectMode = null;
310
+ if (mode === 'text') {
311
+ this.connectText();
312
+ }
313
+ else {
314
+ this.connectVoice();
315
+ }
316
+ });
317
+ }
318
+ declineTerms() {
319
+ this.core.declineTerms();
320
+ }
321
+ dismissBlockedUrl() {
322
+ this.core.dismissBlockedUrl();
323
+ }
324
+ openBlockedUrl() {
325
+ this.core.openBlockedUrl();
326
+ }
327
+ dismissPopover() {
328
+ this.core.dismissPopover();
329
+ }
330
+ getState() {
331
+ return {
332
+ config: this.core.configManager.getConfig(),
333
+ resolvedTheme: this.core.themeManager.resolvedTheme,
334
+ isLoading: this.core.isLoading,
335
+ initFailed: this.core.initFailed,
336
+ hasError: this.core.hasError,
337
+ isMicrophoneBlocked: this.voiceState.isMicrophoneBlocked,
338
+ isMinimized: this.core.isMinimized,
339
+ isConnecting: this.core.isConnecting,
340
+ connectionState: this.getActiveConnectionState(),
341
+ currentMode: this.currentMode,
342
+ isMuted: this.voiceState.isMuted,
343
+ isMicrophoneReady: this.voiceState.isMicrophoneReady,
344
+ isUserSpeaking: this.voiceState.isUserSpeaking,
345
+ audioLevel: this.voiceState.audioLevel,
346
+ isAiThinking: this.voiceState.isAiThinking,
347
+ isStreaming: this.currentMode === 'text' ? this.textMode.isStreaming : this.voiceState.isStreaming,
348
+ isAssistantSpeaking: this.currentMode === 'text' ? false : this.voiceState.isAssistantSpeaking,
349
+ streamingContent: this.currentMode === 'text' ? this.textMode.streamContent : this.voiceState.streamContent,
350
+ isTranscribing: this.voiceState.isTranscribing,
351
+ transcribingContent: this.voiceState.transcribingContent,
352
+ messages: this.core.chatManager.getMessages(),
353
+ agentStatus: this.currentMode === 'text' ? this.textMode.agentStatus : this.voiceState.agentStatus,
354
+ showTermsModal: this.core.consentManager.showTermsModal,
355
+ hasConsented: this.core.consentManager.hasConsented,
356
+ blockedUrl: this.core.navigationManager.blockedUrl,
357
+ showPopover: this.core.popoverManager.showPopover,
358
+ };
359
+ }
360
+ dispose() {
361
+ this.voiceConnectionManager.dispose();
362
+ this.textMode.dispose();
363
+ this.core.dispose();
364
+ this.removeAllListeners();
365
+ }
366
+ notifyStateChanged() {
367
+ this.emit('stateChanged', this.getState());
368
+ }
369
+ getActiveConnectionState() {
370
+ if (this.showReconnecting)
371
+ return 'reconnecting';
372
+ if (this.isReconnecting)
373
+ return 'connecting';
374
+ if (this.currentMode === 'text') {
375
+ return this.textMode.getConnectionState();
376
+ }
377
+ return this.voiceConnectionManager.getConnectionState();
378
+ }
379
+ handleRetryResult(connected) {
380
+ if (connected) {
381
+ // A failed attempt earlier in the retry loop will have flipped
382
+ // hasError on; reset it so the surface doesn't keep showing the
383
+ // banner after a successful reconnect.
384
+ this.core.hasError = false;
385
+ const config = this.core.configManager.getConfig();
386
+ if (config) {
387
+ this.core.persistence.setAssistantId(config.assistantId);
388
+ const clientId = this.core.apiClient.getClientId();
389
+ if (clientId) {
390
+ this.sessionBeacon.register(this.core.apiClient.getBaseUrl(), clientId, config.assistantId, () => this.handleSessionResumed());
391
+ }
392
+ this.core.persistence.startNewSession();
393
+ }
394
+ }
395
+ else {
396
+ this.core.hasError = true;
397
+ }
398
+ this.core.isConnecting = false;
399
+ this.notifyStateChanged();
400
+ }
401
+ handleSessionResumed() {
402
+ this.core.persistence.startNewSession();
403
+ }
404
+ async disconnectCurrentMode() {
405
+ if (this.currentMode === 'text') {
406
+ await this.textMode.disconnect();
407
+ return;
408
+ }
409
+ await this.voiceConnectionManager.disconnect();
410
+ this.voiceState.resetState();
411
+ }
412
+ async handleUnexpectedDisconnect() {
413
+ if (this.currentMode !== 'voice' || this.isReconnecting)
414
+ return;
415
+ const config = this.core.configManager.getConfig();
416
+ if (!config)
417
+ return;
418
+ logger.warn(`${LOG_PREFIX} Unexpected voice disconnect, attempting reconnect...`);
419
+ this.isReconnecting = true;
420
+ this.showReconnecting = true;
421
+ this.core.isConnecting = true;
422
+ this.voiceState.resetState();
423
+ this.voiceState.prepareForConnection();
424
+ this.notifyStateChanged();
425
+ await new Promise((resolve) => setTimeout(resolve, RECONNECT_DELAY_MS));
426
+ if (!this.isReconnecting)
427
+ return;
428
+ const connected = await this.retryHandler.retryAsync({
429
+ connectFn: async () => {
430
+ await this.voiceConnectionManager.connect(config, this.core.pageUrl, this.core.pageTitle, this.shouldSkipVoiceGreeting(config));
431
+ },
432
+ cleanupFn: () => this.voiceConnectionManager.disconnect(),
433
+ label: 'voice-reconnect',
434
+ logPrefix: LOG_PREFIX,
435
+ });
436
+ this.isReconnecting = false;
437
+ this.showReconnecting = false;
438
+ if (connected) {
439
+ logger.debug(`${LOG_PREFIX} Voice reconnect successful`);
440
+ }
441
+ else {
442
+ logger.error(`${LOG_PREFIX} Voice reconnect failed`);
443
+ this.core.hasError = true;
444
+ }
445
+ this.core.isConnecting = false;
446
+ this.notifyStateChanged();
447
+ }
448
+ // The watchdog reports a "connected but dead" voice session (no setup or the
449
+ // greeting never responds) where no disconnect event fires. Recover by
450
+ // tearing the dead session down and reconnecting — exactly what a manual
451
+ // reset does. First attempt is silent; from the second it shows the
452
+ // reconnecting indicator. Capped so a persistently broken backend surfaces
453
+ // an error instead of looping.
454
+ async handleVoiceUnhealthy(reason) {
455
+ if (this.currentMode !== 'voice' || this.isReconnecting)
456
+ return;
457
+ const config = this.core.configManager.getConfig();
458
+ if (!config)
459
+ return;
460
+ this.voiceRecoveryAttempts++;
461
+ if (this.voiceRecoveryAttempts > MAX_VOICE_RECOVERY_ATTEMPTS) {
462
+ logger.error(`${LOG_PREFIX} Voice unhealthy (${reason}) — recovery exhausted after ${MAX_VOICE_RECOVERY_ATTEMPTS} attempts`);
463
+ await this.voiceConnectionManager.disconnect();
464
+ this.core.hasError = true;
465
+ this.core.isConnecting = false;
466
+ this.notifyStateChanged();
467
+ return;
468
+ }
469
+ const visible = this.voiceRecoveryAttempts >= 2;
470
+ logger.warn(`${LOG_PREFIX} Voice unhealthy (${reason}), recovery attempt ${this.voiceRecoveryAttempts}${visible ? ' (visible)' : ' (silent)'}`);
471
+ this.isReconnecting = true;
472
+ this.showReconnecting = visible;
473
+ // Mark connecting so the chooser (DisconnectedView) shows its spinner with
474
+ // disabled buttons during recovery instead of clickable Voice/Text buttons.
475
+ this.core.isConnecting = true;
476
+ if (visible) {
477
+ this.voiceState.resetState();
478
+ this.voiceState.prepareForConnection();
479
+ }
480
+ this.notifyStateChanged();
481
+ await this.voiceConnectionManager.disconnect();
482
+ await new Promise((resolve) => setTimeout(resolve, RECONNECT_DELAY_MS));
483
+ if (!this.isReconnecting)
484
+ return;
485
+ const connected = await this.retryHandler.retryAsync({
486
+ connectFn: async () => {
487
+ await this.voiceConnectionManager.connect(config, this.core.pageUrl, this.core.pageTitle, this.shouldSkipVoiceGreeting(config));
488
+ },
489
+ cleanupFn: () => this.voiceConnectionManager.disconnect(),
490
+ label: 'voice-health-recovery',
491
+ logPrefix: LOG_PREFIX,
492
+ });
493
+ this.isReconnecting = false;
494
+ this.showReconnecting = false;
495
+ if (connected) {
496
+ logger.debug(`${LOG_PREFIX} Voice recovery reconnect successful`);
497
+ }
498
+ else {
499
+ logger.error(`${LOG_PREFIX} Voice recovery reconnect failed`);
500
+ this.core.hasError = true;
501
+ }
502
+ this.core.isConnecting = false;
503
+ this.notifyStateChanged();
504
+ }
505
+ async handleVoiceStopConversation() {
506
+ logger.debug(`${LOG_PREFIX} Voice StopConversation received`);
507
+ await this.disconnect(true);
508
+ this.core.isMinimized = true;
509
+ this.notifyStateChanged();
510
+ }
511
+ async handleTextStopConversation() {
512
+ logger.debug(`${LOG_PREFIX} Text StopConversation received, closing after delay`);
513
+ this.textMode.isSessionEnding = true;
514
+ await new Promise((resolve) => setTimeout(resolve, TEXT_STOP_DELAY_MS));
515
+ await this.disconnect(true);
516
+ this.core.isMinimized = true;
517
+ this.textMode.isSessionEnding = false;
518
+ this.notifyStateChanged();
519
+ }
520
+ updateDocumentProviders(getDoc, getWin) {
521
+ this.voiceConnectionManager.setDocumentProvider(getDoc, getWin);
522
+ this.textMode.setDocumentProvider(getDoc, getWin);
523
+ }
524
+ setupExtensionBridge() {
525
+ initExtensionBridge([
526
+ (executor) => this.voiceConnectionManager.setExternalExecutor(executor),
527
+ (executor) => this.textMode.setExternalExecutor(executor),
528
+ ], (ctx) => {
529
+ this.core.pageUrl = ctx.url;
530
+ this.core.pageTitle = ctx.title;
531
+ });
532
+ }
533
+ }
@@ -0,0 +1,51 @@
1
+ import { WidgetApiClient } from '../services/api';
2
+ import type { DocumentProvider, WindowProvider } from '../services/page-interaction/page-interaction-handler';
3
+ import { ConfigManager } from './config-manager';
4
+ import { ChatManager } from './chat-manager';
5
+ import { ThemeManager } from './theme-manager';
6
+ import { ConsentManager } from './consent-manager';
7
+ import { PopoverManager } from './popover-manager';
8
+ import { NavigationManager } from './navigation-manager';
9
+ import { ConversationPersistence } from './conversation-persistence';
10
+ import type { WidgetConfig } from '../types';
11
+ import type { WidgetControllerOptions } from './types';
12
+ type StateChangeCallback = () => void;
13
+ type DocumentProviderCallback = (getDoc: DocumentProvider, getWin: WindowProvider) => void;
14
+ type ConnectCallback = () => void;
15
+ export declare class WidgetCore {
16
+ readonly apiClient: WidgetApiClient;
17
+ readonly configManager: ConfigManager;
18
+ readonly chatManager: ChatManager;
19
+ readonly themeManager: ThemeManager;
20
+ readonly consentManager: ConsentManager;
21
+ readonly popoverManager: PopoverManager;
22
+ readonly navigationManager: NavigationManager;
23
+ readonly persistence: ConversationPersistence;
24
+ isMinimized: boolean;
25
+ isConnecting: boolean;
26
+ hasError: boolean;
27
+ isLoading: boolean;
28
+ initFailed: boolean;
29
+ pageUrl?: string;
30
+ pageTitle?: string;
31
+ private readonly onStateChanged;
32
+ private readonly onConnect;
33
+ private readonly logPrefix;
34
+ readonly source: string | undefined;
35
+ constructor(options: WidgetControllerOptions, onStateChanged: StateChangeCallback, onDocumentProviders: DocumentProviderCallback, onConnect: ConnectCallback, logPrefix: string);
36
+ initialize(): Promise<void>;
37
+ toggleMinimized(): void;
38
+ setMinimized(minimized: boolean): void;
39
+ dismissPopover(): void;
40
+ acceptTerms(onAccepted: () => void): void;
41
+ declineTerms(): void;
42
+ dismissBlockedUrl(): void;
43
+ openBlockedUrl(): void;
44
+ requireConsentOrConnect(config: WidgetConfig): boolean;
45
+ showDemoWelcome(config: WidgetConfig): void;
46
+ dispose(): void;
47
+ private detectPageContext;
48
+ private resolvePageHost;
49
+ private checkForReconnect;
50
+ }
51
+ export {};