@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,465 @@
|
|
|
1
|
+
import { GoogleGenAI, MediaModality, Modality, VoiceActivityType } from '@google/genai';
|
|
2
|
+
import { mapToolsForGoogle } from './google-tool-mapper';
|
|
3
|
+
import { GoogleAudioHandler } from './google-audio';
|
|
4
|
+
import { logger } from '../../../utils/logger';
|
|
5
|
+
const AUDIO_MIME_TYPE = 'audio/pcm;rate=16000';
|
|
6
|
+
const DEFAULT_VOICE = 'Kore';
|
|
7
|
+
const MAX_TOOL_CALL_HISTORY = 5;
|
|
8
|
+
const PLAYBACK_CHECK_INTERVAL_MS = 100;
|
|
9
|
+
const PLAYBACK_SETTLED_CHECKS = 3;
|
|
10
|
+
const DEFAULT_MAX_WAIT_MS = 20000;
|
|
11
|
+
export class GoogleRealtimeProvider {
|
|
12
|
+
session = null;
|
|
13
|
+
audioHandler;
|
|
14
|
+
eventHandlers = new Map();
|
|
15
|
+
_status = 'disconnected';
|
|
16
|
+
pendingFunctionCalls = new Map();
|
|
17
|
+
awaitingToolResponse = false;
|
|
18
|
+
isSetupComplete = false;
|
|
19
|
+
modelResponding = false;
|
|
20
|
+
userSpeaking = false;
|
|
21
|
+
pendingCommands = [];
|
|
22
|
+
outputTranscriptBuffer = '';
|
|
23
|
+
inputTranscriptBuffer = '';
|
|
24
|
+
lastCompletedToolCalls = [];
|
|
25
|
+
constructor() {
|
|
26
|
+
this.audioHandler = new GoogleAudioHandler();
|
|
27
|
+
}
|
|
28
|
+
get status() {
|
|
29
|
+
return this._status;
|
|
30
|
+
}
|
|
31
|
+
async connect(config) {
|
|
32
|
+
this._status = 'connecting';
|
|
33
|
+
this.emit('connectionStateChanged', 'connecting');
|
|
34
|
+
// Ephemeral tokens require v1alpha
|
|
35
|
+
const ai = new GoogleGenAI({
|
|
36
|
+
apiKey: config.apiKey,
|
|
37
|
+
httpOptions: { apiVersion: 'v1alpha' },
|
|
38
|
+
});
|
|
39
|
+
const toolDeclarations = mapToolsForGoogle(config.tools);
|
|
40
|
+
logger.debug('[Google] Connecting to Live API, model:', config.model, 'tools:', toolDeclarations.length, 'voice:', config.voice);
|
|
41
|
+
// languageCodes pin the ASR + TTS to a specific BCP-47 locale so
|
|
42
|
+
// Gemini doesn't drift into Spanish/Hindi on quiet inputs (observed
|
|
43
|
+
// live during M3-i easybell trunk testing). Empty list when the
|
|
44
|
+
// tenant has no configured locale — Gemini auto-detects then.
|
|
45
|
+
const transcriptionLanguageCodes = config.transcription?.language
|
|
46
|
+
? [config.transcription.language]
|
|
47
|
+
: undefined;
|
|
48
|
+
const liveConfig = {
|
|
49
|
+
responseModalities: [Modality.AUDIO],
|
|
50
|
+
speechConfig: {
|
|
51
|
+
voiceConfig: {
|
|
52
|
+
prebuiltVoiceConfig: {
|
|
53
|
+
voiceName: config.voice || DEFAULT_VOICE,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
inputAudioTranscription: transcriptionLanguageCodes
|
|
58
|
+
? { languageCodes: transcriptionLanguageCodes }
|
|
59
|
+
: {},
|
|
60
|
+
outputAudioTranscription: transcriptionLanguageCodes
|
|
61
|
+
? { languageCodes: transcriptionLanguageCodes }
|
|
62
|
+
: {},
|
|
63
|
+
};
|
|
64
|
+
if (config.instructions) {
|
|
65
|
+
liveConfig.systemInstruction = config.instructions;
|
|
66
|
+
}
|
|
67
|
+
if (toolDeclarations.length > 0) {
|
|
68
|
+
liveConfig.tools = [{ functionDeclarations: toolDeclarations }];
|
|
69
|
+
}
|
|
70
|
+
this.session = await ai.live.connect({
|
|
71
|
+
model: config.model,
|
|
72
|
+
config: liveConfig,
|
|
73
|
+
callbacks: {
|
|
74
|
+
onopen: () => {
|
|
75
|
+
logger.debug('[Google] WebSocket opened');
|
|
76
|
+
this._status = 'connected';
|
|
77
|
+
this.emit('connectionStateChanged', 'connected');
|
|
78
|
+
},
|
|
79
|
+
onmessage: (message) => {
|
|
80
|
+
this.handleServerMessage(message);
|
|
81
|
+
},
|
|
82
|
+
onerror: (error) => {
|
|
83
|
+
logger.error('[Google] WebSocket error:', error.message || error);
|
|
84
|
+
this.emit('error', { message: error.message || 'Google Live API error' });
|
|
85
|
+
},
|
|
86
|
+
onclose: (event) => {
|
|
87
|
+
logger.warn('[Google] WebSocket closed, code:', event.code, 'reason:', event.reason, '— modelResponding:', this.modelResponding, 'pendingToolCalls:', this.pendingFunctionCalls.size, 'lastToolCalls:', this.lastCompletedToolCalls.join(' | ') || 'none');
|
|
88
|
+
this._status = 'disconnected';
|
|
89
|
+
this.resetSessionState();
|
|
90
|
+
this.audioHandler.stop();
|
|
91
|
+
this.emit('connectionStateChanged', 'disconnected');
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
this.audioHandler.onAudioData = (base64Pcm) => {
|
|
96
|
+
if (this.session && this.isSetupComplete && this._status === 'connected'
|
|
97
|
+
&& this.pendingFunctionCalls.size === 0) {
|
|
98
|
+
try {
|
|
99
|
+
this.session.sendRealtimeInput({
|
|
100
|
+
audio: { data: base64Pcm, mimeType: AUDIO_MIME_TYPE },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
logger.debug('[Google] sendRealtimeInput failed:', error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
logger.debug('[Google] Session established, starting audio capture');
|
|
109
|
+
await this.audioHandler.startCapture();
|
|
110
|
+
}
|
|
111
|
+
close() {
|
|
112
|
+
if (this.session) {
|
|
113
|
+
this.session.close();
|
|
114
|
+
this.session = null;
|
|
115
|
+
}
|
|
116
|
+
this.audioHandler.stop();
|
|
117
|
+
this._status = 'disconnected';
|
|
118
|
+
this.resetSessionState();
|
|
119
|
+
}
|
|
120
|
+
resumeAudio() {
|
|
121
|
+
this.audioHandler.resume();
|
|
122
|
+
}
|
|
123
|
+
mute(muted) {
|
|
124
|
+
this.audioHandler.setMuted(muted);
|
|
125
|
+
}
|
|
126
|
+
interrupt() {
|
|
127
|
+
// Gemini Live has no client-side response-cancel; dropping the queued
|
|
128
|
+
// playback stops the talking immediately, which is what the user wants.
|
|
129
|
+
this.audioHandler.clearPlaybackQueue();
|
|
130
|
+
this.emit('interrupted');
|
|
131
|
+
}
|
|
132
|
+
requestResponse() {
|
|
133
|
+
if (!this.isSetupComplete) {
|
|
134
|
+
this.pendingCommands.push({ type: 'requestResponse' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.executeRequestResponse();
|
|
138
|
+
}
|
|
139
|
+
sendToolResult(callId, name, output) {
|
|
140
|
+
if (!this.isSetupComplete) {
|
|
141
|
+
this.pendingCommands.push({ type: 'sendToolResult', payload: { callId, name, output } });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.executeSendToolResult(callId, name, output);
|
|
145
|
+
}
|
|
146
|
+
sendText(text) {
|
|
147
|
+
if (!this.session)
|
|
148
|
+
return;
|
|
149
|
+
if (!this.isSetupComplete) {
|
|
150
|
+
this.pendingCommands.push({ type: 'sendText', payload: text });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.session.sendRealtimeInput({ text });
|
|
154
|
+
}
|
|
155
|
+
waitForAudioPlaybackEnd(timeoutMs = DEFAULT_MAX_WAIT_MS) {
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
if (!this.audioHandler.isPlaybackActive) {
|
|
158
|
+
resolve('no active playback');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
let settledCount = 0;
|
|
162
|
+
const interval = setInterval(() => {
|
|
163
|
+
if (this.audioHandler.isPlaybackActive) {
|
|
164
|
+
settledCount = 0;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
settledCount++;
|
|
168
|
+
if (settledCount >= PLAYBACK_SETTLED_CHECKS) {
|
|
169
|
+
clearInterval(interval);
|
|
170
|
+
resolve('playback complete');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}, PLAYBACK_CHECK_INTERVAL_MS);
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
clearInterval(interval);
|
|
176
|
+
resolve('max wait reached');
|
|
177
|
+
}, timeoutMs);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
async refreshAudioDevice() {
|
|
181
|
+
logger.debug('[Google] Refreshing audio device (restarting capture)');
|
|
182
|
+
await this.audioHandler.restartCapture();
|
|
183
|
+
}
|
|
184
|
+
on(event, handler) {
|
|
185
|
+
if (!this.eventHandlers.has(event)) {
|
|
186
|
+
this.eventHandlers.set(event, new Set());
|
|
187
|
+
}
|
|
188
|
+
this.eventHandlers.get(event).add(handler);
|
|
189
|
+
return () => this.off(event, handler);
|
|
190
|
+
}
|
|
191
|
+
off(event, handler) {
|
|
192
|
+
this.eventHandlers.get(event)?.delete(handler);
|
|
193
|
+
}
|
|
194
|
+
emit(event, ...args) {
|
|
195
|
+
const handlers = this.eventHandlers.get(event);
|
|
196
|
+
if (!handlers)
|
|
197
|
+
return;
|
|
198
|
+
for (const handler of handlers) {
|
|
199
|
+
handler(...args);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
resetSessionState() {
|
|
203
|
+
this.session = null;
|
|
204
|
+
this.isSetupComplete = false;
|
|
205
|
+
this.awaitingToolResponse = false;
|
|
206
|
+
this.modelResponding = false;
|
|
207
|
+
this.userSpeaking = false;
|
|
208
|
+
this.pendingFunctionCalls.clear();
|
|
209
|
+
this.pendingCommands = [];
|
|
210
|
+
this.outputTranscriptBuffer = '';
|
|
211
|
+
this.inputTranscriptBuffer = '';
|
|
212
|
+
this.lastCompletedToolCalls = [];
|
|
213
|
+
}
|
|
214
|
+
executeRequestResponse() {
|
|
215
|
+
if (!this.session)
|
|
216
|
+
return;
|
|
217
|
+
if (this.awaitingToolResponse) {
|
|
218
|
+
logger.debug('[Google] Skipping requestResponse — Google auto-continues after tool response');
|
|
219
|
+
this.awaitingToolResponse = false;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (this.pendingFunctionCalls.size > 0 || this.modelResponding) {
|
|
223
|
+
logger.debug('[Google] Skipping requestResponse — model busy');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
this.session.sendRealtimeInput({ text: '.' });
|
|
227
|
+
}
|
|
228
|
+
executeSendToolResult(callId, name, output) {
|
|
229
|
+
if (!this.session)
|
|
230
|
+
return;
|
|
231
|
+
this.awaitingToolResponse = true;
|
|
232
|
+
const funcName = this.pendingFunctionCalls.get(callId)?.name ?? name;
|
|
233
|
+
const rawOutput = output;
|
|
234
|
+
let parsedResult;
|
|
235
|
+
try {
|
|
236
|
+
const parsed = JSON.parse(rawOutput);
|
|
237
|
+
parsedResult =
|
|
238
|
+
typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
239
|
+
? parsed
|
|
240
|
+
: { output: parsed };
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
parsedResult = { output: rawOutput };
|
|
244
|
+
}
|
|
245
|
+
const functionResponse = {
|
|
246
|
+
id: callId,
|
|
247
|
+
name: funcName,
|
|
248
|
+
response: parsedResult,
|
|
249
|
+
};
|
|
250
|
+
try {
|
|
251
|
+
this.session.sendToolResponse({ functionResponses: [functionResponse] });
|
|
252
|
+
logger.debug('[Google] Tool response sent for', funcName, 'size:', rawOutput.length);
|
|
253
|
+
this.lastCompletedToolCalls.push(`${funcName}(${rawOutput.slice(0, 100)})`);
|
|
254
|
+
if (this.lastCompletedToolCalls.length > MAX_TOOL_CALL_HISTORY) {
|
|
255
|
+
this.lastCompletedToolCalls.shift();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
logger.error('[Google] Failed to send tool response for', funcName, error);
|
|
260
|
+
this.emit('error', { message: `Tool response failed for ${funcName}: ${error}` });
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
this.pendingFunctionCalls.delete(callId);
|
|
264
|
+
if (this.pendingFunctionCalls.size === 0) {
|
|
265
|
+
this.modelResponding = false;
|
|
266
|
+
this.audioHandler.resumeCapture();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
finalizeInputTranscript() {
|
|
271
|
+
if (this.inputTranscriptBuffer) {
|
|
272
|
+
this.emit('userTranscriptCompleted', this.inputTranscriptBuffer);
|
|
273
|
+
this.inputTranscriptBuffer = '';
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
handleServerMessage(message) {
|
|
277
|
+
const msgRecord = message;
|
|
278
|
+
const keys = Object.keys(msgRecord).filter((k) => msgRecord[k] != null);
|
|
279
|
+
logger.debug('[Google] message keys:', keys.join(', '));
|
|
280
|
+
if (message.setupComplete !== undefined) {
|
|
281
|
+
logger.debug('[Google] Setup complete');
|
|
282
|
+
this.isSetupComplete = true;
|
|
283
|
+
this.emit('sessionReady');
|
|
284
|
+
this.flushPendingCommands();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (message.voiceActivity) {
|
|
288
|
+
this.handleVoiceActivity(message.voiceActivity);
|
|
289
|
+
}
|
|
290
|
+
if (message.usageMetadata) {
|
|
291
|
+
this.extractAndEmitUsage(message.usageMetadata);
|
|
292
|
+
}
|
|
293
|
+
if (message.serverContent) {
|
|
294
|
+
this.handleServerContent(message.serverContent);
|
|
295
|
+
}
|
|
296
|
+
if (message.toolCall) {
|
|
297
|
+
this.ensureSpeechStopped();
|
|
298
|
+
this.handleToolCall(message.toolCall);
|
|
299
|
+
}
|
|
300
|
+
if (message.toolCallCancellation) {
|
|
301
|
+
logger.debug('[Google] Tool call cancelled, clearing', this.pendingFunctionCalls.size, 'pending calls');
|
|
302
|
+
this.pendingFunctionCalls.clear();
|
|
303
|
+
this.modelResponding = false;
|
|
304
|
+
this.audioHandler.resumeCapture();
|
|
305
|
+
this.audioHandler.clearPlaybackQueue();
|
|
306
|
+
}
|
|
307
|
+
const serverMessage = message;
|
|
308
|
+
if (serverMessage.goAway) {
|
|
309
|
+
const goAway = serverMessage.goAway;
|
|
310
|
+
const timeLeft = goAway.timeLeft;
|
|
311
|
+
logger.warn('[Google] Server disconnecting soon, timeLeft:', timeLeft);
|
|
312
|
+
this.emit('serverDisconnecting', timeLeft);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
extractAndEmitUsage(metadata) {
|
|
316
|
+
logger.debug('[Google Live] Raw usage metadata:', JSON.stringify(metadata));
|
|
317
|
+
const inputTokens = metadata.promptTokenCount ?? 0;
|
|
318
|
+
const outputTokens = metadata.responseTokenCount ?? 0;
|
|
319
|
+
const totalTokens = metadata.totalTokenCount ?? (inputTokens + outputTokens);
|
|
320
|
+
const findModality = (details, modality) => details?.find((d) => d.modality === modality)?.tokenCount ?? undefined;
|
|
321
|
+
const providerUsage = {
|
|
322
|
+
inputTokens,
|
|
323
|
+
outputTokens,
|
|
324
|
+
totalTokens,
|
|
325
|
+
cachedTokens: metadata.cachedContentTokenCount ?? undefined,
|
|
326
|
+
audioInputTokens: findModality(metadata.promptTokensDetails, MediaModality.AUDIO),
|
|
327
|
+
audioOutputTokens: findModality(metadata.responseTokensDetails, MediaModality.AUDIO),
|
|
328
|
+
textInputTokens: findModality(metadata.promptTokensDetails, MediaModality.TEXT),
|
|
329
|
+
textOutputTokens: findModality(metadata.responseTokensDetails, MediaModality.TEXT),
|
|
330
|
+
};
|
|
331
|
+
this.emit('usageUpdated', providerUsage);
|
|
332
|
+
}
|
|
333
|
+
flushPendingCommands() {
|
|
334
|
+
const commands = this.pendingCommands;
|
|
335
|
+
this.pendingCommands = [];
|
|
336
|
+
for (const cmd of commands) {
|
|
337
|
+
switch (cmd.type) {
|
|
338
|
+
case 'requestResponse':
|
|
339
|
+
this.executeRequestResponse();
|
|
340
|
+
break;
|
|
341
|
+
case 'sendToolResult': {
|
|
342
|
+
const p = cmd.payload;
|
|
343
|
+
this.executeSendToolResult(p.callId, p.name, p.output);
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
case 'sendText':
|
|
347
|
+
this.sendText(cmd.payload);
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
handleVoiceActivity(activity) {
|
|
353
|
+
if (activity.voiceActivityType === VoiceActivityType.ACTIVITY_START && !this.userSpeaking) {
|
|
354
|
+
logger.debug('[Google] Voice activity: ACTIVITY_START');
|
|
355
|
+
this.userSpeaking = true;
|
|
356
|
+
this.emit('userSpeechStarted');
|
|
357
|
+
}
|
|
358
|
+
else if (activity.voiceActivityType === VoiceActivityType.ACTIVITY_END && this.userSpeaking) {
|
|
359
|
+
logger.debug('[Google] Voice activity: ACTIVITY_END');
|
|
360
|
+
this.ensureSpeechStopped();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
handleServerContent(content) {
|
|
364
|
+
const hasContent = content.inputTranscription || content.outputTranscription
|
|
365
|
+
|| content.modelTurn || content.turnComplete;
|
|
366
|
+
if (!hasContent && !this.modelResponding && !this.userSpeaking) {
|
|
367
|
+
logger.debug('[Google] Speech activity detected (empty serverContent fallback)');
|
|
368
|
+
this.userSpeaking = true;
|
|
369
|
+
this.emit('userSpeechStarted');
|
|
370
|
+
}
|
|
371
|
+
if (content.inputTranscription?.text) {
|
|
372
|
+
logger.debug('[Google] inputTranscription text:', content.inputTranscription.text.slice(0, 80));
|
|
373
|
+
if (!this.userSpeaking) {
|
|
374
|
+
this.userSpeaking = true;
|
|
375
|
+
this.emit('userSpeechStarted');
|
|
376
|
+
}
|
|
377
|
+
this.inputTranscriptBuffer += content.inputTranscription.text;
|
|
378
|
+
this.emit('userTranscriptDelta', content.inputTranscription.text);
|
|
379
|
+
}
|
|
380
|
+
if (content.inputTranscription?.finished) {
|
|
381
|
+
logger.debug('[Google] inputTranscription finished');
|
|
382
|
+
this.finalizeInputTranscript();
|
|
383
|
+
this.emit('userSpeechStopped');
|
|
384
|
+
this.userSpeaking = false;
|
|
385
|
+
}
|
|
386
|
+
const isFirstModelResponse = content.modelTurn && !this.modelResponding;
|
|
387
|
+
if (content.modelTurn) {
|
|
388
|
+
this.handleModelTurn(content);
|
|
389
|
+
}
|
|
390
|
+
if (content.outputTranscription?.text) {
|
|
391
|
+
if (isFirstModelResponse) {
|
|
392
|
+
this.deferAssistantTranscript(content.outputTranscription.text);
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
this.outputTranscriptBuffer += content.outputTranscription.text;
|
|
396
|
+
this.emit('assistantTranscriptDelta', content.outputTranscription.text);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (content.turnComplete) {
|
|
400
|
+
this.handleTurnComplete();
|
|
401
|
+
}
|
|
402
|
+
if (content.interrupted) {
|
|
403
|
+
this.handleInterrupted();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
static THINKING_RENDER_DELAY_MS = 150;
|
|
407
|
+
deferAssistantTranscript(text) {
|
|
408
|
+
setTimeout(() => {
|
|
409
|
+
this.outputTranscriptBuffer += text;
|
|
410
|
+
this.emit('assistantTranscriptDelta', text);
|
|
411
|
+
}, GoogleRealtimeProvider.THINKING_RENDER_DELAY_MS);
|
|
412
|
+
}
|
|
413
|
+
ensureSpeechStopped() {
|
|
414
|
+
if (this.inputTranscriptBuffer) {
|
|
415
|
+
this.finalizeInputTranscript();
|
|
416
|
+
}
|
|
417
|
+
this.userSpeaking = false;
|
|
418
|
+
this.emit('userSpeechStopped');
|
|
419
|
+
}
|
|
420
|
+
handleModelTurn(content) {
|
|
421
|
+
if (!this.modelResponding) {
|
|
422
|
+
logger.debug('[Google] Model turn started');
|
|
423
|
+
}
|
|
424
|
+
this.modelResponding = true;
|
|
425
|
+
this.ensureSpeechStopped();
|
|
426
|
+
if (content.modelTurn?.parts) {
|
|
427
|
+
for (const part of content.modelTurn.parts) {
|
|
428
|
+
if (part.inlineData?.mimeType?.startsWith('audio/') && part.inlineData.data) {
|
|
429
|
+
this.audioHandler.playAudio(part.inlineData.data);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
handleTurnComplete() {
|
|
435
|
+
logger.debug('[Google] Turn complete');
|
|
436
|
+
this.modelResponding = false;
|
|
437
|
+
if (this.outputTranscriptBuffer) {
|
|
438
|
+
this.emit('assistantTranscriptDone', this.outputTranscriptBuffer);
|
|
439
|
+
}
|
|
440
|
+
this.outputTranscriptBuffer = '';
|
|
441
|
+
this.emit('audioDone');
|
|
442
|
+
}
|
|
443
|
+
handleInterrupted() {
|
|
444
|
+
logger.debug('[Google] Model interrupted');
|
|
445
|
+
this.modelResponding = false;
|
|
446
|
+
this.finalizeInputTranscript();
|
|
447
|
+
this.outputTranscriptBuffer = '';
|
|
448
|
+
this.audioHandler.clearPlaybackQueue();
|
|
449
|
+
this.emit('interrupted');
|
|
450
|
+
}
|
|
451
|
+
handleToolCall(toolCall) {
|
|
452
|
+
if (!toolCall.functionCalls)
|
|
453
|
+
return;
|
|
454
|
+
this.modelResponding = true;
|
|
455
|
+
this.audioHandler.pauseCapture();
|
|
456
|
+
for (const fc of toolCall.functionCalls) {
|
|
457
|
+
const callId = fc.id || `call_${Date.now()}`;
|
|
458
|
+
const name = fc.name ?? '';
|
|
459
|
+
const args = JSON.stringify(fc.args || {});
|
|
460
|
+
logger.debug('[Google] Tool call received:', name, 'args:', args);
|
|
461
|
+
this.pendingFunctionCalls.set(callId, { name, args });
|
|
462
|
+
this.emit('functionCall', { callId, name, arguments: args });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export function mapToolsForGoogle(tools) {
|
|
2
|
+
return tools.map((tool) => {
|
|
3
|
+
const declaration = {
|
|
4
|
+
name: tool.name,
|
|
5
|
+
description: tool.description,
|
|
6
|
+
};
|
|
7
|
+
const properties = tool.parameters.properties;
|
|
8
|
+
const hasProperties = Object.keys(properties).length > 0;
|
|
9
|
+
if (hasProperties) {
|
|
10
|
+
declaration.parameters = {
|
|
11
|
+
type: 'OBJECT',
|
|
12
|
+
properties: Object.fromEntries(Object.entries(properties).map(([key, prop]) => [key, toGoogleSchema(prop)])),
|
|
13
|
+
required: tool.parameters.required && tool.parameters.required.length > 0
|
|
14
|
+
? tool.parameters.required
|
|
15
|
+
: undefined,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return declaration;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function toGoogleSchema(prop) {
|
|
22
|
+
const schema = {
|
|
23
|
+
type: mapType(prop.type),
|
|
24
|
+
description: prop.description || undefined,
|
|
25
|
+
};
|
|
26
|
+
if (prop.format) {
|
|
27
|
+
schema.format = prop.format;
|
|
28
|
+
}
|
|
29
|
+
if (prop.enum) {
|
|
30
|
+
schema.enum = prop.enum.map(String);
|
|
31
|
+
}
|
|
32
|
+
if (prop.items) {
|
|
33
|
+
schema.items = toGoogleSchema(prop.items);
|
|
34
|
+
}
|
|
35
|
+
if (prop.properties) {
|
|
36
|
+
schema.properties = Object.fromEntries(Object.entries(prop.properties).map(([key, nested]) => [key, toGoogleSchema(nested)]));
|
|
37
|
+
}
|
|
38
|
+
if (prop.required && prop.required.length > 0) {
|
|
39
|
+
schema.required = prop.required;
|
|
40
|
+
}
|
|
41
|
+
return schema;
|
|
42
|
+
}
|
|
43
|
+
function mapType(type) {
|
|
44
|
+
if (typeof type === 'string') {
|
|
45
|
+
return jsonSchemaTypeToGoogle(type);
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(type)) {
|
|
48
|
+
const nonNull = type.filter((t) => t !== 'null');
|
|
49
|
+
return nonNull.length > 0 ? jsonSchemaTypeToGoogle(nonNull[0]) : 'STRING';
|
|
50
|
+
}
|
|
51
|
+
return 'STRING';
|
|
52
|
+
}
|
|
53
|
+
function jsonSchemaTypeToGoogle(type) {
|
|
54
|
+
switch (type.toLowerCase()) {
|
|
55
|
+
case 'string':
|
|
56
|
+
return 'STRING';
|
|
57
|
+
case 'number':
|
|
58
|
+
return 'NUMBER';
|
|
59
|
+
case 'integer':
|
|
60
|
+
return 'INTEGER';
|
|
61
|
+
case 'boolean':
|
|
62
|
+
return 'BOOLEAN';
|
|
63
|
+
case 'array':
|
|
64
|
+
return 'ARRAY';
|
|
65
|
+
case 'object':
|
|
66
|
+
return 'OBJECT';
|
|
67
|
+
default:
|
|
68
|
+
return 'STRING';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GoogleRealtimeProvider } from './google-provider';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GoogleRealtimeProvider } from './google-provider';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpenAiRealtimeProvider } from './openai-provider';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpenAiRealtimeProvider } from './openai-provider';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function waitForAudioPlaybackEnd(peerConnection: RTCPeerConnection, maxWaitMs?: number): Promise<string>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const CHECK_INTERVAL_MS = 150;
|
|
2
|
+
const REQUIRED_SILENCE_CHECKS = 5;
|
|
3
|
+
const ENERGY_DELTA_THRESHOLD = 0.001;
|
|
4
|
+
const SILENCE_DELAY_MS = 300;
|
|
5
|
+
const DEFAULT_MAX_WAIT_MS = 20000;
|
|
6
|
+
export function waitForAudioPlaybackEnd(peerConnection, maxWaitMs = DEFAULT_MAX_WAIT_MS) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const audioReceiver = peerConnection.getReceivers()
|
|
9
|
+
.find(r => r.track?.kind === 'audio');
|
|
10
|
+
if (!audioReceiver) {
|
|
11
|
+
reject(new Error('No audio receiver'));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
let lastEnergy = -1;
|
|
15
|
+
let silenceCount = 0;
|
|
16
|
+
const interval = setInterval(async () => {
|
|
17
|
+
try {
|
|
18
|
+
const currentEnergy = extractAudioEnergy(await audioReceiver.getStats());
|
|
19
|
+
if (currentEnergy < 0) {
|
|
20
|
+
clearInterval(interval);
|
|
21
|
+
reject(new Error('totalAudioEnergy not available'));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (lastEnergy >= 0) {
|
|
25
|
+
const isSilent = currentEnergy - lastEnergy < ENERGY_DELTA_THRESHOLD;
|
|
26
|
+
silenceCount = isSilent ? silenceCount + 1 : 0;
|
|
27
|
+
if (silenceCount >= REQUIRED_SILENCE_CHECKS) {
|
|
28
|
+
clearInterval(interval);
|
|
29
|
+
setTimeout(() => resolve('silence detected'), SILENCE_DELAY_MS);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
lastEnergy = currentEnergy;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
clearInterval(interval);
|
|
37
|
+
reject(new Error('getStats failed'));
|
|
38
|
+
}
|
|
39
|
+
}, CHECK_INTERVAL_MS);
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
clearInterval(interval);
|
|
42
|
+
resolve('max wait reached');
|
|
43
|
+
}, maxWaitMs);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function extractAudioEnergy(stats) {
|
|
47
|
+
let energy = -1;
|
|
48
|
+
stats.forEach((report) => {
|
|
49
|
+
const rtpReport = report;
|
|
50
|
+
if (rtpReport.type === 'inbound-rtp' && rtpReport.totalAudioEnergy !== undefined) {
|
|
51
|
+
energy = rtpReport.totalAudioEnergy;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return energy;
|
|
55
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { RealtimeProviderClient, RealtimeProviderConnectConfig, ProviderEventMap } from '../types';
|
|
2
|
+
export declare class OpenAiRealtimeProvider implements RealtimeProviderClient {
|
|
3
|
+
private readonly client;
|
|
4
|
+
private readonly eventHandlers;
|
|
5
|
+
private readonly oobResponseIds;
|
|
6
|
+
private readonly oobResponseBuffers;
|
|
7
|
+
private transcriptionConfig;
|
|
8
|
+
private outputAudioPlaying;
|
|
9
|
+
private sawOutputAudioBufferEvents;
|
|
10
|
+
private readonly playbackEndWaiters;
|
|
11
|
+
private analyserContext;
|
|
12
|
+
private analyserNode;
|
|
13
|
+
private analyserRafId;
|
|
14
|
+
private readonly audioElement;
|
|
15
|
+
constructor();
|
|
16
|
+
get status(): 'disconnected' | 'connecting' | 'connected';
|
|
17
|
+
connect(config: RealtimeProviderConnectConfig): Promise<void>;
|
|
18
|
+
close(): void;
|
|
19
|
+
resumeAudio(): void;
|
|
20
|
+
mute(muted: boolean): void;
|
|
21
|
+
interrupt(): void;
|
|
22
|
+
requestResponse(): void;
|
|
23
|
+
sendToolResult(callId: string, name: string, output: string): void;
|
|
24
|
+
sendText(text: string): void;
|
|
25
|
+
waitForAudioPlaybackEnd(timeoutMs?: number): Promise<string>;
|
|
26
|
+
private resolvePlaybackEndWaiters;
|
|
27
|
+
refreshAudioDevice(): Promise<void>;
|
|
28
|
+
on<K extends keyof ProviderEventMap>(event: K, handler: ProviderEventMap[K]): () => void;
|
|
29
|
+
off<K extends keyof ProviderEventMap>(event: K, handler: ProviderEventMap[K]): void;
|
|
30
|
+
private emit;
|
|
31
|
+
private getPeerConnection;
|
|
32
|
+
private applyMicNoiseConstraints;
|
|
33
|
+
private startAudioLevelAnalysis;
|
|
34
|
+
private stopAudioLevelAnalysis;
|
|
35
|
+
private setupInternalEventHandlers;
|
|
36
|
+
private handleServerEvent;
|
|
37
|
+
private handleAudioCommitted;
|
|
38
|
+
private handleResponseCreated;
|
|
39
|
+
private handleTextDelta;
|
|
40
|
+
private handleResponseDone;
|
|
41
|
+
private extractAndEmitUsage;
|
|
42
|
+
private processResponseOutput;
|
|
43
|
+
private finalizeOobTranscription;
|
|
44
|
+
private buildOobPrompt;
|
|
45
|
+
private handleNativeTranscriptionCompleted;
|
|
46
|
+
private emitAssistantDelta;
|
|
47
|
+
private handleAudioTranscriptDone;
|
|
48
|
+
private handleFunctionCallDone;
|
|
49
|
+
private handleError;
|
|
50
|
+
}
|