@qafka/react-native 2.3.8 → 2.4.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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.0] — 2026-06-21
11
+
12
+ ### Added
13
+
14
+ - Full-duplex voice (barge-in): users can now interrupt the AI by speaking while it is talking. The microphone stays open during playback and the AI stops promptly when the user talks over it. On devices whose echo cancellation underperforms, the session automatically falls back to half-duplex so the AI's own audio is never mistaken for user speech.
15
+
16
+ ### Fixed
17
+
18
+ - Voice echo on both iOS and Android: the AI's playback no longer leaks back into the microphone (where it could be transcribed as user input). Audio is routed through the platform's voice-communication path — Apple VPIO on iOS, communication-mode capture with hardware echo cancellation on Android — and played on the loudspeaker.
19
+
10
20
  ## [2.3.8] — 2026-06-17
11
21
 
12
22
  ### Added
@@ -1,4 +1,9 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
2
  <!-- Required for voice capture. Merged into the consumer app manifest. -->
3
3
  <uses-permission android:name="android.permission.RECORD_AUDIO" />
4
+ <!-- Required to set MODE_IN_COMMUNICATION and route playback to the
5
+ loudspeaker (setCommunicationDevice / setSpeakerphoneOn) for hands-free
6
+ voice. Without it those calls are silently denied and audio stays on the
7
+ earpiece. This is a normal (install-time) permission. -->
8
+ <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
4
9
  </manifest>
@@ -5,6 +5,7 @@ import android.content.Context
5
5
  import android.content.pm.PackageManager
6
6
  import android.media.AudioAttributes
7
7
  import android.media.AudioFormat
8
+ import android.media.AudioDeviceInfo
8
9
  import android.media.AudioManager
9
10
  import android.media.AudioRecord
10
11
  import android.media.AudioTrack
@@ -12,6 +13,7 @@ import android.media.MediaRecorder
12
13
  import android.media.audiofx.AcousticEchoCanceler
13
14
  import android.media.audiofx.AutomaticGainControl
14
15
  import android.media.audiofx.NoiseSuppressor
16
+ import android.os.Build
15
17
  import android.util.Base64
16
18
  import android.util.Log
17
19
  import androidx.core.content.ContextCompat
@@ -29,6 +31,20 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
29
31
  @Volatile private var isCapturing = false
30
32
  private var listenerCount = 0
31
33
 
34
+ // Client-side barge-in. The server-side VAD interrupt is too slow to
35
+ // cut the AI promptly, but the on-device AEC keeps the mic clean (the user's
36
+ // voice is a clear energy spike vs near-zero silence), so we detect the
37
+ // barge-in locally and cut playback instantly. `suppressPlayback` then drops
38
+ // the rest of the now-cancelled turn until the next turn boundary, when JS
39
+ // calls resumePlayback().
40
+ @Volatile private var lastPlaybackWriteMs = 0L
41
+ @Volatile private var suppressPlayback = false
42
+ @Volatile private var bargeInAtMs = 0L
43
+ // Gates client-side barge-in detection. JS turns it OFF when it detects the
44
+ // device's AEC is failing (a burst of barge-ins = the AI's own echo, not a
45
+ // user) and downgrades to half-duplex, so the echo can't keep cutting the AI.
46
+ @Volatile private var bargeInEnabled = true
47
+
32
48
  // iOS VPIO equivalent — hardware AEC + NS + AGC (android.media.audiofx).
33
49
  // Enables barge-in by cancelling playback echo at mic level.
34
50
  // Device-dependent; isAvailable() + null checks keep it safe.
@@ -48,6 +64,17 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
48
64
  private const val CHANNEL_IN = AudioFormat.CHANNEL_IN_MONO
49
65
  private const val CHANNEL_OUT = AudioFormat.CHANNEL_OUT_MONO
50
66
  private const val AUDIO_ENCODING = AudioFormat.ENCODING_PCM_16BIT
67
+
68
+ // Barge-in detection tuning. Mic RMS is ~0 in silence (AEC removes the
69
+ // AI's echo) and ~2000-5000 when the user speaks, so the threshold sits
70
+ // comfortably between. Require a couple of consecutive ~128ms reads to
71
+ // reject transient clicks; only armed while the AI is actively playing.
72
+ private const val BARGE_IN_RMS_THRESHOLD = 1200.0
73
+ private const val BARGE_IN_SUSTAIN_READS = 2
74
+ private const val PLAYBACK_ACTIVE_WINDOW_MS = 350L
75
+ // Safety cap: if no turn-end signal arrives to lift suppression, resume
76
+ // anyway so a missed event can't permanently mute the next turn.
77
+ private const val SUPPRESS_MAX_MS = 6000L
51
78
  }
52
79
 
53
80
  /**
@@ -98,6 +125,11 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
98
125
  return
99
126
  }
100
127
 
128
+ // Fresh session starts full-duplex (barge-in armed) with no suppression;
129
+ // JS downgrades to half-duplex later if it detects the AEC is failing.
130
+ bargeInEnabled = true
131
+ suppressPlayback = false
132
+
101
133
  // Check permission
102
134
  val permission = ContextCompat.checkSelfPermission(
103
135
  reactApplicationContext,
@@ -127,13 +159,29 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
127
159
  val audioManager =
128
160
  reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
129
161
  previousAudioMode = audioManager.mode
130
- previousSpeakerphoneOn = audioManager.isSpeakerphoneOn
131
162
  audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
132
- @Suppress("DEPRECATION")
133
- audioManager.isSpeakerphoneOn = true
134
- dlogI("✅ AudioManager mode=IN_COMMUNICATION, speakerphone=on")
163
+ // Route playback to the LOUDSPEAKER for hands-free use. On API 31+
164
+ // setSpeakerphoneOn is deprecated and a no-op on many devices —
165
+ // setCommunicationDevice(BUILTIN_SPEAKER) is the supported path.
166
+ // Requires MODIFY_AUDIO_SETTINGS (declared in the manifest); without
167
+ // it these calls are silently denied and audio stays on the earpiece.
168
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
169
+ val speaker = audioManager.availableCommunicationDevices
170
+ .firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
171
+ if (speaker != null) {
172
+ val ok = audioManager.setCommunicationDevice(speaker)
173
+ dlogI("✅ mode=IN_COMMUNICATION, setCommunicationDevice(speaker)=$ok")
174
+ } else {
175
+ dlogW("⚠️ No BUILTIN_SPEAKER communication device available")
176
+ }
177
+ } else {
178
+ previousSpeakerphoneOn = audioManager.isSpeakerphoneOn
179
+ @Suppress("DEPRECATION")
180
+ audioManager.isSpeakerphoneOn = true
181
+ dlogI("✅ mode=IN_COMMUNICATION, speakerphone=on (legacy)")
182
+ }
135
183
  } catch (e: Exception) {
136
- dlogW("⚠️ Failed to set communication audio mode: ${e.message}")
184
+ dlogW("⚠️ Failed to set communication audio routing: ${e.message}")
137
185
  }
138
186
 
139
187
  try {
@@ -185,17 +233,21 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
185
233
  }
186
234
 
187
235
  // Initialize AudioTrack for playback.
188
- // The voice-communication output path favors low latency with a
189
- // small default buffer; that underruns on jittery streamed network
190
- // audio and makes playback choppy ("skipping"). Size the buffer for
191
- // ~0.4s of audio to absorb jitter — smoothness matters more than
192
- // latency for an assistant reply.
236
+ // The original ~0.4s jitter buffer (chosen for playback
237
+ // smoothness) pushes the echo-path delay far out and renders network
238
+ // audio in large, late, sometimes-underrunning bursts — which the
239
+ // OS echo canceler cannot align/reference, so the AI's playback
240
+ // leaks back into the mic. Shrink to ~150ms to pull the echo-path
241
+ // delay into the AEC's tracking window (and reduce the BUFFER
242
+ // TIMEOUT underruns seen in logcat). The maxOf floor still keeps it
243
+ // at/above the device minimum so it can't underflow outright.
193
244
  val playMinBuffer = AudioTrack.getMinBufferSize(
194
245
  PLAYBACK_SAMPLE_RATE, CHANNEL_OUT, AUDIO_ENCODING
195
246
  )
196
- // bytes/sec = sampleRate * 2 (16-bit mono); * 4 / 10 ≈ 0.4s
197
- val jitterBufferBytes = PLAYBACK_SAMPLE_RATE * 2 * 4 / 10
247
+ // bytes/sec = sampleRate * 2 (16-bit mono); * 15 / 100 ≈ 0.15s
248
+ val jitterBufferBytes = PLAYBACK_SAMPLE_RATE * 2 * 15 / 100
198
249
  val playBufferSize = maxOf(playMinBuffer * 2, jitterBufferBytes)
250
+ dlogI("🔧 playbackBuffer=$playBufferSize bytes (~${playBufferSize * 1000 / (PLAYBACK_SAMPLE_RATE * 2)}ms)")
199
251
 
200
252
  // iOS VPIO equivalent (part 2): play on the voice-communication path
201
253
  // (USAGE_VOICE_COMMUNICATION + CONTENT_TYPE_SPEECH) so the echo
@@ -214,6 +266,13 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
214
266
  .setEncoding(AUDIO_ENCODING)
215
267
  .build()
216
268
  )
269
+ // Bind playback to the SAME audio session as the capture that
270
+ // AcousticEchoCanceler is attached to. Without this the HAL
271
+ // cannot correlate the render (AI playback) and capture (mic)
272
+ // streams, so the echo canceler has no reference to subtract and
273
+ // silently no-ops — the likely reason hardware AEC "reports
274
+ // enabled but fails to cancel". Required for full-duplex barge-in.
275
+ .setSessionId(sessionId)
217
276
  .setBufferSizeInBytes(playBufferSize)
218
277
  .setTransferMode(AudioTrack.MODE_STREAM)
219
278
  .build()
@@ -229,10 +288,36 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
229
288
  // Start capture thread
230
289
  captureThread = Thread {
231
290
  val readBuffer = ShortArray(bufferSize / 2)
291
+ var highReads = 0
232
292
 
233
293
  while (isCapturing) {
234
294
  val shortsRead = record.read(readBuffer, 0, readBuffer.size)
235
295
  if (shortsRead > 0) {
296
+ // Client-side barge-in: while the AI is actively playing,
297
+ // a sustained mic-energy spike means the user is talking
298
+ // over it — cut locally instead of waiting for the
299
+ // server's slower interrupt signal.
300
+ val playbackActive =
301
+ (System.currentTimeMillis() - lastPlaybackWriteMs) < PLAYBACK_ACTIVE_WINDOW_MS
302
+ if (playbackActive && !suppressPlayback && bargeInEnabled) {
303
+ var sum = 0.0
304
+ for (i in 0 until shortsRead) {
305
+ val s = readBuffer[i].toDouble()
306
+ sum += s * s
307
+ }
308
+ val rms = Math.sqrt(sum / shortsRead)
309
+ if (rms > BARGE_IN_RMS_THRESHOLD) {
310
+ highReads++
311
+ if (highReads >= BARGE_IN_SUSTAIN_READS) {
312
+ highReads = 0
313
+ triggerBargeIn()
314
+ }
315
+ } else {
316
+ highReads = 0
317
+ }
318
+ } else {
319
+ highReads = 0
320
+ }
236
321
  // Convert shorts to bytes (little-endian PCM 16-bit)
237
322
  val byteBuffer = ByteArray(shortsRead * 2)
238
323
  for (i in 0 until shortsRead) {
@@ -293,11 +378,15 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
293
378
  try {
294
379
  val audioManager =
295
380
  reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
296
- previousAudioMode?.let { audioManager.mode = it }
297
- previousSpeakerphoneOn?.let {
298
- @Suppress("DEPRECATION")
299
- audioManager.isSpeakerphoneOn = it
381
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
382
+ audioManager.clearCommunicationDevice()
383
+ } else {
384
+ previousSpeakerphoneOn?.let {
385
+ @Suppress("DEPRECATION")
386
+ audioManager.isSpeakerphoneOn = it
387
+ }
300
388
  }
389
+ previousAudioMode?.let { audioManager.mode = it }
301
390
  } catch (_: Exception) {}
302
391
  previousAudioMode = null
303
392
  previousSpeakerphoneOn = null
@@ -309,6 +398,19 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
309
398
 
310
399
  @ReactMethod
311
400
  fun playAudioChunk(base64Data: String, promise: Promise) {
401
+ // Drop the AI's remaining audio after a local barge-in until the turn
402
+ // boundary lifts suppression (or the safety cap elapses). Without this
403
+ // the in-flight deltas of the just-cancelled turn would refill the buffer
404
+ // and the AI would audibly resume right after we cut it.
405
+ if (suppressPlayback) {
406
+ if (System.currentTimeMillis() - bargeInAtMs > SUPPRESS_MAX_MS) {
407
+ suppressPlayback = false
408
+ } else {
409
+ promise.resolve(true)
410
+ return
411
+ }
412
+ }
413
+
312
414
  val track = audioTrack
313
415
  if (track == null) {
314
416
  promise.reject("NOT_RUNNING", "Audio is not running. Call startCapture first.")
@@ -318,6 +420,7 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
318
420
  try {
319
421
  val audioBytes = Base64.decode(base64Data, Base64.DEFAULT)
320
422
  track.write(audioBytes, 0, audioBytes.size)
423
+ lastPlaybackWriteMs = System.currentTimeMillis()
321
424
  promise.resolve(true)
322
425
  } catch (e: Exception) {
323
426
  promise.reject("PLAYBACK_ERROR", "Failed to play audio chunk: ${e.message}", e)
@@ -328,6 +431,8 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
328
431
 
329
432
  @ReactMethod
330
433
  fun stopPlayback(promise: Promise) {
434
+ // Purges queued playback when the server signals the user interrupted
435
+ // (response.interrupted) so the cancelled turn stops promptly.
331
436
  val track = audioTrack ?: run {
332
437
  promise.resolve(true)
333
438
  return
@@ -342,4 +447,44 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
342
447
  promise.reject("PLAYBACK_ERROR", "Failed to stop playback: ${e.message}", e)
343
448
  }
344
449
  }
450
+
451
+ // MARK: - Barge-in
452
+
453
+ /**
454
+ * Called from the capture thread when a sustained mic-energy spike is
455
+ * detected while the AI is playing. Cuts playback instantly and starts
456
+ * dropping the cancelled turn's remaining audio; JS lifts suppression at the
457
+ * next turn boundary via resumePlayback().
458
+ */
459
+ private fun triggerBargeIn() {
460
+ suppressPlayback = true
461
+ bargeInAtMs = System.currentTimeMillis()
462
+ try {
463
+ audioTrack?.let {
464
+ it.pause()
465
+ it.flush()
466
+ it.play()
467
+ }
468
+ } catch (_: Exception) {}
469
+ dlogI("⚡ Local barge-in — cut playback, suppressing cancelled turn")
470
+ sendEvent("onBargeIn", Arguments.createMap())
471
+ }
472
+
473
+ @ReactMethod
474
+ fun resumePlayback(promise: Promise) {
475
+ suppressPlayback = false
476
+ promise.resolve(true)
477
+ }
478
+
479
+ /**
480
+ * Arm/disarm client-side barge-in. JS disarms it after detecting the device's
481
+ * echo cancellation is failing (downgrade to half-duplex), so the AI's own
482
+ * leaked echo can't keep triggering a cut. Disarming also clears suppression.
483
+ */
484
+ @ReactMethod
485
+ fun setBargeInEnabled(enabled: Boolean, promise: Promise) {
486
+ bargeInEnabled = enabled
487
+ if (!enabled) suppressPlayback = false
488
+ promise.resolve(true)
489
+ }
345
490
  }
@@ -60,7 +60,9 @@ function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, tool
60
60
  </react_native_1.View>)}
61
61
 
62
62
  {/* Message List */}
63
- <react_native_1.FlatList ref={flatListRef} data={displayMessages} renderItem={renderMessage} keyExtractor={(item) => item.id} style={{ flex: 1, flexGrow: 1, flexShrink: 1 }} contentContainerStyle={styles.messageListStyle} ListEmptyComponent={renderEmpty} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" onContentSizeChange={() => {
63
+ <react_native_1.FlatList ref={flatListRef} data={displayMessages} renderItem={renderMessage} keyExtractor={(item) => item.id} style={{ flex: 1, flexGrow: 1, flexShrink: 1 }} contentContainerStyle={styles.messageListStyle} ListEmptyComponent={renderEmpty} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" ListFooterComponent={toolStatus ? (<react_native_1.View style={{ paddingHorizontal: 8, paddingTop: 4, alignItems: 'flex-start' }}>
64
+ <ToolStatusPill_1.ToolStatusPill message={toolStatus.message} theme={theme}/>
65
+ </react_native_1.View>) : null} onContentSizeChange={() => {
64
66
  if (displayMessages.length > 0) {
65
67
  flatListRef.current?.scrollToEnd({ animated: true });
66
68
  }
@@ -71,10 +73,8 @@ function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, tool
71
73
  <TypingIndicator_1.TypingIndicator theme={theme}/>
72
74
  </react_native_1.View>)}
73
75
 
74
- {/* Tool Status Pill (server-managed modes) */}
75
- {toolStatus && (<react_native_1.View style={{ paddingHorizontal: 8, alignItems: 'flex-start' }}>
76
- <ToolStatusPill_1.ToolStatusPill message={toolStatus.message} theme={theme}/>
77
- </react_native_1.View>)}
76
+ {/* Tool Status Pill now renders inside the list (ListFooterComponent)
77
+ so it sits directly under the last message and scrolls with it. */}
78
78
 
79
79
  {/* Input Area — hidden until SDK is ready so users can't type a message
80
80
  that would be rejected by an un-attested send. TypingIndicator in the
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Returns true when a realtime event signals a server-side interruption
3
+ * (user barge-in), so the caller can stop audio playback and let the user's
4
+ * new turn take over. Kept as a pure function so the decision is unit-testable
5
+ * without loading the native-bound RealtimeService.
6
+ */
7
+ export declare function shouldStopPlayback(event: {
8
+ type?: string;
9
+ } | null | undefined): boolean;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.shouldStopPlayback = shouldStopPlayback;
4
+ /**
5
+ * Returns true when a realtime event signals a server-side interruption
6
+ * (user barge-in), so the caller can stop audio playback and let the user's
7
+ * new turn take over. Kept as a pure function so the decision is unit-testable
8
+ * without loading the native-bound RealtimeService.
9
+ */
10
+ function shouldStopPlayback(event) {
11
+ return event?.type === 'response.interrupted';
12
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Decides whether mic audio should be forwarded to the server.
3
+ *
4
+ * Gates, in order of precedence:
5
+ * - `micEnabled`: the user/system mute state (false = muted). Always wins.
6
+ * - `fullDuplex`: when true, the native OS echo canceler
7
+ * (MODE_IN_COMMUNICATION + AcousticEchoCanceler bound to the shared capture
8
+ * session, see QafkaAudioModule) is trusted to strip the AI's playback out
9
+ * of the mic, so the mic stays open while the AI speaks — this is what
10
+ * enables voice barge-in. Defaults to false.
11
+ * - `aiSpeaking` is false: half-duplex gate, used only when `fullDuplex` is
12
+ * off. While the AI is speaking we stop forwarding so its own playback,
13
+ * echoed into the mic, isn't transcribed as a user turn (there is no
14
+ * server-side echo cancellation upstream).
15
+ *
16
+ * Pure function so the gating logic is unit-testable without the native-bound
17
+ * RealtimeService.
18
+ */
19
+ export declare function shouldForwardMicAudio(state: {
20
+ micEnabled: boolean;
21
+ aiSpeaking: boolean;
22
+ fullDuplex?: boolean;
23
+ }): boolean;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.shouldForwardMicAudio = shouldForwardMicAudio;
4
+ /**
5
+ * Decides whether mic audio should be forwarded to the server.
6
+ *
7
+ * Gates, in order of precedence:
8
+ * - `micEnabled`: the user/system mute state (false = muted). Always wins.
9
+ * - `fullDuplex`: when true, the native OS echo canceler
10
+ * (MODE_IN_COMMUNICATION + AcousticEchoCanceler bound to the shared capture
11
+ * session, see QafkaAudioModule) is trusted to strip the AI's playback out
12
+ * of the mic, so the mic stays open while the AI speaks — this is what
13
+ * enables voice barge-in. Defaults to false.
14
+ * - `aiSpeaking` is false: half-duplex gate, used only when `fullDuplex` is
15
+ * off. While the AI is speaking we stop forwarding so its own playback,
16
+ * echoed into the mic, isn't transcribed as a user turn (there is no
17
+ * server-side echo cancellation upstream).
18
+ *
19
+ * Pure function so the gating logic is unit-testable without the native-bound
20
+ * RealtimeService.
21
+ */
22
+ function shouldForwardMicAudio(state) {
23
+ if (!state.micEnabled)
24
+ return false;
25
+ if (state.fullDuplex)
26
+ return true;
27
+ return !state.aiSpeaking;
28
+ }
@@ -4,6 +4,9 @@ exports.useChatMessages = void 0;
4
4
  const react_1 = require("react");
5
5
  const QafkaSDK_1 = require("../QafkaSDK");
6
6
  const Qafka_utils_1 = require("../components/Qafka.utils");
7
+ const joinNarration_1 = require("../utils/joinNarration");
8
+ const extractionContinuation_1 = require("../utils/extractionContinuation");
9
+ const toolNarration_1 = require("../utils/toolNarration");
7
10
  /**
8
11
  * Custom hook for managing chat messages and sending
9
12
  */
@@ -32,29 +35,47 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
32
35
  const lastConversationIdRef = (0, react_1.useRef)(null);
33
36
  // guard against double-fire of postToolResult per toolKey
34
37
  const inFlightPostToolResultRef = (0, react_1.useRef)(new Set());
38
+ // The most recent suggested tool that is BOTH multi-step and file-input.
39
+ // Uploading + extracting a file does not start a new model turn on its own,
40
+ // so when extraction completes we fire a hidden continuation turn (tools
41
+ // enabled) to let the model advance to the next step. Captured here at
42
+ // suggestion time because the extraction callback only carries the result,
43
+ // not the tool definition.
44
+ const activeMultiStepFileToolRef = (0, react_1.useRef)(null);
45
+ // Always-current `context` so a deferred send (the post-extraction
46
+ // continuation) reads the consumer's freshly-updated context (e.g. a CV
47
+ // summary it set in onExtractionResult) rather than a stale closure value.
48
+ const contextRef = (0, react_1.useRef)(context);
49
+ contextRef.current = context;
35
50
  const scrollToBottom = (animated = true) => {
36
51
  setTimeout(() => {
37
52
  flatListRef.current?.scrollToEnd({ animated });
38
53
  }, 100);
39
54
  };
40
- const handleSend = async (message) => {
55
+ // `hidden` turns send a message to the backend WITHOUT rendering a user
56
+ // bubble — used to auto-advance a tool flow (e.g. after a file upload) where
57
+ // the "message" is a system continuation the end user never typed.
58
+ const handleSend = async (message, options) => {
41
59
  if (isSending)
42
60
  return;
61
+ const hidden = options?.hidden === true;
43
62
  setIsSending(true);
44
63
  setIsTyping(true);
45
64
  setStreamingMessage('');
46
65
  finalChunkBufferRef.current = '';
47
- // Add user message
48
- const userMessage = {
49
- id: Date.now().toString(),
50
- role: 'user',
51
- text: message,
52
- content: message,
53
- timestamp: new Date(),
54
- };
55
- setMessages((prev) => [...prev, userMessage]);
56
- onMessageSent?.(message);
57
- scrollToBottom();
66
+ // Add user message (skipped for hidden continuation turns)
67
+ if (!hidden) {
68
+ const userMessage = {
69
+ id: Date.now().toString(),
70
+ role: 'user',
71
+ text: message,
72
+ content: message,
73
+ timestamp: new Date(),
74
+ };
75
+ setMessages((prev) => [...prev, userMessage]);
76
+ onMessageSent?.(message);
77
+ scrollToBottom();
78
+ }
58
79
  const messageToSendAI = contextDescription
59
80
  ? `${contextDescription}\n\n${message}`
60
81
  : message;
@@ -179,7 +200,7 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
179
200
  // onError
180
201
  (err) => {
181
202
  handleError(err);
182
- }, context, contextDescription,
203
+ }, contextRef.current, contextDescription,
183
204
  // onToolSuggested
184
205
  async (tools, conversationId, messageId) => {
185
206
  if (conversationId) {
@@ -188,6 +209,12 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
188
209
  if (messageId) {
189
210
  lastMessageIdRef.current = messageId;
190
211
  }
212
+ // Remember a multi-step file tool so a completed extraction can
213
+ // auto-advance the flow (see the onExtractionResult handler below).
214
+ const multiStepFileTool = (tools || []).find(extractionContinuation_1.isMultiStepFileTool);
215
+ if (multiStepFileTool) {
216
+ activeMultiStepFileToolRef.current = multiStepFileTool;
217
+ }
191
218
  if (onToolSuggested) {
192
219
  // Tracks tools the consumer's `onToolSuggested` handler explicitly
193
220
  // called `addResponse` for. The auto-fire loop below uses this — NOT
@@ -251,7 +278,13 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
251
278
  // In managed follow-up mode the SDK should POST the
252
279
  // tool result back to the backend, which runs a second LLM turn
253
280
  // and streams a `final_chunk` reply.
254
- if (commits && tool?.executionMode === 'custom-with-ai') {
281
+ //
282
+ // Skipped for card-rendering tools: there the model's first reply
283
+ // (bridge) already carries the conversation and the card shows the
284
+ // data, so the turn-2 narration only repeats it in a second message.
285
+ if (commits &&
286
+ tool?.executionMode === 'custom-with-ai' &&
287
+ !(0, toolNarration_1.shouldSkipTurn2Narration)(tool)) {
255
288
  const conversationId = lastConversationIdRef.current;
256
289
  const messageId = lastMessageIdRef.current;
257
290
  if (conversationId && messageId) {
@@ -288,7 +321,10 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
288
321
  setMessages((prev) => {
289
322
  const lastMsg = prev[prev.length - 1];
290
323
  if (lastMsg && lastMsg.role === 'assistant') {
291
- const nextText = (lastMsg.text || '') + content;
324
+ // Turn-2 (post file-upload) narration appends to the same bubble; keep a
325
+ // paragraph break so it doesn't glue onto turn-1 ("..yeterli!Harika!").
326
+ // Matches the streaming path's separator and the backend persisted join.
327
+ const nextText = (0, joinNarration_1.joinNarration)(lastMsg.text || '', content);
292
328
  return [
293
329
  ...prev.slice(0, -1),
294
330
  {
@@ -401,8 +437,26 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
401
437
  }
402
438
  onFileUploadRequest?.(request);
403
439
  },
404
- // onExtractionResult
405
- onExtractionResult,
440
+ // onExtractionResult — forward to consumer, then auto-advance multi-step
441
+ // file flows. A completed extraction does not start a new model turn by
442
+ // itself, so we fire a hidden continuation turn (tools enabled) carrying
443
+ // the extracted fields, letting the model proceed to the next step.
444
+ (result) => {
445
+ onExtractionResult?.(result);
446
+ const tool = activeMultiStepFileToolRef.current;
447
+ if ((0, extractionContinuation_1.shouldAutoContinueAfterExtraction)({
448
+ isMultiStepFileTool: !!tool,
449
+ extractionStatus: result?.status,
450
+ })) {
451
+ activeMultiStepFileToolRef.current = null;
452
+ // A completed extraction does not start a new model turn by itself; fire a
453
+ // hidden continuation (tools enabled) carrying the extracted fields inline
454
+ // so the model reliably advances to the next step.
455
+ void handleSend((0, extractionContinuation_1.buildExtractionContinuationMessage)(result?.data), {
456
+ hidden: true,
457
+ });
458
+ }
459
+ },
406
460
  // onToolStatus: show pill
407
461
  (status) => {
408
462
  setToolStatus(status);
@@ -439,15 +493,12 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
439
493
  setToolStatus(null);
440
494
  finalChunkBufferRef.current +=
441
495
  (finalChunkBufferRef.current ? '\n\n' : '') + content;
442
- setStreamingMessage((prev) => {
443
- const sep = prev && !prev.endsWith('\n') ? '\n\n' : '';
444
- return prev + sep + content;
445
- });
496
+ setStreamingMessage((prev) => (0, joinNarration_1.joinNarration)(prev, content));
446
497
  });
447
498
  }
448
499
  else {
449
500
  // Non-streaming
450
- const response = await sdk.sendMessage(message, context, contextDescription);
501
+ const response = await sdk.sendMessage(message, contextRef.current, contextDescription);
451
502
  const aiMessage = {
452
503
  id: response.id,
453
504
  role: 'assistant',
@@ -131,6 +131,22 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
131
131
  }
132
132
  return;
133
133
  }
134
+ case 'response.interrupted': {
135
+ // Barge-in: the user cut in. RealtimeService already stopped native
136
+ // playback; finalize the partial AI turn (matching the server, which
137
+ // persists what was already streamed) and return to listening.
138
+ const partialAi = transcriptRef.current;
139
+ if (partialAi && partialAi.trim().length > 0) {
140
+ setTranscriptHistory((h) => [
141
+ ...h,
142
+ { id: `a_${Date.now()}_${h.length}`, role: 'assistant', text: partialAi },
143
+ ]);
144
+ }
145
+ transcriptRef.current = '';
146
+ setTranscript('');
147
+ setState('listening');
148
+ return;
149
+ }
134
150
  case 'session.closed':
135
151
  setState('idle');
136
152
  setNavigationSuggestion(null);
@@ -10,6 +10,8 @@ export declare const QafkaAudio: {
10
10
  stopCapture: () => Promise<boolean>;
11
11
  playAudioChunk: (base64Data: string) => Promise<boolean>;
12
12
  stopPlayback: () => Promise<boolean>;
13
+ resumePlayback: () => Promise<boolean>;
14
+ setBargeInEnabled: (enabled: boolean) => Promise<boolean>;
13
15
  onAudioData: (callback: (data: {
14
16
  audio: string;
15
17
  }) => void) => () => void | undefined;
@@ -17,5 +19,6 @@ export declare const QafkaAudio: {
17
19
  level: number;
18
20
  source: "mic" | "speaker";
19
21
  }) => void) => () => void | undefined;
22
+ onBargeIn: (callback: () => void) => () => void | undefined;
20
23
  isAvailable: () => boolean;
21
24
  };
@@ -47,6 +47,12 @@ exports.QafkaAudio = {
47
47
  stopCapture: () => lifecycleQueue(() => requireModule().stopCapture()),
48
48
  playAudioChunk: (base64Data) => requireModule().playAudioChunk(base64Data),
49
49
  stopPlayback: () => requireModule().stopPlayback(),
50
+ // Lift the post-barge-in playback suppression at a turn boundary so the AI's
51
+ // next turn can play. No-op if nothing is suppressed.
52
+ resumePlayback: () => requireModule().resumePlayback(),
53
+ // Arm/disarm native client-side barge-in. JS disarms it after detecting the
54
+ // device's echo cancellation is failing (downgrade to half-duplex).
55
+ setBargeInEnabled: (enabled) => requireModule().setBargeInEnabled(enabled),
50
56
  onAudioData: (callback) => {
51
57
  const subscription = emitter?.addListener('onAudioData', callback);
52
58
  return () => subscription?.remove();
@@ -55,5 +61,12 @@ exports.QafkaAudio = {
55
61
  const subscription = emitter?.addListener('onAmplitude', callback);
56
62
  return () => subscription?.remove();
57
63
  },
64
+ // Fired by the native capture thread when it detects the user talking over the
65
+ // AI (client-side barge-in). Native has already cut playback; the SDK uses this
66
+ // to return the UI to listening without waiting for the slow server interrupt.
67
+ onBargeIn: (callback) => {
68
+ const subscription = emitter?.addListener('onBargeIn', () => callback());
69
+ return () => subscription?.remove();
70
+ },
58
71
  isAvailable: () => NativeQafkaAudio != null,
59
72
  };