@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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/constants/defaults.d.ts +6 -0
- package/dist/constants/defaults.js +69 -0
- package/dist/core/chat-manager.d.ts +17 -0
- package/dist/core/chat-manager.js +39 -0
- package/dist/core/config-manager.d.ts +14 -0
- package/dist/core/config-manager.js +75 -0
- package/dist/core/connection-manager.d.ts +68 -0
- package/dist/core/connection-manager.js +273 -0
- package/dist/core/connection-retry-handler.d.ts +11 -0
- package/dist/core/connection-retry-handler.js +33 -0
- package/dist/core/consent-manager.d.ts +15 -0
- package/dist/core/consent-manager.js +40 -0
- package/dist/core/conversation-persistence.d.ts +23 -0
- package/dist/core/conversation-persistence.js +109 -0
- package/dist/core/event-emitter.d.ts +7 -0
- package/dist/core/event-emitter.js +27 -0
- package/dist/core/greeting-gate.d.ts +16 -0
- package/dist/core/greeting-gate.js +53 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.js +7 -0
- package/dist/core/link-target.d.ts +15 -0
- package/dist/core/link-target.js +64 -0
- package/dist/core/local-storage-persistence.d.ts +11 -0
- package/dist/core/local-storage-persistence.js +111 -0
- package/dist/core/navigation-manager.d.ts +24 -0
- package/dist/core/navigation-manager.js +132 -0
- package/dist/core/popover-manager.d.ts +14 -0
- package/dist/core/popover-manager.js +36 -0
- package/dist/core/session-beacon.d.ts +11 -0
- package/dist/core/session-beacon.js +60 -0
- package/dist/core/text-connection-manager.d.ts +58 -0
- package/dist/core/text-connection-manager.js +200 -0
- package/dist/core/text-mode-manager.d.ts +50 -0
- package/dist/core/text-mode-manager.js +154 -0
- package/dist/core/text-types.d.ts +20 -0
- package/dist/core/text-types.js +1 -0
- package/dist/core/text-widget-controller.d.ts +47 -0
- package/dist/core/text-widget-controller.js +278 -0
- package/dist/core/theme-manager.d.ts +10 -0
- package/dist/core/theme-manager.js +25 -0
- package/dist/core/types.d.ts +50 -0
- package/dist/core/types.js +1 -0
- package/dist/core/voice-state-manager.d.ts +85 -0
- package/dist/core/voice-state-manager.js +317 -0
- package/dist/core/widget-controller.d.ts +58 -0
- package/dist/core/widget-controller.js +533 -0
- package/dist/core/widget-core.d.ts +51 -0
- package/dist/core/widget-core.js +160 -0
- package/dist/google.d.ts +1 -0
- package/dist/google.js +12 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +40 -0
- package/dist/openai.d.ts +1 -0
- package/dist/openai.js +7 -0
- package/dist/services/api.d.ts +114 -0
- package/dist/services/api.js +336 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +3 -0
- package/dist/services/page-interaction/constants.d.ts +1 -0
- package/dist/services/page-interaction/constants.js +1 -0
- package/dist/services/page-interaction/content-extractor.d.ts +2 -0
- package/dist/services/page-interaction/content-extractor.js +104 -0
- package/dist/services/page-interaction/deep-query.d.ts +8 -0
- package/dist/services/page-interaction/deep-query.js +44 -0
- package/dist/services/page-interaction/dom-executor.d.ts +7 -0
- package/dist/services/page-interaction/dom-executor.js +172 -0
- package/dist/services/page-interaction/dom-extractor.d.ts +8 -0
- package/dist/services/page-interaction/dom-extractor.js +252 -0
- package/dist/services/page-interaction/extension-bridge.d.ts +10 -0
- package/dist/services/page-interaction/extension-bridge.js +86 -0
- package/dist/services/page-interaction/iframe-navigation-manager.d.ts +28 -0
- package/dist/services/page-interaction/iframe-navigation-manager.js +154 -0
- package/dist/services/page-interaction/index.d.ts +5 -0
- package/dist/services/page-interaction/index.js +4 -0
- package/dist/services/page-interaction/page-interaction-handler.d.ts +21 -0
- package/dist/services/page-interaction/page-interaction-handler.js +129 -0
- package/dist/services/page-interaction/selector-generator.d.ts +1 -0
- package/dist/services/page-interaction/selector-generator.js +48 -0
- package/dist/services/page-interaction/types.d.ts +75 -0
- package/dist/services/page-interaction/types.js +1 -0
- package/dist/services/realtime/google/google-audio.d.ts +44 -0
- package/dist/services/realtime/google/google-audio.js +410 -0
- package/dist/services/realtime/google/google-provider.d.ts +47 -0
- package/dist/services/realtime/google/google-provider.js +465 -0
- package/dist/services/realtime/google/google-tool-mapper.d.ts +3 -0
- package/dist/services/realtime/google/google-tool-mapper.js +70 -0
- package/dist/services/realtime/google/index.d.ts +1 -0
- package/dist/services/realtime/google/index.js +1 -0
- package/dist/services/realtime/openai/index.d.ts +1 -0
- package/dist/services/realtime/openai/index.js +1 -0
- package/dist/services/realtime/openai/openai-audio-monitor.d.ts +1 -0
- package/dist/services/realtime/openai/openai-audio-monitor.js +55 -0
- package/dist/services/realtime/openai/openai-provider.d.ts +50 -0
- package/dist/services/realtime/openai/openai-provider.js +579 -0
- package/dist/services/realtime/openai/openai-tool-mapper.d.ts +15 -0
- package/dist/services/realtime/openai/openai-tool-mapper.js +14 -0
- package/dist/services/realtime/provider-factory.d.ts +4 -0
- package/dist/services/realtime/provider-factory.js +12 -0
- package/dist/services/realtime/types.d.ts +78 -0
- package/dist/services/realtime/types.js +1 -0
- package/dist/services/realtime/vertexai/index.d.ts +1 -0
- package/dist/services/realtime/vertexai/index.js +1 -0
- package/dist/services/realtime/vertexai/vertexai-provider.d.ts +53 -0
- package/dist/services/realtime/vertexai/vertexai-provider.js +561 -0
- package/dist/services/realtime/voice-session-tracker.d.ts +10 -0
- package/dist/services/realtime/voice-session-tracker.js +62 -0
- package/dist/services/signalr.d.ts +37 -0
- package/dist/services/signalr.js +145 -0
- package/dist/services/thinking-audio-pulse.d.ts +2 -0
- package/dist/services/thinking-audio-pulse.js +72 -0
- package/dist/services/thinking-audio.d.ts +2 -0
- package/dist/services/thinking-audio.js +105 -0
- package/dist/services/voice-health-watchdog.d.ts +42 -0
- package/dist/services/voice-health-watchdog.js +90 -0
- package/dist/services/webrtc-event-handler.d.ts +4 -0
- package/dist/services/webrtc-event-handler.js +42 -0
- package/dist/services/webrtc-greeting.d.ts +3 -0
- package/dist/services/webrtc-greeting.js +62 -0
- package/dist/services/webrtc-microphone.d.ts +15 -0
- package/dist/services/webrtc-microphone.js +64 -0
- package/dist/services/webrtc.d.ts +44 -0
- package/dist/services/webrtc.js +196 -0
- package/dist/types/index.d.ts +151 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/audio-context.d.ts +11 -0
- package/dist/utils/audio-context.js +33 -0
- package/dist/utils/connect-chime.d.ts +11 -0
- package/dist/utils/connect-chime.js +230 -0
- package/dist/utils/consent.d.ts +4 -0
- package/dist/utils/consent.js +30 -0
- package/dist/utils/language-detection.d.ts +1 -0
- package/dist/utils/language-detection.js +17 -0
- package/dist/utils/logger.d.ts +13 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/mic-level-analyser.d.ts +13 -0
- package/dist/utils/mic-level-analyser.js +50 -0
- package/dist/utils/proactive-events.d.ts +5 -0
- package/dist/utils/proactive-events.js +1 -0
- package/dist/utils/styles.d.ts +4 -0
- package/dist/utils/styles.js +35 -0
- package/dist/utils/theme-detection.d.ts +3 -0
- package/dist/utils/theme-detection.js +121 -0
- package/dist/utils/tool-status.d.ts +10 -0
- package/dist/utils/tool-status.js +61 -0
- package/dist/utils/ui-sounds.d.ts +3 -0
- package/dist/utils/ui-sounds.js +57 -0
- 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 {};
|