Sergei dde0ecb9cd Add Julia AI voice agent with LiveKit integration
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>
2026-01-17 17:58:31 -08:00

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,
},
});