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.
- package/app.plugin.js +126 -9
- package/dist/BlackBox.js +9 -1
- package/dist/DeployPanel.js +65 -0
- package/dist/Discovery.js +13 -2
- package/dist/FeedbackModal.js +477 -66
- package/dist/MachinePickerScreen.js +14 -5
- package/dist/P2PClient.d.ts +94 -1
- package/dist/P2PClient.js +281 -2
- package/dist/ShakeDetector.js +2 -0
- package/dist/VibeChatScreen.d.ts +6 -1
- package/dist/VibeChatScreen.js +204 -1
- package/dist/YaverFeedback.d.ts +98 -0
- package/dist/YaverFeedback.js +509 -46
- package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
- package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
- package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
- package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
- package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
- package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
- package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
- package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
- package/dist/__tests__/P2PClient.test.js +2 -2
- package/dist/__tests__/ReportIdentity.test.d.ts +1 -0
- package/dist/__tests__/ReportIdentity.test.js +168 -0
- package/dist/__tests__/SDKToken.test.js +1 -1
- package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
- package/dist/__tests__/ShakeToggle.test.js +121 -0
- package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
- package/dist/__tests__/pickTargetDevice.test.js +89 -0
- package/dist/__tests__/reloadActions.test.d.ts +1 -0
- package/dist/__tests__/reloadActions.test.js +129 -0
- package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
- package/dist/__tests__/reloadActionsParity.test.js +42 -0
- package/dist/__tests__/types.test.js +5 -5
- package/dist/_core/device.d.ts +19 -9
- package/dist/_core/device.js +20 -14
- package/dist/capture.d.ts +6 -0
- package/dist/capture.js +53 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -1
- package/dist/reloadActions.d.ts +88 -0
- package/dist/reloadActions.js +200 -0
- package/dist/storeShots.d.ts +67 -0
- package/dist/storeShots.js +137 -0
- package/dist/types.d.ts +215 -3
- package/dist/voice.d.ts +61 -0
- package/dist/voice.js +246 -0
- package/package.json +14 -3
- package/src/BlackBox.ts +10 -1
- package/src/DeployPanel.tsx +74 -0
- package/src/Discovery.ts +13 -2
- package/src/FeedbackModal.tsx +563 -87
- package/src/MachinePickerScreen.tsx +12 -3
- package/src/P2PClient.ts +314 -2
- package/src/ShakeDetector.ts +1 -0
- package/src/VibeChatScreen.tsx +219 -0
- package/src/YaverFeedback.ts +498 -46
- package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
- package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
- package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
- package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
- package/src/__tests__/P2PClient.test.ts +2 -2
- package/src/__tests__/ReportIdentity.test.ts +203 -0
- package/src/__tests__/SDKToken.test.ts +1 -1
- package/src/__tests__/ShakeToggle.test.ts +153 -0
- package/src/__tests__/pickTargetDevice.test.ts +101 -0
- package/src/__tests__/reloadActions.test.ts +171 -0
- package/src/__tests__/reloadActionsParity.test.ts +49 -0
- package/src/__tests__/types.test.ts +5 -5
- package/src/_core/device.ts +20 -14
- package/src/capture.ts +51 -0
- package/src/index.ts +21 -0
- package/src/reloadActions.ts +273 -0
- package/src/storeShots.ts +189 -0
- package/src/types.ts +217 -3
- package/src/voice.ts +270 -0
package/src/VibeChatScreen.tsx
CHANGED
|
@@ -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
|
});
|