@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,579 @@
1
+ import { OpenAIRealtimeWebRTC } from '@openai/agents-realtime';
2
+ import { mapToolsForOpenAI } from './openai-tool-mapper';
3
+ import { waitForAudioPlaybackEnd } from './openai-audio-monitor';
4
+ import { logger } from '../../../utils/logger';
5
+ function unwrapTranscript(text) {
6
+ try {
7
+ const parsed = JSON.parse(text);
8
+ if (typeof parsed === 'string')
9
+ return parsed;
10
+ if (typeof parsed !== 'object' || parsed === null)
11
+ return text;
12
+ const transcript = parsed.transcript;
13
+ if (typeof transcript === 'string')
14
+ return transcript;
15
+ }
16
+ catch {
17
+ // not JSON — treat as plain text
18
+ }
19
+ return text;
20
+ }
21
+ function createPlaybackElement() {
22
+ if (typeof document === 'undefined')
23
+ return null;
24
+ const el = document.createElement('audio');
25
+ el.autoplay = true;
26
+ // playsinline keeps iOS from hijacking WebRTC audio into a fullscreen player.
27
+ el.setAttribute('playsinline', '');
28
+ el.style.display = 'none';
29
+ document.body.appendChild(el);
30
+ return el;
31
+ }
32
+ const TRANSCRIPTION_PURPOSE = 'user_transcription';
33
+ const DEFAULT_TRANSCRIBE_MODEL = 'gpt-4o-transcribe';
34
+ const OUTPUT_AUDIO_PLAYBACK_TIMEOUT_MS = 20000;
35
+ export class OpenAiRealtimeProvider {
36
+ client;
37
+ eventHandlers = new Map();
38
+ oobResponseIds = new Set();
39
+ oobResponseBuffers = new Map();
40
+ transcriptionConfig;
41
+ // WebRTC delivers `output_audio_buffer.started/stopped` over the data channel.
42
+ // These track *playback* (when the user actually hears audio), unlike
43
+ // `response.output_audio.done` which only signals generation finished — so
44
+ // they are the reliable "assistant finished speaking" signal.
45
+ outputAudioPlaying = false;
46
+ sawOutputAudioBufferEvents = false;
47
+ playbackEndWaiters = new Set();
48
+ analyserContext = null;
49
+ analyserNode = null;
50
+ analyserRafId = null;
51
+ // We own the playback element (instead of letting the SDK create a detached
52
+ // one) so it can be re-played after iOS pauses it on PWA backgrounding.
53
+ audioElement;
54
+ constructor() {
55
+ this.audioElement = createPlaybackElement();
56
+ this.client = new OpenAIRealtimeWebRTC(this.audioElement ? { audioElement: this.audioElement } : {});
57
+ }
58
+ get status() {
59
+ return this.client.status;
60
+ }
61
+ async connect(config) {
62
+ this.transcriptionConfig = config.transcription;
63
+ this.setupInternalEventHandlers();
64
+ const audioInput = {
65
+ noiseReduction: { type: 'near_field' },
66
+ turnDetection: {
67
+ type: 'semantic_vad',
68
+ eagerness: 'low',
69
+ createResponse: true,
70
+ interruptResponse: true,
71
+ },
72
+ };
73
+ if (config.transcription?.mode === 'native') {
74
+ audioInput.transcription = {
75
+ model: config.transcription.model || DEFAULT_TRANSCRIBE_MODEL,
76
+ language: config.transcription.language,
77
+ ...(config.transcription.prompt ? { prompt: config.transcription.prompt } : {}),
78
+ };
79
+ }
80
+ await this.client.connect({
81
+ apiKey: config.apiKey,
82
+ model: config.model,
83
+ initialSessionConfig: {
84
+ instructions: config.instructions,
85
+ tools: mapToolsForOpenAI(config.tools),
86
+ toolChoice: config.toolChoice,
87
+ audio: {
88
+ input: audioInput,
89
+ output: {
90
+ voice: config.voice || 'ash',
91
+ speed: config.speed ?? 1,
92
+ },
93
+ },
94
+ },
95
+ });
96
+ }
97
+ close() {
98
+ this.stopAudioLevelAnalysis();
99
+ this.client.close();
100
+ this.audioElement?.remove();
101
+ this.oobResponseIds.clear();
102
+ this.oobResponseBuffers.clear();
103
+ this.outputAudioPlaying = false;
104
+ this.resolvePlaybackEndWaiters('closed');
105
+ }
106
+ resumeAudio() {
107
+ if (this.analyserContext?.state === 'suspended') {
108
+ void this.analyserContext.resume().catch(() => undefined);
109
+ }
110
+ if (this.audioElement?.paused) {
111
+ void this.audioElement.play().catch(() => undefined);
112
+ }
113
+ }
114
+ mute(muted) {
115
+ this.client.mute(muted);
116
+ }
117
+ interrupt() {
118
+ this.client.interrupt();
119
+ this.outputAudioPlaying = false;
120
+ this.resolvePlaybackEndWaiters('interrupted');
121
+ }
122
+ requestResponse() {
123
+ this.client.sendEvent({ type: 'response.create' });
124
+ }
125
+ sendToolResult(callId, name, output) {
126
+ try {
127
+ this.client.sendEvent({
128
+ type: 'conversation.item.create',
129
+ item: { type: 'function_call_output', call_id: callId, output },
130
+ });
131
+ this.client.sendEvent({ type: 'response.create' });
132
+ logger.debug(`[OpenAI] Tool result sent: ${name}`);
133
+ }
134
+ catch {
135
+ logger.debug(`[OpenAI] Failed to send tool result for ${name} (connection may be closing)`);
136
+ }
137
+ }
138
+ sendText(text) {
139
+ this.client.sendEvent({
140
+ type: 'conversation.item.create',
141
+ item: {
142
+ type: 'message',
143
+ role: 'user',
144
+ content: [{ type: 'input_text', text }],
145
+ },
146
+ });
147
+ this.client.sendEvent({ type: 'response.create' });
148
+ }
149
+ async waitForAudioPlaybackEnd(timeoutMs = OUTPUT_AUDIO_PLAYBACK_TIMEOUT_MS) {
150
+ // Preferred: the WebRTC data channel reports exactly when playback ends.
151
+ if (this.sawOutputAudioBufferEvents) {
152
+ if (!this.outputAudioPlaying) {
153
+ return 'output audio idle';
154
+ }
155
+ return new Promise((resolve) => {
156
+ let settled = false;
157
+ const finish = (reason) => {
158
+ if (settled)
159
+ return;
160
+ settled = true;
161
+ this.playbackEndWaiters.delete(finish);
162
+ resolve(reason);
163
+ };
164
+ this.playbackEndWaiters.add(finish);
165
+ setTimeout(() => finish('playback wait timeout'), timeoutMs);
166
+ });
167
+ }
168
+ // Fallback for API surfaces without output_audio_buffer.*: RTP silence.
169
+ const peerConnection = this.getPeerConnection();
170
+ if (!peerConnection) {
171
+ return 'no peer connection';
172
+ }
173
+ return waitForAudioPlaybackEnd(peerConnection, timeoutMs);
174
+ }
175
+ resolvePlaybackEndWaiters(reason) {
176
+ const waiters = [...this.playbackEndWaiters];
177
+ this.playbackEndWaiters.clear();
178
+ waiters.forEach((resolve) => resolve(reason));
179
+ }
180
+ async refreshAudioDevice() {
181
+ const peerConnection = this.getPeerConnection();
182
+ if (!peerConnection)
183
+ return;
184
+ const audioSender = peerConnection.getSenders().find((s) => s.track?.kind === 'audio');
185
+ if (!audioSender?.track)
186
+ return;
187
+ const currentDeviceId = audioSender.track.getSettings().deviceId;
188
+ try {
189
+ const newStream = await navigator.mediaDevices.getUserMedia({ audio: true });
190
+ const newTrack = newStream.getAudioTracks()[0];
191
+ const newDeviceId = newTrack.getSettings().deviceId;
192
+ if (currentDeviceId === newDeviceId) {
193
+ newTrack.stop();
194
+ logger.debug('[OpenAI] Audio device unchanged after devicechange event');
195
+ return;
196
+ }
197
+ const oldTrack = audioSender.track;
198
+ await audioSender.replaceTrack(newTrack);
199
+ oldTrack.stop();
200
+ logger.debug(`[OpenAI] Audio device refreshed: ${currentDeviceId} → ${newDeviceId}`);
201
+ }
202
+ catch (err) {
203
+ logger.debug('[OpenAI] Failed to refresh audio device:', err);
204
+ }
205
+ }
206
+ on(event, handler) {
207
+ if (!this.eventHandlers.has(event)) {
208
+ this.eventHandlers.set(event, new Set());
209
+ }
210
+ this.eventHandlers.get(event).add(handler);
211
+ return () => this.off(event, handler);
212
+ }
213
+ off(event, handler) {
214
+ this.eventHandlers.get(event)?.delete(handler);
215
+ }
216
+ emit(event, ...args) {
217
+ const handlers = this.eventHandlers.get(event);
218
+ if (!handlers)
219
+ return;
220
+ for (const handler of handlers) {
221
+ handler(...args);
222
+ }
223
+ }
224
+ getPeerConnection() {
225
+ const state = this.client.connectionState;
226
+ return state.status === 'connected' ? state.peerConnection : null;
227
+ }
228
+ // The SDK opens the mic with a bare getUserMedia({ audio: true }), so the
229
+ // browser's noise/echo/gain processing isn't guaranteed on — on mobile that
230
+ // let background noise through to the VAD and the assistant kept reacting to
231
+ // it. Force the processing on for the live mic track once connected.
232
+ async applyMicNoiseConstraints() {
233
+ const peerConnection = this.getPeerConnection();
234
+ const track = peerConnection?.getSenders().find((s) => s.track?.kind === 'audio')?.track;
235
+ if (!track)
236
+ return;
237
+ try {
238
+ await track.applyConstraints({
239
+ echoCancellation: true,
240
+ noiseSuppression: true,
241
+ autoGainControl: true,
242
+ });
243
+ logger.debug('[OpenAI] Mic noise constraints applied');
244
+ }
245
+ catch (err) {
246
+ logger.debug('[OpenAI] Failed to apply mic noise constraints:', err);
247
+ }
248
+ }
249
+ startAudioLevelAnalysis() {
250
+ const peerConnection = this.getPeerConnection();
251
+ if (!peerConnection)
252
+ return;
253
+ const audioSender = peerConnection.getSenders().find((s) => s.track?.kind === 'audio');
254
+ if (!audioSender?.track) {
255
+ logger.debug('[OpenAI] No mic track yet — audio level analysis skipped');
256
+ return;
257
+ }
258
+ try {
259
+ this.analyserContext = new AudioContext();
260
+ const stream = new MediaStream([audioSender.track]);
261
+ const source = this.analyserContext.createMediaStreamSource(stream);
262
+ this.analyserNode = this.analyserContext.createAnalyser();
263
+ // 1024 samples ≈ 21 ms at 48 kHz — fast enough to react to syllables
264
+ // but stable enough for time-domain RMS. The downstream envelope
265
+ // follower in voice-state-manager owns the actual smoothing, so we
266
+ // pick a modest internal coefficient.
267
+ this.analyserNode.fftSize = 1024;
268
+ this.analyserNode.smoothingTimeConstant = 0.4;
269
+ source.connect(this.analyserNode);
270
+ const buffer = new Float32Array(this.analyserNode.fftSize);
271
+ const tick = () => {
272
+ if (!this.analyserNode)
273
+ return;
274
+ this.analyserNode.getFloatTimeDomainData(buffer);
275
+ let sumSquares = 0;
276
+ for (const sample of buffer) {
277
+ sumSquares += sample * sample;
278
+ }
279
+ const rms = Math.sqrt(sumSquares / buffer.length);
280
+ this.emit('userAudioLevel', Math.min(1, rms));
281
+ this.analyserRafId = requestAnimationFrame(tick);
282
+ };
283
+ this.analyserRafId = requestAnimationFrame(tick);
284
+ logger.debug('[OpenAI] Audio level analyser wired to mic track');
285
+ }
286
+ catch (err) {
287
+ logger.warn('[OpenAI] Audio level analyser setup failed:', err);
288
+ this.stopAudioLevelAnalysis();
289
+ }
290
+ }
291
+ stopAudioLevelAnalysis() {
292
+ if (this.analyserRafId !== null) {
293
+ cancelAnimationFrame(this.analyserRafId);
294
+ this.analyserRafId = null;
295
+ }
296
+ this.analyserNode?.disconnect();
297
+ this.analyserNode = null;
298
+ if (this.analyserContext && this.analyserContext.state !== 'closed') {
299
+ void this.analyserContext.close();
300
+ }
301
+ this.analyserContext = null;
302
+ }
303
+ setupInternalEventHandlers() {
304
+ this.client.on('connection_change', (status) => {
305
+ switch (status) {
306
+ case 'connected':
307
+ this.emit('connectionStateChanged', 'connected');
308
+ void this.applyMicNoiseConstraints();
309
+ this.startAudioLevelAnalysis();
310
+ break;
311
+ case 'disconnected':
312
+ this.oobResponseIds.clear();
313
+ this.oobResponseBuffers.clear();
314
+ this.stopAudioLevelAnalysis();
315
+ this.emit('connectionStateChanged', 'disconnected');
316
+ break;
317
+ case 'connecting':
318
+ this.emit('connectionStateChanged', 'connecting');
319
+ break;
320
+ }
321
+ });
322
+ this.client.on('audio_done', () => {
323
+ this.emit('audioDone');
324
+ });
325
+ this.client.on('*', (event) => {
326
+ this.handleServerEvent(event);
327
+ });
328
+ }
329
+ handleServerEvent(event) {
330
+ const type = event.type;
331
+ if (!type)
332
+ return;
333
+ switch (type) {
334
+ case 'session.created':
335
+ this.emit('sessionReady');
336
+ break;
337
+ case 'input_audio_buffer.speech_started':
338
+ this.emit('userSpeechStarted');
339
+ break;
340
+ case 'input_audio_buffer.speech_stopped':
341
+ this.emit('userSpeechStopped');
342
+ break;
343
+ case 'input_audio_buffer.committed':
344
+ this.handleAudioCommitted(event);
345
+ break;
346
+ case 'response.created':
347
+ this.handleResponseCreated(event);
348
+ break;
349
+ case 'response.text.delta':
350
+ case 'response.output_text.delta':
351
+ this.handleTextDelta(event);
352
+ break;
353
+ case 'response.output_audio_transcript.delta':
354
+ this.emitAssistantDelta(event);
355
+ break;
356
+ case 'response.audio_transcript.done':
357
+ case 'response.output_audio_transcript.done':
358
+ this.handleAudioTranscriptDone(event);
359
+ break;
360
+ case 'response.done':
361
+ case 'response.completed':
362
+ this.handleResponseDone(event);
363
+ break;
364
+ case 'output_audio_buffer.started':
365
+ this.sawOutputAudioBufferEvents = true;
366
+ this.outputAudioPlaying = true;
367
+ break;
368
+ case 'output_audio_buffer.stopped':
369
+ case 'output_audio_buffer.cleared':
370
+ this.sawOutputAudioBufferEvents = true;
371
+ this.outputAudioPlaying = false;
372
+ this.resolvePlaybackEndWaiters(type);
373
+ break;
374
+ case 'conversation.item.input_audio_transcription.completed':
375
+ this.handleNativeTranscriptionCompleted(event);
376
+ break;
377
+ case 'response.function_call_arguments.done':
378
+ this.handleFunctionCallDone(event);
379
+ break;
380
+ case 'error':
381
+ this.handleError(event);
382
+ break;
383
+ }
384
+ }
385
+ // --- OOB Transcription ---
386
+ handleAudioCommitted(event) {
387
+ if (this.transcriptionConfig?.mode !== 'oob')
388
+ return;
389
+ const itemId = event.item_id;
390
+ if (!itemId)
391
+ return;
392
+ logger.debug('[OpenAI] OOB: Requesting transcription for item:', itemId);
393
+ this.client.sendEvent({
394
+ type: 'response.create',
395
+ response: {
396
+ conversation: 'none',
397
+ output_modalities: ['text'],
398
+ tools: [],
399
+ metadata: { purpose: TRANSCRIPTION_PURPOSE },
400
+ instructions: this.buildOobPrompt(),
401
+ input: [{ type: 'item_reference', id: itemId }],
402
+ },
403
+ });
404
+ }
405
+ handleResponseCreated(event) {
406
+ const response = event.response;
407
+ const responseId = response?.id;
408
+ if (!responseId)
409
+ return;
410
+ const metadata = response?.metadata;
411
+ if (metadata?.purpose !== TRANSCRIPTION_PURPOSE)
412
+ return;
413
+ this.oobResponseIds.add(responseId);
414
+ this.oobResponseBuffers.set(responseId, '');
415
+ logger.debug('[OpenAI] OOB: Registered response:', responseId);
416
+ }
417
+ handleTextDelta(event) {
418
+ const responseId = event.response_id;
419
+ const delta = event.delta;
420
+ if (responseId && this.oobResponseIds.has(responseId)) {
421
+ if (!delta)
422
+ return;
423
+ const buffer = (this.oobResponseBuffers.get(responseId) ?? '') + delta;
424
+ this.oobResponseBuffers.set(responseId, buffer);
425
+ this.emit('userTranscriptDelta', delta);
426
+ return;
427
+ }
428
+ if (delta) {
429
+ this.emit('assistantTranscriptDelta', delta);
430
+ }
431
+ }
432
+ handleResponseDone(event) {
433
+ const response = event.response;
434
+ const responseId = response?.id;
435
+ if (responseId && this.oobResponseIds.has(responseId)) {
436
+ this.finalizeOobTranscription(responseId);
437
+ return;
438
+ }
439
+ this.extractAndEmitUsage(response);
440
+ const output = response?.output;
441
+ if (output) {
442
+ this.processResponseOutput(output);
443
+ }
444
+ }
445
+ extractAndEmitUsage(response) {
446
+ const usage = response?.usage;
447
+ if (!usage)
448
+ return;
449
+ logger.debug('[OpenAI Realtime] Raw usage data:', JSON.stringify(usage));
450
+ const inputDetails = usage.input_token_details;
451
+ const outputDetails = usage.output_token_details;
452
+ const inputTokens = usage.input_tokens ?? 0;
453
+ const outputTokens = usage.output_tokens ?? 0;
454
+ const totalTokens = usage.total_tokens ?? (inputTokens + outputTokens);
455
+ const cachedDetails = inputDetails?.cached_tokens_details;
456
+ const textCached = cachedDetails?.text_tokens;
457
+ const audioCached = cachedDetails?.audio_tokens;
458
+ const rawTextInput = inputDetails?.text_tokens;
459
+ const rawAudioInput = inputDetails?.audio_tokens;
460
+ const providerUsage = {
461
+ inputTokens,
462
+ outputTokens,
463
+ totalTokens,
464
+ cachedTokens: inputDetails?.cached_tokens,
465
+ textCachedInputTokens: textCached,
466
+ audioCachedInputTokens: audioCached,
467
+ audioInputTokens: rawAudioInput === undefined ? undefined : Math.max(rawAudioInput - (audioCached ?? 0), 0),
468
+ audioOutputTokens: outputDetails?.audio_tokens,
469
+ textInputTokens: rawTextInput === undefined ? undefined : Math.max(rawTextInput - (textCached ?? 0), 0),
470
+ textOutputTokens: outputDetails?.text_tokens,
471
+ };
472
+ this.emit('usageUpdated', providerUsage);
473
+ }
474
+ processResponseOutput(output) {
475
+ for (const item of output) {
476
+ if (item.type === 'function_call' && item.call_id && item.name && item.arguments) {
477
+ this.emit('functionCall', {
478
+ callId: item.call_id,
479
+ name: item.name,
480
+ arguments: item.arguments,
481
+ });
482
+ continue;
483
+ }
484
+ if (item.type !== 'message')
485
+ continue;
486
+ const content = item.content;
487
+ if (!content)
488
+ continue;
489
+ for (const c of content) {
490
+ if (c.type === 'text' && c.text) {
491
+ this.emit('assistantTranscriptDone', c.text);
492
+ }
493
+ }
494
+ }
495
+ }
496
+ finalizeOobTranscription(responseId) {
497
+ const rawText = (this.oobResponseBuffers.get(responseId) ?? '').trim();
498
+ this.oobResponseIds.delete(responseId);
499
+ this.oobResponseBuffers.delete(responseId);
500
+ if (!rawText)
501
+ return;
502
+ const fullText = unwrapTranscript(rawText);
503
+ if (fullText !== rawText) {
504
+ logger.debug('[OpenAI] OOB: Unwrapped transcript:', rawText, '→', fullText);
505
+ }
506
+ logger.debug('[OpenAI] OOB: Complete:', fullText);
507
+ this.emit('userTranscriptCompleted', fullText);
508
+ }
509
+ buildOobPrompt() {
510
+ const lang = this.transcriptionConfig?.language ?? 'en';
511
+ const langHint = lang === 'de'
512
+ ? 'The user speaks German. Transcribe in German, preserving any English loanwords or technical terms exactly as spoken.'
513
+ : `The user speaks ${lang}. Transcribe in that language.`;
514
+ return `# Task: Verbatim Transcription
515
+
516
+ You are a strict transcription engine. Transcribe exactly what the user said in their most recent spoken turn.
517
+
518
+ ${langHint}
519
+
520
+ ## Rules
521
+ - Transcribe **only** the latest user turn — do not summarize or merge multiple turns.
522
+ - Preserve everything exactly as spoken: filler words (ähm, äh, um), stutters, self-corrections, mispronunciations, slang, incomplete words.
523
+ - Do **not** fix grammar, reorder words, translate, or interpret.
524
+ - Do **not** answer questions, give advice, or continue the conversation.
525
+
526
+ ## Output Format
527
+ Always respond with exactly this JSON structure — no other format, no extra fields:
528
+
529
+ {"transcript": "<verbatim transcription here>"}
530
+
531
+ If there is no transcribable content (silence, noise), respond with:
532
+
533
+ {"transcript": ""}`;
534
+ }
535
+ // --- Native Transcription ---
536
+ handleNativeTranscriptionCompleted(event) {
537
+ const transcript = event.transcript?.trim();
538
+ if (!transcript)
539
+ return;
540
+ logger.debug('[OpenAI] Native transcription:', transcript);
541
+ this.emit('userTranscriptCompleted', transcript);
542
+ }
543
+ // --- Assistant Events ---
544
+ emitAssistantDelta(event) {
545
+ const delta = event.delta;
546
+ if (delta) {
547
+ this.emit('assistantTranscriptDelta', delta);
548
+ }
549
+ }
550
+ handleAudioTranscriptDone(event) {
551
+ const transcript = event.transcript;
552
+ if (transcript) {
553
+ this.emit('assistantTranscriptDone', transcript);
554
+ }
555
+ }
556
+ // --- Function Calls ---
557
+ handleFunctionCallDone(event) {
558
+ const callId = event.call_id;
559
+ const name = event.name;
560
+ const args = event.arguments;
561
+ if (!callId || !name || !args)
562
+ return;
563
+ this.emit('functionCall', { callId, name, arguments: args });
564
+ }
565
+ // --- Errors ---
566
+ handleError(event) {
567
+ const error = event.error;
568
+ const code = error?.code;
569
+ if (code === 'conversation_already_has_active_response') {
570
+ logger.debug('[OpenAI] Ignoring active response conflict');
571
+ return;
572
+ }
573
+ logger.error('[OpenAI] Server error:', error);
574
+ this.emit('error', {
575
+ code,
576
+ message: error?.message || 'Unknown error',
577
+ });
578
+ }
579
+ }
@@ -0,0 +1,15 @@
1
+ import type { ToolDefinition } from '../../../types';
2
+ type OpenAIFunctionToolDefinition = {
3
+ type: 'function';
4
+ name: string;
5
+ description: string;
6
+ parameters: {
7
+ type: 'object';
8
+ properties: Record<string, unknown>;
9
+ required: string[];
10
+ additionalProperties: boolean;
11
+ };
12
+ strict: boolean;
13
+ };
14
+ export declare function mapToolsForOpenAI(tools: ToolDefinition[]): OpenAIFunctionToolDefinition[];
15
+ export {};
@@ -0,0 +1,14 @@
1
+ export function mapToolsForOpenAI(tools) {
2
+ return tools.map((tool) => ({
3
+ type: 'function',
4
+ name: tool.name,
5
+ description: tool.description,
6
+ parameters: {
7
+ type: 'object',
8
+ properties: tool.parameters.properties,
9
+ required: tool.parameters.required ?? [],
10
+ additionalProperties: tool.parameters.additionalProperties ?? false,
11
+ },
12
+ strict: tool.strict ?? false,
13
+ }));
14
+ }
@@ -0,0 +1,4 @@
1
+ import type { RealtimeProviderClient, RealtimeProviderType } from './types';
2
+ export type RealtimeProviderLoader = () => Promise<RealtimeProviderClient> | RealtimeProviderClient;
3
+ export declare function registerRealtimeProvider(providerType: RealtimeProviderType, loader: RealtimeProviderLoader): void;
4
+ export declare function createRealtimeProvider(providerType: RealtimeProviderType): Promise<RealtimeProviderClient>;
@@ -0,0 +1,12 @@
1
+ const registry = new Map();
2
+ export function registerRealtimeProvider(providerType, loader) {
3
+ registry.set(providerType, loader);
4
+ }
5
+ export async function createRealtimeProvider(providerType) {
6
+ const loader = registry.get(providerType);
7
+ if (!loader) {
8
+ throw new Error(`No realtime provider registered for "${providerType}". ` +
9
+ 'Import the matching provider module before connecting.');
10
+ }
11
+ return loader();
12
+ }