Voice AI Features: - LiveKit Agents integration for real-time voice calls - Julia AI agent (Python) deployed to LiveKit Cloud - Token server for authentication - Debug screen with voice call testing - Voice call screen with full-screen UI Agent Configuration: - STT: Deepgram Nova-2 - LLM: OpenAI GPT-4o - TTS: Deepgram Aura Asteria (female voice) - Turn Detection: LiveKit Multilingual Model - VAD: Silero - Noise Cancellation: LiveKit BVC Files added: - julia-agent/ - Complete agent code and token server - app/voice-call.tsx - Full-screen voice call UI - services/livekitService.ts - LiveKit client service - contexts/VoiceTranscriptContext.tsx - Transcript state - polyfills/livekit-globals.ts - WebRTC polyfills 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
/**
|
|
* Voice Debug Screen
|
|
* Shows transcript logs from voice calls for debugging
|
|
* Allows easy copying of logs
|
|
*/
|
|
|
|
import React, { useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
ScrollView,
|
|
Alert,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { Ionicons, Feather } from '@expo/vector-icons';
|
|
import { useRouter } from 'expo-router';
|
|
import * as Clipboard from 'expo-clipboard';
|
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
|
import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext';
|
|
|
|
export default function VoiceDebugScreen() {
|
|
const router = useRouter();
|
|
const { transcript, clearTranscript, hasNewTranscript, markTranscriptAsShown, addTranscriptEntry } = useVoiceTranscript();
|
|
|
|
// Mark as shown when viewed
|
|
React.useEffect(() => {
|
|
if (hasNewTranscript) {
|
|
markTranscriptAsShown();
|
|
}
|
|
}, [hasNewTranscript, markTranscriptAsShown]);
|
|
|
|
// Copy all logs to clipboard
|
|
const copyAllLogs = useCallback(async () => {
|
|
if (transcript.length === 0) {
|
|
Alert.alert('No logs', 'There are no voice call logs to copy.');
|
|
return;
|
|
}
|
|
|
|
const logsText = transcript
|
|
.map((entry) => {
|
|
const time = entry.timestamp.toLocaleTimeString();
|
|
const speaker = entry.role === 'user' ? 'USER' : 'JULIA';
|
|
return `[${time}] ${speaker}: ${entry.text}`;
|
|
})
|
|
.join('\n\n');
|
|
|
|
const header = `=== Voice Call Transcript ===\n${new Date().toLocaleString()}\nTotal entries: ${transcript.length}\n\n`;
|
|
|
|
await Clipboard.setStringAsync(header + logsText);
|
|
Alert.alert('Copied!', 'Voice call logs copied to clipboard.');
|
|
}, [transcript]);
|
|
|
|
// Copy single entry
|
|
const copySingleEntry = useCallback(async (text: string) => {
|
|
await Clipboard.setStringAsync(text);
|
|
Alert.alert('Copied!', 'Message copied to clipboard.');
|
|
}, []);
|
|
|
|
// Clear all logs
|
|
const handleClearLogs = useCallback(() => {
|
|
Alert.alert(
|
|
'Clear Logs',
|
|
'Are you sure you want to clear all voice call logs?',
|
|
[
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Clear',
|
|
style: 'destructive',
|
|
onPress: clearTranscript,
|
|
},
|
|
]
|
|
);
|
|
}, [clearTranscript]);
|
|
|
|
// Start a new voice call
|
|
const startVoiceCall = useCallback(() => {
|
|
router.push('/voice-call');
|
|
}, [router]);
|
|
|
|
// Add mock data for testing (simulator has no microphone)
|
|
const addMockData = useCallback(() => {
|
|
const mockConversation = [
|
|
{ role: 'assistant' as const, text: "Hi! I have some concerns about Ferdinand today - there was an incident this morning. Want me to tell you more?" },
|
|
{ role: 'user' as const, text: "Yes, what happened?" },
|
|
{ role: 'assistant' as const, text: "Ferdinand had a fall at 6:32 AM in the bathroom. He was able to get up on his own, but I recommend checking in with him. His sleep was also shorter than usual - only 5 hours last night." },
|
|
{ role: 'user' as const, text: "Did he take his medications?" },
|
|
{ role: 'assistant' as const, text: "Yes, he took his morning medications at 8:15 AM. All on schedule. Would you like me to show you the dashboard with more details?" },
|
|
{ role: 'user' as const, text: "Show me the dashboard" },
|
|
{ role: 'assistant' as const, text: "Navigating to Dashboard now. You can see the 7-day overview there." },
|
|
];
|
|
|
|
mockConversation.forEach((entry, index) => {
|
|
setTimeout(() => {
|
|
addTranscriptEntry(entry.role, entry.text);
|
|
}, index * 100);
|
|
});
|
|
|
|
Alert.alert('Mock Data Added', 'Sample voice conversation added for testing.');
|
|
}, [addTranscriptEntry]);
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<View style={styles.headerLeft}>
|
|
<Feather name="terminal" size={24} color={AppColors.primary} />
|
|
<Text style={styles.headerTitle}>Voice Debug</Text>
|
|
</View>
|
|
<View style={styles.headerButtons}>
|
|
{transcript.length > 0 && (
|
|
<>
|
|
<TouchableOpacity style={styles.headerButton} onPress={copyAllLogs}>
|
|
<Ionicons name="copy-outline" size={22} color={AppColors.primary} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.headerButton} onPress={handleClearLogs}>
|
|
<Ionicons name="trash-outline" size={22} color={AppColors.error} />
|
|
</TouchableOpacity>
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Start Call Button */}
|
|
<View style={styles.callButtonContainer}>
|
|
<TouchableOpacity style={styles.callButton} onPress={startVoiceCall}>
|
|
<Ionicons name="call" size={24} color={AppColors.white} />
|
|
<Text style={styles.callButtonText}>Start Voice Call</Text>
|
|
</TouchableOpacity>
|
|
{/* Mock Data Button for simulator testing */}
|
|
<TouchableOpacity style={styles.mockDataButton} onPress={addMockData}>
|
|
<Feather name="plus-circle" size={20} color={AppColors.primary} />
|
|
<Text style={styles.mockDataButtonText}>Add Mock Data</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Logs Section */}
|
|
<View style={styles.logsHeader}>
|
|
<Text style={styles.logsTitle}>Call Transcript</Text>
|
|
<Text style={styles.logsCount}>
|
|
{transcript.length} {transcript.length === 1 ? 'entry' : 'entries'}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Transcript List */}
|
|
<ScrollView style={styles.logsList} contentContainerStyle={styles.logsContent}>
|
|
{transcript.length === 0 ? (
|
|
<View style={styles.emptyState}>
|
|
<Feather name="mic-off" size={48} color={AppColors.textMuted} />
|
|
<Text style={styles.emptyTitle}>No voice logs yet</Text>
|
|
<Text style={styles.emptySubtitle}>
|
|
Start a voice call with Julia AI to see the transcript here.
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
transcript.map((entry) => (
|
|
<TouchableOpacity
|
|
key={entry.id}
|
|
style={[
|
|
styles.logEntry,
|
|
entry.role === 'user' ? styles.logEntryUser : styles.logEntryAssistant,
|
|
]}
|
|
onLongPress={() => copySingleEntry(entry.text)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={styles.logEntryHeader}>
|
|
<View style={styles.logEntrySpeaker}>
|
|
<Ionicons
|
|
name={entry.role === 'user' ? 'person' : 'sparkles'}
|
|
size={14}
|
|
color={entry.role === 'user' ? AppColors.primary : AppColors.success}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.logEntrySpeakerText,
|
|
{ color: entry.role === 'user' ? AppColors.primary : AppColors.success },
|
|
]}
|
|
>
|
|
{entry.role === 'user' ? 'You' : 'Julia'}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.logEntryTime}>
|
|
{entry.timestamp.toLocaleTimeString()}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.logEntryText} selectable>
|
|
{entry.text}
|
|
</Text>
|
|
<Text style={styles.logEntryHint}>Long press to copy</Text>
|
|
</TouchableOpacity>
|
|
))
|
|
)}
|
|
</ScrollView>
|
|
|
|
{/* Footer hint */}
|
|
{transcript.length > 0 && (
|
|
<View style={styles.footer}>
|
|
<Text style={styles.footerText}>
|
|
Tap the copy icon to copy all logs
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: AppColors.background,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: AppColors.border,
|
|
},
|
|
headerLeft: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: Spacing.sm,
|
|
},
|
|
headerTitle: {
|
|
fontSize: FontSizes.xl,
|
|
fontWeight: '700',
|
|
color: AppColors.textPrimary,
|
|
},
|
|
headerButtons: {
|
|
flexDirection: 'row',
|
|
gap: Spacing.sm,
|
|
},
|
|
headerButton: {
|
|
padding: Spacing.xs,
|
|
borderRadius: BorderRadius.md,
|
|
backgroundColor: AppColors.surface,
|
|
},
|
|
callButtonContainer: {
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.md,
|
|
},
|
|
callButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: Spacing.sm,
|
|
backgroundColor: AppColors.success,
|
|
paddingVertical: Spacing.md,
|
|
borderRadius: BorderRadius.lg,
|
|
shadowColor: AppColors.success,
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 8,
|
|
elevation: 4,
|
|
},
|
|
callButtonText: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: '600',
|
|
color: AppColors.white,
|
|
},
|
|
mockDataButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: Spacing.xs,
|
|
marginTop: Spacing.sm,
|
|
paddingVertical: Spacing.sm,
|
|
borderRadius: BorderRadius.md,
|
|
borderWidth: 1,
|
|
borderColor: AppColors.primary,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
mockDataButtonText: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: '500',
|
|
color: AppColors.primary,
|
|
},
|
|
logsHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: AppColors.border,
|
|
},
|
|
logsTitle: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
},
|
|
logsCount: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textMuted,
|
|
},
|
|
logsList: {
|
|
flex: 1,
|
|
},
|
|
logsContent: {
|
|
padding: Spacing.md,
|
|
gap: Spacing.sm,
|
|
},
|
|
emptyState: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: Spacing.xxl * 2,
|
|
},
|
|
emptyTitle: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
marginTop: Spacing.md,
|
|
},
|
|
emptySubtitle: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textMuted,
|
|
textAlign: 'center',
|
|
marginTop: Spacing.xs,
|
|
paddingHorizontal: Spacing.xl,
|
|
},
|
|
logEntry: {
|
|
padding: Spacing.md,
|
|
borderRadius: BorderRadius.lg,
|
|
marginBottom: Spacing.sm,
|
|
},
|
|
logEntryUser: {
|
|
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
|
borderLeftWidth: 3,
|
|
borderLeftColor: AppColors.primary,
|
|
},
|
|
logEntryAssistant: {
|
|
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
|
borderLeftWidth: 3,
|
|
borderLeftColor: AppColors.success,
|
|
},
|
|
logEntryHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: Spacing.xs,
|
|
},
|
|
logEntrySpeaker: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
},
|
|
logEntrySpeakerText: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: '600',
|
|
},
|
|
logEntryTime: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textMuted,
|
|
},
|
|
logEntryText: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textPrimary,
|
|
lineHeight: 22,
|
|
},
|
|
logEntryHint: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textMuted,
|
|
marginTop: Spacing.xs,
|
|
fontStyle: 'italic',
|
|
},
|
|
footer: {
|
|
padding: Spacing.md,
|
|
alignItems: 'center',
|
|
borderTopWidth: 1,
|
|
borderTopColor: AppColors.border,
|
|
},
|
|
footerText: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textMuted,
|
|
},
|
|
});
|