yaver-feedback-react-native 0.8.13 → 0.9.2

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 (76) hide show
  1. package/app.plugin.js +126 -9
  2. package/dist/BlackBox.js +9 -1
  3. package/dist/DeployPanel.js +65 -0
  4. package/dist/Discovery.js +13 -2
  5. package/dist/FeedbackModal.js +477 -66
  6. package/dist/MachinePickerScreen.js +14 -5
  7. package/dist/P2PClient.d.ts +94 -1
  8. package/dist/P2PClient.js +281 -2
  9. package/dist/ShakeDetector.js +2 -0
  10. package/dist/VibeChatScreen.d.ts +6 -1
  11. package/dist/VibeChatScreen.js +204 -1
  12. package/dist/YaverFeedback.d.ts +98 -0
  13. package/dist/YaverFeedback.js +509 -46
  14. package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
  15. package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
  16. package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
  17. package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
  18. package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
  19. package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
  20. package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
  21. package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
  22. package/dist/__tests__/P2PClient.test.js +2 -2
  23. package/dist/__tests__/ReportIdentity.test.d.ts +1 -0
  24. package/dist/__tests__/ReportIdentity.test.js +168 -0
  25. package/dist/__tests__/SDKToken.test.js +1 -1
  26. package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
  27. package/dist/__tests__/ShakeToggle.test.js +121 -0
  28. package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
  29. package/dist/__tests__/pickTargetDevice.test.js +89 -0
  30. package/dist/__tests__/reloadActions.test.d.ts +1 -0
  31. package/dist/__tests__/reloadActions.test.js +129 -0
  32. package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
  33. package/dist/__tests__/reloadActionsParity.test.js +42 -0
  34. package/dist/__tests__/types.test.js +5 -5
  35. package/dist/_core/device.d.ts +19 -9
  36. package/dist/_core/device.js +20 -14
  37. package/dist/capture.d.ts +6 -0
  38. package/dist/capture.js +53 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.js +11 -1
  41. package/dist/reloadActions.d.ts +88 -0
  42. package/dist/reloadActions.js +200 -0
  43. package/dist/storeShots.d.ts +67 -0
  44. package/dist/storeShots.js +137 -0
  45. package/dist/types.d.ts +215 -3
  46. package/dist/voice.d.ts +61 -0
  47. package/dist/voice.js +246 -0
  48. package/package.json +14 -3
  49. package/src/BlackBox.ts +10 -1
  50. package/src/DeployPanel.tsx +74 -0
  51. package/src/Discovery.ts +13 -2
  52. package/src/FeedbackModal.tsx +563 -87
  53. package/src/MachinePickerScreen.tsx +12 -3
  54. package/src/P2PClient.ts +314 -2
  55. package/src/ShakeDetector.ts +1 -0
  56. package/src/VibeChatScreen.tsx +219 -0
  57. package/src/YaverFeedback.ts +498 -46
  58. package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
  59. package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
  60. package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
  61. package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
  62. package/src/__tests__/P2PClient.test.ts +2 -2
  63. package/src/__tests__/ReportIdentity.test.ts +203 -0
  64. package/src/__tests__/SDKToken.test.ts +1 -1
  65. package/src/__tests__/ShakeToggle.test.ts +153 -0
  66. package/src/__tests__/pickTargetDevice.test.ts +101 -0
  67. package/src/__tests__/reloadActions.test.ts +171 -0
  68. package/src/__tests__/reloadActionsParity.test.ts +49 -0
  69. package/src/__tests__/types.test.ts +5 -5
  70. package/src/_core/device.ts +20 -14
  71. package/src/capture.ts +51 -0
  72. package/src/index.ts +21 -0
  73. package/src/reloadActions.ts +273 -0
  74. package/src/storeShots.ts +189 -0
  75. package/src/types.ts +217 -3
  76. package/src/voice.ts +270 -0
@@ -27,9 +27,13 @@ import {
27
27
  View,
28
28
  } from 'react-native';
29
29
  import type { P2PClient } from './P2PClient';
30
+ import { SDKVoiceSession, pcmToTempWavURI, isVoiceStreamSupported } from './voice';
31
+ import { startPcmRecording, stopPcmRecording, isVoiceCaptureSupported } from './capture';
30
32
 
31
33
  export type VibeTurnRole = 'user' | 'assistant' | 'status';
32
34
 
35
+ type VoiceState = 'idle' | 'recording' | 'uploading' | 'thinking' | 'speaking';
36
+
33
37
  export interface VibeTurn {
34
38
  id: string;
35
39
  role: VibeTurnRole;
@@ -45,6 +49,11 @@ interface Props {
45
49
  /** Called when the user taps Reload after a task completes — uses
46
50
  * P2PClient.reloadApp() with the active project context. */
47
51
  onReload?: () => Promise<void>;
52
+ /** Optional context forwarded to the voice stream so the agent runs
53
+ * the task against the right project / runner / model. */
54
+ project?: string;
55
+ model?: string;
56
+ runner?: string;
48
57
  }
49
58
 
50
59
  export function VibeChatScreen({
@@ -53,6 +62,9 @@ export function VibeChatScreen({
53
62
  initialUserPrompt,
54
63
  onClose,
55
64
  onReload,
65
+ project,
66
+ model,
67
+ runner,
56
68
  }: Props) {
57
69
  const [taskId, setTaskId] = useState(initialTaskId);
58
70
  const [turns, setTurns] = useState<VibeTurn[]>(() => [
@@ -77,6 +89,17 @@ export function VibeChatScreen({
77
89
  const scrollRef = useRef<ScrollView | null>(null);
78
90
  const abortRef = useRef<(() => void) | null>(null);
79
91
 
92
+ // ── Voice vibe coding ──────────────────────────────────────────────
93
+ const [voiceState, setVoiceState] = useState<VoiceState>('idle');
94
+ const [voiceAvailable, setVoiceAvailable] = useState(false);
95
+ // "local" = whisper.cpp on the host (free, private); "flux" = Deepgram
96
+ // nova-3 streaming. fluxAvailable gates the toggle on whether the agent
97
+ // has a Deepgram key. activeEngine is echoed back by the agent.
98
+ const [voiceMode, setVoiceMode] = useState<'local' | 'flux'>('local');
99
+ const [fluxAvailable, setFluxAvailable] = useState(false);
100
+ const [activeEngine, setActiveEngine] = useState<string>('');
101
+ const voiceSessionRef = useRef<SDKVoiceSession | null>(null);
102
+
80
103
  // Subscribe to the current task's SSE stream. Re-runs whenever the
81
104
  // taskId changes (resumeTask reuses the same id, so this only fires
82
105
  // once per task — which is fine).
@@ -192,6 +215,157 @@ export function VibeChatScreen({
192
215
  }
193
216
  }, [isReloading, onReload]);
194
217
 
218
+ // Probe whether voice is usable: deps present (expo-av + expo-file-
219
+ // system + buffer) AND the agent reports STT/TTS ready. Hide the mic
220
+ // entirely otherwise so users never tap a dead button.
221
+ useEffect(() => {
222
+ let cancelled = false;
223
+ (async () => {
224
+ if (!isVoiceCaptureSupported() || !isVoiceStreamSupported()) return;
225
+ try {
226
+ const res = await fetch(`${client.agentBaseUrl}/voice/status`, { headers: client.voiceAuthHeaders() });
227
+ if (!res.ok) return;
228
+ const body = await res.json();
229
+ if (cancelled) return;
230
+ // Local whisper is always usable when voice is enabled; Flux needs
231
+ // a Deepgram key on the agent. Show the mic if either path works.
232
+ const localOk = !!body?.enabled;
233
+ const fluxOk = !!body?.enabled && !!body?.deepgramSet;
234
+ if (localOk || fluxOk) setVoiceAvailable(true);
235
+ setFluxAvailable(fluxOk);
236
+ if (!localOk && fluxOk) setVoiceMode('flux');
237
+ } catch { /* leave hidden */ }
238
+ })();
239
+ return () => { cancelled = true; };
240
+ }, [client]);
241
+
242
+ useEffect(() => () => { voiceSessionRef.current?.close(); }, []);
243
+
244
+ // Local TTS: the agent streams no audio for "local"/"device" engines,
245
+ // so the client speaks the result text with the device synthesizer.
246
+ // expo-speech is optional — if absent, the text is still shown.
247
+ const speakLocalText = useCallback((text: string) => {
248
+ if (!text) return;
249
+ try {
250
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
251
+ const Speech = require('expo-speech');
252
+ const headline = text.length > 280 ? `${text.slice(0, 280)} — see screen for the rest.` : text;
253
+ Speech.stop?.();
254
+ Speech.speak?.(headline);
255
+ } catch { /* expo-speech not installed — text remains visible */ }
256
+ }, []);
257
+
258
+ const playTTS = useCallback(async (pcm: Uint8Array, sampleRate: number) => {
259
+ try {
260
+ const wavUri = await pcmToTempWavURI(pcm, sampleRate);
261
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
262
+ const { Audio } = require('expo-av');
263
+ const { sound } = await Audio.Sound.createAsync({ uri: wavUri }, { shouldPlay: true });
264
+ sound.setOnPlaybackStatusUpdate((st: any) => {
265
+ if (st.didJustFinish) sound.unloadAsync().catch(() => {});
266
+ });
267
+ } catch { /* playback best-effort */ }
268
+ }, []);
269
+
270
+ const stopVoiceAndProcess = useCallback(async () => {
271
+ setVoiceState('uploading');
272
+ let uri: string | null = null;
273
+ try {
274
+ uri = await stopPcmRecording();
275
+ } catch (e) {
276
+ setVoiceState('idle');
277
+ setTurns((prev) => [...prev, { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${e instanceof Error ? e.message : String(e)}`, timestamp: Date.now() }]);
278
+ return;
279
+ }
280
+ if (!uri) { setVoiceState('idle'); return; }
281
+
282
+ const useFlux = voiceMode === 'flux' && fluxAvailable;
283
+ const session = new SDKVoiceSession({
284
+ onProviders: (stt, tts) => setActiveEngine(stt === 'deepgram' ? 'Flux (Deepgram)' : stt === 'local' ? 'Local (whisper)' : stt),
285
+ onTranscriptPartial: (t) => {
286
+ setTurns((prev) => {
287
+ const next = prev.filter((x) => x.id !== 'voice-partial');
288
+ next.push({ id: 'voice-partial', role: 'status', text: `🎙 ${t}`, timestamp: Date.now() });
289
+ return next;
290
+ });
291
+ },
292
+ onTranscriptFinal: (t) => {
293
+ setVoiceState('thinking');
294
+ setTurns((prev) => [
295
+ ...prev.filter((x) => x.id !== 'voice-partial'),
296
+ { id: `user-voice-${Date.now()}`, role: 'user', text: t, timestamp: Date.now() },
297
+ { id: `status-${Date.now()}`, role: 'status', text: 'thinking…', timestamp: Date.now() },
298
+ ]);
299
+ },
300
+ onTaskCreated: (id) => {
301
+ // Hand the chat's SSE subscription the new task so its agent
302
+ // output streams into the transcript exactly like a typed turn.
303
+ if (id) { setStatus('running'); setStreamBuffer(''); setTaskId(id); }
304
+ },
305
+ onTaskResult: (_id, text) => {
306
+ setVoiceState('speaking');
307
+ // Local TTS path: agent sends no audio frames, so speak here.
308
+ if (!useFlux) speakLocalText(text);
309
+ },
310
+ onTTSReady: (pcm, sr) => { void playTTS(pcm, sr); },
311
+ onDone: () => setTimeout(() => setVoiceState('idle'), 1200),
312
+ onError: (msg) => {
313
+ setVoiceState('idle');
314
+ setTurns((prev) => [...prev.filter((x) => x.id !== 'voice-partial'), { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${msg}`, timestamp: Date.now() }]);
315
+ },
316
+ });
317
+ voiceSessionRef.current = session;
318
+ try {
319
+ await session.start({
320
+ wsUrl: client.voiceStreamUrl(),
321
+ headers: client.voiceAuthHeaders(),
322
+ project,
323
+ model,
324
+ runner,
325
+ surface: 'feedback-sdk',
326
+ ttsBudget: 280,
327
+ // Local: whisper.cpp on the host + device synth. Flux: Deepgram
328
+ // nova-3 STT + Aura TTS streamed back as PCM.
329
+ sttProvider: useFlux ? 'deepgram' : 'local',
330
+ ttsProvider: useFlux ? 'deepgram' : 'local',
331
+ });
332
+ await session.streamAudioFile(uri, { skipWavHeader: true });
333
+ session.finalize();
334
+ } catch (e) {
335
+ setVoiceState('idle');
336
+ session.close();
337
+ setTurns((prev) => [...prev, { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${e instanceof Error ? e.message : String(e)}`, timestamp: Date.now() }]);
338
+ }
339
+ }, [client, project, model, runner, playTTS, voiceMode, fluxAvailable, speakLocalText]);
340
+
341
+ const handleVoicePress = useCallback(async () => {
342
+ if (voiceState === 'recording') {
343
+ void stopVoiceAndProcess();
344
+ return;
345
+ }
346
+ if (voiceState !== 'idle') {
347
+ // Mid-flow tap cancels.
348
+ voiceSessionRef.current?.close();
349
+ voiceSessionRef.current = null;
350
+ setVoiceState('idle');
351
+ return;
352
+ }
353
+ try {
354
+ await startPcmRecording();
355
+ setVoiceState('recording');
356
+ } catch (e) {
357
+ setTurns((prev) => [...prev, { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${e instanceof Error ? e.message : String(e)}`, timestamp: Date.now() }]);
358
+ }
359
+ }, [voiceState, stopVoiceAndProcess]);
360
+
361
+ const voiceLabel: Record<VoiceState, string> = {
362
+ idle: '🎙 speak',
363
+ recording: '■ stop',
364
+ uploading: 'sending…',
365
+ thinking: 'thinking…',
366
+ speaking: 'speaking…',
367
+ };
368
+
195
369
  return (
196
370
  <View style={styles.container}>
197
371
  <View style={styles.header}>
@@ -240,6 +414,12 @@ export function VibeChatScreen({
240
414
  </ScrollView>
241
415
 
242
416
  <View style={styles.footer}>
417
+ {voiceAvailable && voiceState !== 'idle' && (
418
+ <Text style={styles.engineCaption}>
419
+ {voiceState === 'recording' ? 'listening' : voiceState === 'uploading' ? 'sending' : voiceState === 'thinking' ? 'agent working' : 'speaking'}
420
+ {activeEngine ? ` · ${activeEngine}` : ` · ${voiceMode === 'flux' ? 'Flux (Deepgram)' : 'Local (whisper)'}`}
421
+ </Text>
422
+ )}
243
423
  <TextInput
244
424
  style={styles.input}
245
425
  value={followUp}
@@ -250,6 +430,40 @@ export function VibeChatScreen({
250
430
  multiline
251
431
  />
252
432
  <View style={styles.actions}>
433
+ {voiceAvailable && (
434
+ <>
435
+ {/* Local ↔ Flux engine toggle. Only shows Flux when the
436
+ agent has a Deepgram key; otherwise the label just
437
+ states "Local" so the active engine is always clear. */}
438
+ {fluxAvailable ? (
439
+ <TouchableOpacity
440
+ style={[styles.actionBtn, styles.engineToggle]}
441
+ onPress={() => setVoiceMode((m) => (m === 'local' ? 'flux' : 'local'))}
442
+ disabled={voiceState !== 'idle'}
443
+ accessibilityLabel="Toggle voice engine"
444
+ >
445
+ <Text style={styles.engineToggleText}>{voiceMode === 'flux' ? '⚡ Flux' : '🔒 Local'}</Text>
446
+ </TouchableOpacity>
447
+ ) : (
448
+ <View style={[styles.actionBtn, styles.engineToggle]}>
449
+ <Text style={styles.engineToggleText}>🔒 Local</Text>
450
+ </View>
451
+ )}
452
+ <TouchableOpacity
453
+ style={[
454
+ styles.actionBtn,
455
+ styles.voiceBtn,
456
+ voiceState === 'recording' && styles.voiceBtnActive,
457
+ (voiceState === 'uploading' || voiceState === 'thinking' || voiceState === 'speaking') && styles.actionBtnDisabled,
458
+ ]}
459
+ onPress={handleVoicePress}
460
+ disabled={voiceState === 'uploading' || voiceState === 'thinking' || voiceState === 'speaking'}
461
+ accessibilityLabel="Vibe code by voice"
462
+ >
463
+ <Text style={styles.actionText}>{voiceLabel[voiceState]}</Text>
464
+ </TouchableOpacity>
465
+ </>
466
+ )}
253
467
  {onReload && (
254
468
  <TouchableOpacity
255
469
  style={[
@@ -357,6 +571,11 @@ const styles = StyleSheet.create({
357
571
  },
358
572
  actionBtnDisabled: { opacity: 0.5 },
359
573
  reloadBtn: { backgroundColor: 'rgba(255,255,255,0.08)' },
574
+ voiceBtn: { backgroundColor: 'rgba(16,185,129,0.18)' },
575
+ voiceBtnActive: { backgroundColor: '#ef4444' },
576
+ engineToggle: { backgroundColor: 'rgba(255,255,255,0.06)', marginRight: 'auto', marginLeft: 0 },
577
+ engineToggleText: { color: '#cbd5e1', fontSize: 12, fontWeight: '600' },
578
+ engineCaption: { color: '#9ca3af', fontSize: 11, marginBottom: 6, marginLeft: 4 },
360
579
  sendBtn: { backgroundColor: '#7582f5' },
361
580
  actionText: { color: '#fff', fontSize: 13, fontWeight: '600' },
362
581
  });