Compare commits

...

10 Commits

Author SHA1 Message Date
Sergei
6d017ea617 WellNuo Lite Robert - Julia Robust Agent (no barge-in)
Changes:
- Updated livekitService.ts to use remote token server
- Julia-robust agent with disabled interruptions
- Added discard_audio_if_uninterruptible=True
- Added min_interruption_duration=2.0
- Token server configured for julia-robust agent
2026-01-24 15:22:21 -08:00
Sergei
a578ec8081 feat: Pass Debug tab deployment ID to voice calls
- Add debugDeploymentId to BeneficiaryContext for sharing between screens
- Sync Debug tab's deploymentId state with global context
- voice-call.tsx now prioritizes debugDeploymentId when starting calls
- Enables testing voice calls with specific deployment IDs from Debug screen
2026-01-24 00:05:47 -08:00
Sergei
5ecb5f9683 Fix Julia AI voice: use SINGLE_DEPLOYMENT_MODE for Lite
- livekitService.ts: send empty beneficiaryNamesDict in Lite mode
- agent.py: handle None beneficiary_names_dict correctly
- chat.tsx: align text chat with same SINGLE_DEPLOYMENT_MODE flag

This fixes Julia saying "I didn't get the name of beneficiary"
by letting WellNuo API use the default beneficiary for deployment_id.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 16:49:55 -08:00
Sergei
8d98bab3cf Remove expo-speech-recognition plugin from app.json
The package was removed in commit d9fff44 but the plugin
entry was left in app.json, causing EAS Build to fail with
'Unknown error in Read app config' phase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 10:25:29 -08:00
Sergei
e36b9bbf4a Add fallback audio configurations for iOS
If primary audio config fails (OSStatus -50), automatically try:
1. videoChat mode (speaker default)
2. voiceChat mode (earpiece default)
3. minimal config (most compatible)

Also make speaker output setting non-critical - call will work
even if output can't be changed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:43:05 -08:00
Sergei
d9fff44fc9 Remove unused expo-speech packages to avoid AudioSession conflicts
- Remove expo-speech (TTS) - not used
- Remove expo-speech-recognition (STT) - not used
- Delete dead code: hooks/useSpeechRecognition.ts

These packages add native audio modules that can conflict with
LiveKit's AudioSession management on iOS.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:41:35 -08:00
Sergei
42e3f389f7 Fix iOS AudioSession OSStatus error -50
- Remove 'defaultToSpeaker' from audioCategoryOptions (conflicts with some iOS versions)
- Use 'videoChat' mode instead of 'voiceChat' for speaker output
- Reorder operations: set config first, start session, then configure output
- Keep audioCategoryOptions minimal to avoid param conflicts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:36:20 -08:00
Sergei
ac6d458aae Fix Julia AI agent race condition - wait for participant metadata
The agent was reading participant metadata immediately after connecting,
but the mobile user might not have joined yet or metadata wasn't synced.

Changes:
- Replace extract_beneficiary_data() with wait_for_participant_with_metadata()
- Wait up to 10 seconds for participant with deploymentId
- Log waiting status every 2 seconds for debugging
- Fallback gracefully if timeout reached

This fixes "I couldn't get that information right now" error in voice calls.
2026-01-21 14:51:53 -08:00
Sergei
204cb87f05 Fix voice call race condition - ensure beneficiaryData is passed
- Fix race condition where connect() was called before beneficiaryData loaded
- Add connectCalledRef to prevent duplicate connect calls
- Wait for beneficiaryData.deploymentId before initiating call
- Add 5s timeout fallback for edge cases (API failure/no beneficiaries)
- Hide Debug tab, show only Julia tab in navigation
- Add Android keyboard layout fix for password fields
- Bump version to 1.0.5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:30:59 -08:00
Sergei
906213e620 Add beneficiary_names_dict support for voice assistant
- Voice agent now extracts deploymentId and beneficiaryNamesDict from
  participant metadata passed via LiveKit token
- WellNuoLLM class accepts dynamic deployment_id and beneficiary_names_dict
- API calls now include personalized beneficiary names for better responses
- Text chat already has this functionality (verified)
- Updated LiveKit agent deployed to cloud

Also includes:
- Speaker toggle button in voice call UI
- Keyboard controller integration for chat
- Various UI improvements
2026-01-20 14:41:33 -08:00
25 changed files with 1309 additions and 647 deletions

1
.gitignore vendored
View File

@ -54,3 +54,4 @@ store-screenshots/
# Build artifacts # Build artifacts
WellNuoLite-Android/ WellNuoLite-Android/
julia-agent/julia-ai/google-credentials.json

View File

@ -1,8 +1,8 @@
{ {
"expo": { "expo": {
"name": "WellNuo", "name": "WellNuo ROBUST",
"slug": "WellNuo", "slug": "WellNuo",
"version": "1.0.4", "version": "1.0.5",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "wellnuo", "scheme": "wellnuo",
@ -28,6 +28,7 @@
}, },
"android": { "android": {
"package": "com.wellnuo.app", "package": "com.wellnuo.app",
"softwareKeyboardLayoutMode": "resize",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png", "foregroundImage": "./assets/images/android-icon-foreground.png",
@ -68,13 +69,6 @@
"backgroundColor": "#000000" "backgroundColor": "#000000"
} }
} }
],
[
"expo-speech-recognition",
{
"microphonePermission": "WellNuo needs access to your microphone for voice commands.",
"speechRecognitionPermission": "WellNuo uses speech recognition to convert your voice to text."
}
] ]
], ],
"experiments": { "experiments": {
@ -84,9 +78,9 @@
"extra": { "extra": {
"router": {}, "router": {},
"eas": { "eas": {
"projectId": "a845255d-c966-4f12-aa60-c452c2d0c60d" "projectId": "8618c68a-6942-47ec-94f5-787fdbe5c0b4"
} }
}, },
"owner": "serter20692" "owner": "rzmrzli"
} }
} }

View File

@ -49,12 +49,14 @@ export default function LoginScreen() {
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.container} style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior="padding"
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
> >
<ScrollView <ScrollView
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
bounces={false}
> >
{/* Logo / Header */} {/* Logo / Header */}
<View style={styles.header}> <View style={styles.header}>
@ -132,8 +134,9 @@ const styles = StyleSheet.create({
}, },
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: Spacing.lg, paddingHorizontal: Spacing.lg,
paddingTop: Spacing.xxl + Spacing.xl, paddingTop: Spacing.xl,
paddingBottom: Spacing.xl, paddingBottom: Spacing.xl,
}, },
header: { header: {

View File

@ -56,7 +56,7 @@ export default function TabLayout() {
), ),
}} }}
/> />
{/* Voice tab hidden - using Debug for testing */} {/* Voice tab - HIDDEN (calls go through Julia tab chat screen) */}
<Tabs.Screen <Tabs.Screen
name="voice" name="voice"
options={{ options={{
@ -72,11 +72,14 @@ export default function TabLayout() {
), ),
}} }}
/> />
{/* Debug tab - hidden in production */} {/* Debug tab - Voice call debugging with detailed logs */}
<Tabs.Screen <Tabs.Screen
name="debug" name="debug"
options={{ options={{
href: null, title: 'Debug',
tabBarIcon: ({ color, size }) => (
<Feather name="terminal" size={22} color={color} />
),
}} }}
/> />
{/* Hide explore tab */} {/* Hide explore tab */}

View File

@ -9,9 +9,9 @@ import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { FullScreenError } from '@/components/ui/ErrorMessage'; import { FullScreenError } from '@/components/ui/ErrorMessage';
// Start with login page, then redirect to dashboard after auth // Use dev.kresoja.net for MobileAppLogin support
const LOGIN_URL = 'https://react.eluxnetworks.net/login'; // After MobileAppLogin() is called on /login, it auto-redirects to /dashboard
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard'; const LOGIN_URL = 'https://dev.kresoja.net/login';
export default function BeneficiaryDashboardScreen() { export default function BeneficiaryDashboardScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
@ -24,7 +24,7 @@ export default function BeneficiaryDashboardScreen() {
const [userName, setUserName] = useState<string | null>(null); const [userName, setUserName] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [isTokenLoaded, setIsTokenLoaded] = useState(false); const [isTokenLoaded, setIsTokenLoaded] = useState(false);
const [webViewUrl, setWebViewUrl] = useState(DASHBOARD_URL); const [hasCalledMobileLogin, setHasCalledMobileLogin] = useState(false);
const beneficiaryName = currentBeneficiary?.name || 'Dashboard'; const beneficiaryName = currentBeneficiary?.name || 'Dashboard';
@ -48,31 +48,49 @@ export default function BeneficiaryDashboardScreen() {
loadCredentials(); loadCredentials();
}, []); }, []);
// JavaScript to inject token into localStorage before page loads // JavaScript to call MobileAppLogin after page loads
// Web app uses auth2 key with JSON object: {username, token, user_id} // MobileAppLogin sets is_mobile=1, saves auth2, and redirects to /dashboard
const injectedJavaScript = authToken // This hides desktop navigation (login/logout/dashboard buttons)
? ` const getMobileLoginScript = () => {
if (!authToken) return '';
return `
(function() { (function() {
try { try {
// Web app expects auth2 as JSON object with these exact fields // Wait for window.MobileAppLogin to be available
var checkInterval = setInterval(function() {
if (typeof window.MobileAppLogin === 'function') {
clearInterval(checkInterval);
console.log('MobileAppLogin found, calling with auth data...');
var authData = { var authData = {
username: '${userName || ''}', username: '${userName || ''}',
token: '${authToken}', token: '${authToken}',
user_id: ${userId || 'null'} user_id: ${userId || 'null'}
}; };
localStorage.setItem('auth2', JSON.stringify(authData));
console.log('Auth data injected:', authData.username, 'user_id:', authData.user_id); window.MobileAppLogin(authData);
console.log('MobileAppLogin called successfully');
}
}, 100);
// Timeout after 5 seconds
setTimeout(function() {
clearInterval(checkInterval);
console.log('MobileAppLogin timeout - function not found');
}, 5000);
} catch(e) { } catch(e) {
console.error('Failed to inject token:', e); console.error('Failed to call MobileAppLogin:', e);
} }
})(); })();
true; true;
` `;
: ''; };
const handleRefresh = () => { const handleRefresh = () => {
setError(null); setError(null);
setIsLoading(true); setIsLoading(true);
setHasCalledMobileLogin(false); // Reset to call MobileAppLogin again
webViewRef.current?.reload(); webViewRef.current?.reload();
}; };
@ -84,6 +102,21 @@ export default function BeneficiaryDashboardScreen() {
const handleNavigationStateChange = (navState: any) => { const handleNavigationStateChange = (navState: any) => {
setCanGoBack(navState.canGoBack); setCanGoBack(navState.canGoBack);
// Auto-relogin when session expires (redirected to /login)
// If we're on /login and MobileAppLogin was already called, session expired
const url = navState.url || '';
const isOnLoginPage = url.includes('/login');
if (isOnLoginPage && hasCalledMobileLogin && authToken) {
console.log('[Dashboard] Session expired, re-authenticating...');
// Reset and call MobileAppLogin again
setHasCalledMobileLogin(false);
setTimeout(() => {
setHasCalledMobileLogin(true);
webViewRef.current?.injectJavaScript(getMobileLoginScript());
}, 1000);
}
}; };
const handleError = () => { const handleError = () => {
@ -169,10 +202,20 @@ export default function BeneficiaryDashboardScreen() {
<View style={styles.webViewContainer}> <View style={styles.webViewContainer}>
<WebView <WebView
ref={webViewRef} ref={webViewRef}
source={{ uri: webViewUrl }} source={{ uri: LOGIN_URL }}
style={styles.webView} style={styles.webView}
onLoadStart={() => setIsLoading(true)} onLoadStart={() => setIsLoading(true)}
onLoadEnd={() => setIsLoading(false)} onLoadEnd={() => {
setIsLoading(false);
// Call MobileAppLogin only once after first load
if (!hasCalledMobileLogin && authToken) {
setHasCalledMobileLogin(true);
// Small delay to ensure React app has mounted
setTimeout(() => {
webViewRef.current?.injectJavaScript(getMobileLoginScript());
}, 500);
}
}}
onError={handleError} onError={handleError}
onHttpError={handleError} onHttpError={handleError}
onNavigationStateChange={handleNavigationStateChange} onNavigationStateChange={handleNavigationStateChange}
@ -181,10 +224,6 @@ export default function BeneficiaryDashboardScreen() {
startInLoadingState={true} startInLoadingState={true}
scalesPageToFit={true} scalesPageToFit={true}
allowsBackForwardNavigationGestures={true} allowsBackForwardNavigationGestures={true}
// Inject token into localStorage BEFORE content loads
injectedJavaScriptBeforeContentLoaded={injectedJavaScript}
// Also inject after load in case page reads localStorage late
injectedJavaScript={injectedJavaScript}
renderLoading={() => ( renderLoading={() => (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} /> <ActivityIndicator size="large" color={AppColors.primary} />

View File

@ -13,12 +13,12 @@ import {
FlatList, FlatList,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
KeyboardAvoidingView,
Platform,
Modal, Modal,
ActivityIndicator, ActivityIndicator,
Keyboard, Keyboard,
Platform,
} from 'react-native'; } from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@ -34,6 +34,16 @@ const API_URL = 'https://eluxnetworks.net/function/well-api/api';
const WELLNUO_USER = 'anandk'; const WELLNUO_USER = 'anandk';
const WELLNUO_PASSWORD = 'anandk_8'; const WELLNUO_PASSWORD = 'anandk_8';
// ============================================================================
// SINGLE_DEPLOYMENT_MODE
// When true: sends only deployment_id (no beneficiary_names_dict)
// When false: sends both deployment_id AND beneficiary_names_dict
//
// Use true for WellNuo Lite (single beneficiary per user)
// Use false for full WellNuo app (multiple beneficiaries)
// ============================================================================
const SINGLE_DEPLOYMENT_MODE = true;
// Keywords for question normalization (same as julia-agent/julia-ai/src/agent.py) // Keywords for question normalization (same as julia-agent/julia-ai/src/agent.py)
const STATUS_KEYWORDS = [ const STATUS_KEYWORDS = [
/\bhow\s+is\b/i, /\bhow\s+is\b/i,
@ -260,19 +270,36 @@ export default function ChatScreen() {
// (same logic as julia-agent/julia-ai/src/agent.py) // (same logic as julia-agent/julia-ai/src/agent.py)
const normalizedQuestion = normalizeQuestion(trimmedInput); const normalizedQuestion = normalizeQuestion(trimmedInput);
// Build beneficiary_names_dict from all loaded beneficiaries
// Format: {"21": "papa", "69": "David"}
const beneficiaryNamesDict: Record<string, string> = {};
beneficiaries.forEach(b => {
beneficiaryNamesDict[b.id.toString()] = b.name;
});
// Get deployment_id from current beneficiary or fallback to first one
const deploymentId = currentBeneficiary?.id?.toString() || beneficiaries[0]?.id?.toString() || '21';
// Call API with EXACT same params as voice agent // Call API with EXACT same params as voice agent
// Using ask_wellnuo_ai instead of voice_ask (same params, same response format) // SINGLE_DEPLOYMENT_MODE: sends only deployment_id (no beneficiary_names_dict)
const response = await fetch(API_URL, { const requestParams: Record<string, string> = {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
function: 'ask_wellnuo_ai', function: 'ask_wellnuo_ai',
clientId: 'MA_001', clientId: 'MA_001',
user_name: WELLNUO_USER, user_name: WELLNUO_USER,
token: token, token: token,
question: normalizedQuestion, question: normalizedQuestion,
deployment_id: '21', deployment_id: deploymentId,
}).toString(), };
// Only add beneficiary_names_dict if NOT in single deployment mode
if (!SINGLE_DEPLOYMENT_MODE) {
requestParams.beneficiary_names_dict = JSON.stringify(beneficiaryNamesDict);
}
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(requestParams).toString(),
}); });
const data = await response.json(); const data = await response.json();
@ -435,8 +462,7 @@ export default function ChatScreen() {
{/* Messages */} {/* Messages */}
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.chatContainer} style={styles.chatContainer}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior="padding"
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
> >
<FlatList <FlatList
ref={flatListRef} ref={flatListRef}

View File

@ -20,6 +20,8 @@ import {
Share, Share,
AppState, AppState,
AppStateStatus, AppStateStatus,
TextInput,
KeyboardAvoidingView,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@ -28,10 +30,11 @@ import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
import type { Room as RoomType } from 'livekit-client'; import type { Room as RoomType } from 'livekit-client';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { getToken, VOICE_NAME } from '@/services/livekitService'; import { getToken, VOICE_NAME } from '@/services/livekitService';
import { api } from '@/services/api';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { import {
configureAudioForVoiceCall, configureAudioForVoiceCall,
stopAudioSession, stopAudioSession,
setAudioOutput,
} from '@/utils/audioSession'; } from '@/utils/audioSession';
import { import {
startVoiceCallService, startVoiceCallService,
@ -56,12 +59,64 @@ export default function DebugScreen() {
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [callState, setCallState] = useState<CallState>('idle'); const [callState, setCallState] = useState<CallState>('idle');
const [callDuration, setCallDuration] = useState(0); const [callDuration, setCallDuration] = useState(0);
const [isSpeakerOn, setIsSpeakerOn] = useState(true); // Default to speaker const [agentState, setAgentState] = useState<string>('—'); // listening/thinking/speaking
const [lastUserText, setLastUserText] = useState<string>(''); // Последний распознанный текст пользователя
const [lastAgentText, setLastAgentText] = useState<string>(''); // Последний ответ агента
const [micLevel, setMicLevel] = useState<number>(0); // Уровень микрофона 0-100
const [deploymentId, setDeploymentIdState] = useState<string>(''); // Custom deployment ID
const [loadingBeneficiary, setLoadingBeneficiary] = useState(true);
const [accumulateResponses, setAccumulateResponses] = useState(true); // Накапливать chunks до полного ответа
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
// Refs для накопления chunks
const accumulatedUserTextRef = useRef<string>('');
const accumulatedAgentTextRef = useRef<string>('');
const lastUserSegmentIdRef = useRef<string | null>(null);
const lastAgentSegmentIdRef = useRef<string | null>(null);
const roomRef = useRef<RoomType | null>(null); const roomRef = useRef<RoomType | null>(null);
const callStartTimeRef = useRef<number | null>(null); const callStartTimeRef = useRef<number | null>(null);
const appStateRef = useRef<AppStateStatus>(AppState.currentState); const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const { currentBeneficiary, setDebugDeploymentId } = useBeneficiary();
// Sync deploymentId with context for voice-call.tsx to use
const setDeploymentId = useCallback((id: string) => {
setDeploymentIdState(id);
// Update context so voice-call.tsx can access it
setDebugDeploymentId(id.trim() || null);
}, [setDebugDeploymentId]);
// Load default deployment ID from first beneficiary
useEffect(() => {
const loadDefaultDeploymentId = async () => {
try {
// First check if currentBeneficiary is available
if (currentBeneficiary?.id) {
const id = currentBeneficiary.id.toString();
setDeploymentIdState(id);
setDebugDeploymentId(id); // Also set in context
setLoadingBeneficiary(false);
return;
}
// Otherwise load from API
const response = await api.getAllBeneficiaries();
if (response.ok && response.data && response.data.length > 0) {
const firstBeneficiary = response.data[0];
const id = firstBeneficiary.id.toString();
setDeploymentIdState(id);
setDebugDeploymentId(id); // Also set in context
}
} catch (error) {
console.error('[Debug] Failed to load beneficiary:', error);
} finally {
setLoadingBeneficiary(false);
}
};
loadDefaultDeploymentId();
}, [currentBeneficiary, setDebugDeploymentId]);
// Add log entry // Add log entry
const log = useCallback((message: string, type: LogEntry['type'] = 'info') => { const log = useCallback((message: string, type: LogEntry['type'] = 'info') => {
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
@ -127,20 +182,6 @@ export default function DebugScreen() {
return () => subscription.remove(); return () => subscription.remove();
}, [log]); }, [log]);
// Toggle speaker
const toggleSpeaker = useCallback(async () => {
const newState = !isSpeakerOn;
log(`=== TOGGLING SPEAKER: ${isSpeakerOn ? 'ON' : 'OFF'}${newState ? 'ON' : 'OFF'} ===`, 'info');
try {
await setAudioOutput(newState);
setIsSpeakerOn(newState);
log(`Speaker toggled to ${newState ? 'ON (loud speaker)' : 'OFF (earpiece)'}`, 'success');
} catch (err: any) {
log(`Speaker toggle error: ${err?.message || err}`, 'error');
}
}, [isSpeakerOn, log]);
// Start call // Start call
const startCall = useCallback(async () => { const startCall = useCallback(async () => {
if (callState !== 'idle') return; if (callState !== 'idle') return;
@ -148,7 +189,6 @@ export default function DebugScreen() {
clearLogs(); clearLogs();
setCallState('connecting'); setCallState('connecting');
setCallDuration(0); setCallDuration(0);
setIsSpeakerOn(true); // Reset speaker state
callStartTimeRef.current = null; callStartTimeRef.current = null;
try { try {
@ -205,7 +245,20 @@ export default function DebugScreen() {
// Step 4: Get token from server // Step 4: Get token from server
log('Step 4: Requesting token from server...', 'info'); log('Step 4: Requesting token from server...', 'info');
log(`Token server: wellnuo.smartlaunchhub.com/julia/token`, 'info'); log(`Token server: wellnuo.smartlaunchhub.com/julia/token`, 'info');
const result = await getToken(`user-${Date.now()}`);
// Передаём deployment ID если указан
const beneficiaryData = deploymentId.trim() ? {
deploymentId: deploymentId.trim(),
beneficiaryNamesDict: {},
} : undefined;
if (beneficiaryData) {
log(`📋 Using custom Deployment ID: ${deploymentId}`, 'success');
} else {
log(`📋 No Deployment ID specified (default mode)`, 'info');
}
const result = await getToken(`user-${Date.now()}`, beneficiaryData);
if (!result.success || !result.data) { if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to get token'); throw new Error(result.error || 'Failed to get token');
@ -252,11 +305,54 @@ export default function DebugScreen() {
}); });
newRoom.on(RoomEvent.ParticipantConnected, (participant: any) => { newRoom.on(RoomEvent.ParticipantConnected, (participant: any) => {
log(`EVENT: Participant connected: ${participant.identity}`, 'event'); log(`👋 PARTICIPANT CONNECTED: ${participant.identity}`, 'success');
// Подписаться на события этого участника (для агента Julia)
participant.on('isSpeakingChanged', (speaking: boolean) => {
if (speaking) {
log(`🔊 ${participant.identity} STARTED SPEAKING`, 'success');
setAgentState('speaking');
} else {
log(`🔇 ${participant.identity} stopped speaking`, 'info');
}
});
participant.on('trackMuted', (pub: any) => {
log(`🔇 ${participant.identity} muted ${pub.kind}`, 'event');
});
participant.on('trackUnmuted', (pub: any) => {
log(`🔊 ${participant.identity} unmuted ${pub.kind}`, 'event');
});
participant.on('attributesChanged', (attrs: any) => {
log(`📋 ${participant.identity} ATTRIBUTES:`, 'event');
Object.entries(attrs || {}).forEach(([k, v]) => {
log(` ${k}: ${v}`, 'info');
if (k === 'lk.agent.state') {
setAgentState(String(v));
}
});
});
participant.on('transcriptionReceived', (segments: any[]) => {
log(`🤖 ${participant.identity} TRANSCRIPTION:`, 'success');
segments.forEach((seg: any, i: number) => {
const text = seg.text || seg.final || '';
log(` [${i}] "${text}"`, 'info');
if (text) setLastAgentText(text);
});
});
// Показать текущие атрибуты участника
const attrs = participant.attributes || {};
if (Object.keys(attrs).length > 0) {
log(` Initial attributes: ${JSON.stringify(attrs)}`, 'info');
}
}); });
newRoom.on(RoomEvent.ParticipantDisconnected, (participant: any) => { newRoom.on(RoomEvent.ParticipantDisconnected, (participant: any) => {
log(`EVENT: Participant disconnected: ${participant.identity}`, 'event'); log(`👋 PARTICIPANT DISCONNECTED: ${participant.identity}`, 'event');
}); });
newRoom.on(RoomEvent.TrackSubscribed, (track: any, publication: any, participant: any) => { newRoom.on(RoomEvent.TrackSubscribed, (track: any, publication: any, participant: any) => {
@ -284,12 +380,45 @@ export default function DebugScreen() {
} }
}); });
newRoom.on(RoomEvent.DataReceived, (payload: any, participant: any) => { newRoom.on(RoomEvent.DataReceived, (payload: any, participant: any, kind: any, topic: any) => {
log(`📩 DATA RECEIVED from ${participant?.identity || 'unknown'}`, 'event');
log(` kind: ${kind}, topic: ${topic || 'none'}`, 'info');
try { try {
const data = JSON.parse(new TextDecoder().decode(payload)); const text = new TextDecoder().decode(payload);
log(`EVENT: Data received: ${JSON.stringify(data).substring(0, 100)}`, 'event'); const data = JSON.parse(text);
log(` type: ${data.type || 'unknown'}`, 'info');
// Подробное логирование разных типов сообщений
if (data.type === 'transcript' || data.type === 'transcription') {
log(` 🗣️ TRANSCRIPT: role=${data.role}`, 'success');
const text = data.text || data.content || '';
log(` 📝 TEXT: "${text}"`, 'success');
// Обновить UI
if (data.role === 'user') {
setLastUserText(text);
} else if (data.role === 'assistant' || data.role === 'agent') {
setLastAgentText(text);
}
} else if (data.type === 'state' || data.type === 'agent_state') {
const stateValue = data.state || JSON.stringify(data);
log(` 🤖 AGENT STATE: ${stateValue}`, 'success');
setAgentState(stateValue);
} else if (data.type === 'function_call' || data.type === 'tool_call') {
log(` 🔧 FUNCTION CALL: ${data.name || data.function || JSON.stringify(data)}`, 'event');
} else if (data.type === 'function_result' || data.type === 'tool_result') {
log(` ✅ FUNCTION RESULT: ${JSON.stringify(data.result || data).substring(0, 200)}`, 'event');
} else {
// Показать полный JSON для неизвестных типов
log(` 📦 FULL DATA: ${JSON.stringify(data)}`, 'info');
}
} catch (e) { } catch (e) {
log(`EVENT: Data received (binary)`, 'event'); // Попробовать показать как текст
try {
const text = new TextDecoder().decode(payload);
log(` 📄 RAW TEXT: "${text.substring(0, 300)}"`, 'info');
} catch {
log(` 📎 BINARY DATA: ${payload.byteLength} bytes`, 'info');
}
} }
}); });
@ -305,7 +434,267 @@ export default function DebugScreen() {
log(`EVENT: RoomMetadataChanged: ${metadata}`, 'event'); log(`EVENT: RoomMetadataChanged: ${metadata}`, 'event');
}); });
log('Event listeners set up', 'success'); // ===========================================
// TRANSCRIPTION - распознанный текст (STT)
// ===========================================
newRoom.on(RoomEvent.TranscriptionReceived, (segments: any[], participant: any) => {
const isUser = participant?.identity === newRoom.localParticipant.identity;
const who = isUser ? '👤 USER' : '🤖 AGENT';
segments.forEach((segment: any, idx: number) => {
const text = segment.text || segment.final || '';
const segmentId = segment.id || `seg-${Date.now()}`;
const isFinalFlag = segment.final !== undefined;
if (accumulateResponses) {
// === РЕЖИМ НАКОПЛЕНИЯ: Показываем только финальные полные ответы ===
if (isUser) {
// Новый сегмент или продолжение текущего
if (lastUserSegmentIdRef.current !== segmentId) {
// Если был предыдущий финальный - логируем его
if (accumulatedUserTextRef.current && lastUserSegmentIdRef.current) {
log(`👤 USER FINAL: "${accumulatedUserTextRef.current}"`, 'success');
}
accumulatedUserTextRef.current = text;
lastUserSegmentIdRef.current = segmentId;
} else {
// Обновляем текущий сегмент
accumulatedUserTextRef.current = text;
}
// Если финальный - логируем сразу
if (isFinalFlag && text) {
log(`👤 USER: "${text}"`, 'success');
setLastUserText(text);
accumulatedUserTextRef.current = '';
lastUserSegmentIdRef.current = null;
}
} else {
// AGENT
if (lastAgentSegmentIdRef.current !== segmentId) {
if (accumulatedAgentTextRef.current && lastAgentSegmentIdRef.current) {
log(`🤖 AGENT FINAL: "${accumulatedAgentTextRef.current}"`, 'success');
}
accumulatedAgentTextRef.current = text;
lastAgentSegmentIdRef.current = segmentId;
} else {
accumulatedAgentTextRef.current = text;
}
if (isFinalFlag && text) {
log(`🤖 JULIA: "${text}"`, 'success');
setLastAgentText(text);
accumulatedAgentTextRef.current = '';
lastAgentSegmentIdRef.current = null;
}
}
} else {
// === РЕЖИМ ПОЛНОГО ЛОГИРОВАНИЯ: Показываем каждый chunk ===
const finalLabel = isFinalFlag ? '(FINAL)' : '(interim)';
log(`🎤 TRANSCRIPTION from ${who} (${participant?.identity || 'unknown'})`, 'success');
log(` [${idx}] ${finalLabel}: "${text}"`, 'event');
if (segment.id) log(` segment.id: ${segment.id}`, 'info');
if (segment.firstReceivedTime) log(` firstReceivedTime: ${segment.firstReceivedTime}`, 'info');
if (segment.lastReceivedTime) log(` lastReceivedTime: ${segment.lastReceivedTime}`, 'info');
// Обновить UI с последним текстом
if (text && (isFinalFlag || !segment.final)) {
if (isUser) {
setLastUserText(text);
} else {
setLastAgentText(text);
}
}
}
});
});
// ===========================================
// PARTICIPANT ATTRIBUTES - состояние агента
// ===========================================
newRoom.on(RoomEvent.ParticipantAttributesChanged, (changedAttributes: any, participant: any) => {
log(`👤 ATTRIBUTES CHANGED for ${participant?.identity || 'unknown'}`, 'event');
Object.entries(changedAttributes || {}).forEach(([key, value]) => {
log(` ${key}: ${value}`, 'info');
// Особенно важно: lk.agent.state показывает listening/thinking/speaking
if (key === 'lk.agent.state') {
log(` 🤖 AGENT STATE: ${value}`, 'success');
// Обновить UI
setAgentState(String(value));
}
});
// Показать все текущие атрибуты
const attrs = participant?.attributes || {};
if (Object.keys(attrs).length > 0) {
log(` All attributes: ${JSON.stringify(attrs)}`, 'info');
}
});
// ===========================================
// SIGNAL CONNECTED/RECONNECTING
// ===========================================
newRoom.on(RoomEvent.SignalConnected, () => {
log('EVENT: SignalConnected - WebSocket подключен', 'success');
});
newRoom.on(RoomEvent.SignalReconnecting, () => {
log('EVENT: SignalReconnecting - переподключение сигнала...', 'event');
});
// ===========================================
// LOCAL TRACK UNPUBLISHED
// ===========================================
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: any, participant: any) => {
log(`EVENT: LocalTrackUnpublished - ${publication.trackSid}`, 'event');
});
// ===========================================
// ДОПОЛНИТЕЛЬНЫЕ СОБЫТИЯ ДЛЯ ПОЛНОГО ДЕБАГА
// ===========================================
// Качество соединения
newRoom.on(RoomEvent.ConnectionQualityChanged, (quality: any, participant: any) => {
const qualityEmoji = quality === 'excellent' ? '🟢' : quality === 'good' ? '🟡' : '🔴';
log(`${qualityEmoji} CONNECTION QUALITY: ${participant?.identity || 'local'}${quality}`, 'event');
});
// Изменение устройств (микрофон/камера подключены/отключены)
newRoom.on(RoomEvent.MediaDevicesChanged, () => {
log(`🔌 MEDIA DEVICES CHANGED - устройства обновились`, 'event');
});
// Изменение активного устройства
newRoom.on(RoomEvent.ActiveDeviceChanged, (kind: any, deviceId: any) => {
log(`🎛️ ACTIVE DEVICE CHANGED: ${kind}${deviceId}`, 'event');
});
// Ошибка подписки на трек
newRoom.on(RoomEvent.TrackSubscriptionFailed, (trackSid: any, participant: any, reason: any) => {
log(`❌ TRACK SUBSCRIPTION FAILED: ${trackSid} from ${participant?.identity}`, 'error');
log(` Reason: ${reason}`, 'error');
});
// Публикация трека (когда агент начинает говорить)
newRoom.on(RoomEvent.TrackPublished, (publication: any, participant: any) => {
log(`📢 TRACK PUBLISHED by ${participant?.identity}: ${publication.kind} (${publication.source})`, 'event');
});
// Отмена публикации трека
newRoom.on(RoomEvent.TrackUnpublished, (publication: any, participant: any) => {
log(`📤 TRACK UNPUBLISHED by ${participant?.identity}: ${publication.kind}`, 'event');
});
// Изменение метаданных участника
newRoom.on(RoomEvent.ParticipantMetadataChanged, (metadata: any, participant: any) => {
log(`📋 PARTICIPANT METADATA: ${participant?.identity}`, 'event');
try {
const parsed = JSON.parse(metadata || '{}');
log(` ${JSON.stringify(parsed)}`, 'info');
} catch {
log(` ${metadata}`, 'info');
}
});
// Изменение имени участника
newRoom.on(RoomEvent.ParticipantNameChanged, (name: any, participant: any) => {
log(`👤 PARTICIPANT NAME: ${participant?.identity}${name}`, 'event');
});
// Статус записи (если комната записывается)
newRoom.on(RoomEvent.RecordingStatusChanged, (recording: any) => {
log(`⏺️ RECORDING STATUS: ${recording ? 'RECORDING' : 'NOT RECORDING'}`, recording ? 'success' : 'info');
});
// Изменение статуса потока трека
newRoom.on(RoomEvent.TrackStreamStateChanged, (publication: any, streamState: any, participant: any) => {
log(`📊 TRACK STREAM STATE: ${participant?.identity}/${publication.trackSid}${streamState}`, 'event');
});
// Разрешения на подписку трека
newRoom.on(RoomEvent.TrackSubscriptionPermissionChanged, (publication: any, status: any, participant: any) => {
log(`🔐 TRACK PERMISSION: ${participant?.identity}/${publication.trackSid}${status}`, 'event');
});
// Статус подписки на трек
newRoom.on(RoomEvent.TrackSubscriptionStatusChanged, (publication: any, status: any, participant: any) => {
log(`📶 TRACK SUBSCRIPTION: ${participant?.identity}/${publication.trackSid}${status}`, 'event');
});
// Разрешения участника изменились
newRoom.on(RoomEvent.ParticipantPermissionsChanged, (prevPermissions: any, participant: any) => {
log(`🔑 PARTICIPANT PERMISSIONS CHANGED: ${participant?.identity}`, 'event');
log(` New permissions: ${JSON.stringify(participant?.permissions || {})}`, 'info');
});
// ChatMessage - сообщения в чате комнаты
newRoom.on(RoomEvent.ChatMessage, (message: any, participant: any) => {
log(`💬 CHAT MESSAGE from ${participant?.identity || 'system'}:`, 'success');
log(` ${message.message || JSON.stringify(message)}`, 'info');
});
// SIP DTMF - телефонные сигналы
newRoom.on(RoomEvent.SipDTMFReceived, (dtmf: any, participant: any) => {
log(`📞 SIP DTMF: ${dtmf.code} from ${participant?.identity}`, 'event');
});
// Детекция тишины микрофона
newRoom.on(RoomEvent.LocalAudioSilenceDetected, (publication: any) => {
log(`🔇 LOCAL AUDIO SILENCE DETECTED - микрофон молчит`, 'event');
});
// Изменения буфера DataChannel
newRoom.on(RoomEvent.DCBufferStatusChanged, (isLow: any, kind: any) => {
log(`📦 DC BUFFER: ${kind} buffer is ${isLow ? 'LOW' : 'OK'}`, isLow ? 'event' : 'info');
});
// Метрики производительности
newRoom.on(RoomEvent.MetricsReceived, (metrics: any) => {
log(`📈 METRICS RECEIVED:`, 'info');
if (metrics.audioStats) {
log(` Audio: bitrate=${metrics.audioStats.bitrate}, packetsLost=${metrics.audioStats.packetsLost}`, 'info');
}
if (metrics.videoStats) {
log(` Video: bitrate=${metrics.videoStats.bitrate}, fps=${metrics.videoStats.fps}`, 'info');
}
});
// Статус воспроизведения видео (если есть)
newRoom.on(RoomEvent.VideoPlaybackStatusChanged, () => {
log(`🎬 VIDEO PLAYBACK STATUS CHANGED`, 'event');
});
// Ошибка шифрования
newRoom.on(RoomEvent.EncryptionError, (error: any) => {
log(`🔒 ENCRYPTION ERROR: ${error?.message || error}`, 'error');
});
// Статус шифрования участника
newRoom.on(RoomEvent.ParticipantEncryptionStatusChanged, (encrypted: any, participant: any) => {
log(`🔐 ENCRYPTION STATUS: ${participant?.identity}${encrypted ? 'encrypted' : 'not encrypted'}`, 'event');
});
// Комната перемещена (редко)
newRoom.on(RoomEvent.Moved, (room: any) => {
log(`🚀 ROOM MOVED to new server`, 'event');
});
// Участник стал активным
newRoom.on(RoomEvent.ParticipantActive, (participant: any) => {
log(`✅ PARTICIPANT ACTIVE: ${participant?.identity}`, 'success');
// Проверяем, что это агент Julia (не локальный участник)
const isAgent = participant?.identity?.startsWith('agent-') ||
(participant?.attributes?.['lk.agent_name'] === 'julia-ai');
if (isAgent) {
log(``, 'success');
log(`🟢🟢🟢 AGENT READY 🟢🟢🟢`, 'success');
log(`🔊 Julia will now speak greeting...`, 'success');
log(``, 'success');
}
});
log('Event listeners set up (FULL DEBUG MODE)', 'success');
// Step 7: Connect to room // Step 7: Connect to room
log('Step 7: Connecting to LiveKit room...', 'info'); log('Step 7: Connecting to LiveKit room...', 'info');
@ -335,21 +724,117 @@ export default function DebugScreen() {
} }
}); });
// Listen for local track published // ===========================================
// LOCAL PARTICIPANT EVENTS - события моего микрофона
// ===========================================
newRoom.localParticipant.on('localTrackPublished', (pub: any) => { newRoom.localParticipant.on('localTrackPublished', (pub: any) => {
log(`MY TRACK PUBLISHED: ${pub.kind} sid=${pub.trackSid}`, 'success'); log(`🎤 MY TRACK PUBLISHED: ${pub.kind} sid=${pub.trackSid}`, 'success');
});
newRoom.localParticipant.on('localTrackUnpublished', (pub: any) => {
log(`🎤 MY TRACK UNPUBLISHED: ${pub.kind} sid=${pub.trackSid}`, 'event');
});
// IsSpeakingChanged - когда я начинаю/перестаю говорить
newRoom.localParticipant.on('isSpeakingChanged', (speaking: boolean) => {
if (speaking) {
log(`🗣️ >>> I STARTED SPEAKING <<<`, 'success');
} else {
log(`🤐 I stopped speaking`, 'info');
}
});
// Мой трек замьютился/размьютился
newRoom.localParticipant.on('trackMuted', (pub: any) => {
log(`🔇 MY TRACK MUTED: ${pub.kind}`, 'event');
});
newRoom.localParticipant.on('trackUnmuted', (pub: any) => {
log(`🔊 MY TRACK UNMUTED: ${pub.kind}`, 'success');
});
// Ошибка медиа устройства на моём участнике
newRoom.localParticipant.on('mediaDevicesError', (error: any) => {
log(`❌ MY MEDIA DEVICE ERROR: ${error?.message || error}`, 'error');
});
// Аудио поток захвачен
newRoom.localParticipant.on('audioStreamAcquired', () => {
log(`🎙️ AUDIO STREAM ACQUIRED - микрофон захвачен!`, 'success');
});
// Транскрипция на моём треке
newRoom.localParticipant.on('transcriptionReceived', (segments: any[]) => {
log(`🎤 MY TRANSCRIPTION (${segments.length} segments):`, 'success');
segments.forEach((seg: any, i: number) => {
log(` [${i}] "${seg.text || seg.final}"`, 'info');
});
}); });
// Listen when I become an active speaker (means mic is working) // Listen when I become an active speaker (means mic is working)
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers: any[]) => { newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers: any[]) => {
const iAmSpeaking = speakers.some(s => s.identity === newRoom.localParticipant.identity); const iAmSpeaking = speakers.some(s => s.identity === newRoom.localParticipant.identity);
if (iAmSpeaking) { if (iAmSpeaking) {
log(`*** I AM SPEAKING - MIC WORKS ***`, 'success'); log(`🎙️ *** I AM SPEAKING - MIC WORKS! ***`, 'success');
} }
}); });
log(`Local participant: ${newRoom.localParticipant.identity}`, 'info'); log(`Local participant: ${newRoom.localParticipant.identity}`, 'info');
// ===========================================
// AUDIO LEVEL MONITORING - периодическая проверка уровня микрофона
// ===========================================
let audioLevelInterval: ReturnType<typeof setInterval> | null = null;
let lastLoggedLevel = -1;
const startAudioLevelMonitoring = () => {
if (audioLevelInterval) return;
audioLevelInterval = setInterval(() => {
try {
// Найти microphone track среди всех публикаций
const audioTracks = newRoom.localParticipant.audioTrackPublications;
let localAudioTrack: any = null;
audioTracks.forEach((pub: any) => {
if (pub.source === 'microphone' || pub.kind === 'audio') {
localAudioTrack = pub;
}
});
if (localAudioTrack?.track) {
// Получаем audio level через LiveKit API
const audioLevel = (localAudioTrack.track as any).audioLevel;
if (audioLevel !== undefined) {
const roundedLevel = Math.round(audioLevel * 100);
// Обновить UI
setMicLevel(roundedLevel);
// Логируем только когда уровень существенно изменился
if (Math.abs(roundedLevel - lastLoggedLevel) > 5) {
lastLoggedLevel = roundedLevel;
const bars = '▓'.repeat(Math.min(20, Math.round(audioLevel * 20))) + '░'.repeat(Math.max(0, 20 - Math.round(audioLevel * 20)));
log(`🎚️ MIC LEVEL: [${bars}] ${roundedLevel}%`, audioLevel > 0.1 ? 'success' : 'info');
}
}
}
} catch (e) {
// Ignore errors
}
}, 200); // Проверять каждые 200мс для плавного UI
};
// Запустить мониторинг audio level после подключения
newRoom.on(RoomEvent.Connected, () => {
log('Starting audio level monitoring...', 'info');
setTimeout(startAudioLevelMonitoring, 1000);
});
// Остановить при отключении
newRoom.on(RoomEvent.Disconnected, () => {
if (audioLevelInterval) {
clearInterval(audioLevelInterval);
audioLevelInterval = null;
}
});
// Android: Start foreground service to keep call alive in background // Android: Start foreground service to keep call alive in background
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
log('Android: Starting foreground service...', 'info'); log('Android: Starting foreground service...', 'info');
@ -463,6 +948,52 @@ export default function DebugScreen() {
<Text style={styles.logCount}>{logs.length} logs</Text> <Text style={styles.logCount}>{logs.length} logs</Text>
</View> </View>
{/* Deployment ID Input */}
{callState === 'idle' && (
<View style={styles.deploymentIdContainer}>
<Text style={styles.deploymentIdLabel}>Deployment ID (optional):</Text>
<TextInput
style={styles.deploymentIdInput}
value={deploymentId}
onChangeText={setDeploymentId}
placeholder="Enter deployment ID..."
placeholderTextColor="#6b7280"
keyboardType="default"
autoCapitalize="none"
autoCorrect={false}
/>
{deploymentId.trim() && (
<TouchableOpacity
style={styles.clearDeploymentId}
onPress={() => setDeploymentId('')}
>
<Ionicons name="close-circle" size={20} color="#6b7280" />
</TouchableOpacity>
)}
</View>
)}
{/* Log Mode Toggle */}
<View style={styles.logModeContainer}>
<Text style={styles.logModeLabel}>Log mode:</Text>
<TouchableOpacity
style={[styles.logModeButton, accumulateResponses && styles.logModeButtonActive]}
onPress={() => setAccumulateResponses(true)}
>
<Text style={[styles.logModeButtonText, accumulateResponses && styles.logModeButtonTextActive]}>
Clean (final only)
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.logModeButton, !accumulateResponses && styles.logModeButtonActive]}
onPress={() => setAccumulateResponses(false)}
>
<Text style={[styles.logModeButtonText, !accumulateResponses && styles.logModeButtonTextActive]}>
Verbose (all chunks)
</Text>
</TouchableOpacity>
</View>
{/* Control Buttons - Row 1: Call controls */} {/* Control Buttons - Row 1: Call controls */}
<View style={styles.controls}> <View style={styles.controls}>
{callState === 'idle' ? ( {callState === 'idle' ? (
@ -481,19 +1012,6 @@ export default function DebugScreen() {
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* Speaker Toggle Button */}
<TouchableOpacity
style={[styles.speakerButton, isSpeakerOn ? styles.speakerOn : styles.speakerOff]}
onPress={toggleSpeaker}
disabled={callState === 'idle'}
>
<Ionicons
name={isSpeakerOn ? 'volume-high' : 'ear'}
size={20}
color="#fff"
/>
<Text style={styles.smallButtonText}>{isSpeakerOn ? 'Speaker' : 'Ear'}</Text>
</TouchableOpacity>
</View> </View>
{/* Control Buttons - Row 2: Log controls */} {/* Control Buttons - Row 2: Log controls */}
@ -518,6 +1036,54 @@ export default function DebugScreen() {
</View> </View>
</View> </View>
{/* ========== LIVE STATUS PANEL ========== */}
{callState === 'connected' && (
<View style={styles.liveStatusPanel}>
{/* Agent State */}
<View style={styles.liveStatusRow}>
<Text style={styles.liveStatusLabel}>🤖 Agent:</Text>
<View style={[
styles.agentStateBadge,
agentState === 'speaking' && styles.agentStateSpeaking,
agentState === 'thinking' && styles.agentStateThinking,
agentState === 'listening' && styles.agentStateListening,
]}>
<Text style={styles.agentStateText}>
{agentState === 'speaking' ? '🔊 SPEAKING' :
agentState === 'thinking' ? '🧠 THINKING' :
agentState === 'listening' ? '👂 LISTENING' :
agentState}
</Text>
</View>
</View>
{/* Mic Level */}
<View style={styles.liveStatusRow}>
<Text style={styles.liveStatusLabel}>🎙 Mic:</Text>
<View style={styles.micLevelContainer}>
<View style={[styles.micLevelBar, { width: `${Math.min(100, micLevel)}%` }]} />
</View>
<Text style={styles.micLevelText}>{micLevel}%</Text>
</View>
{/* Last User Text */}
{lastUserText ? (
<View style={styles.liveStatusRow}>
<Text style={styles.liveStatusLabel}>👤 You:</Text>
<Text style={styles.transcriptText} numberOfLines={2}>{lastUserText}</Text>
</View>
) : null}
{/* Last Agent Text */}
{lastAgentText ? (
<View style={styles.liveStatusRow}>
<Text style={styles.liveStatusLabel}>🤖 Julia:</Text>
<Text style={styles.transcriptText} numberOfLines={2}>{lastAgentText}</Text>
</View>
) : null}
</View>
)}
{/* Logs */} {/* Logs */}
<FlatList <FlatList
ref={flatListRef} ref={flatListRef}
@ -603,6 +1169,68 @@ const styles = StyleSheet.create({
color: '#888', color: '#888',
fontSize: 12, fontSize: 12,
}, },
deploymentIdContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: '#1f1f1f',
borderBottomWidth: 1,
borderBottomColor: '#333',
},
deploymentIdLabel: {
color: '#9ca3af',
fontSize: 12,
marginRight: 8,
},
deploymentIdInput: {
flex: 1,
backgroundColor: '#2a2a2a',
color: '#fff',
fontSize: 14,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#404040',
},
clearDeploymentId: {
marginLeft: 8,
padding: 4,
},
logModeContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.xs,
backgroundColor: '#1a1a1a',
gap: 8,
},
logModeLabel: {
color: '#9ca3af',
fontSize: 12,
marginRight: 4,
},
logModeButton: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
backgroundColor: '#333',
borderWidth: 1,
borderColor: '#404040',
},
logModeButtonActive: {
backgroundColor: '#3b82f6',
borderColor: '#3b82f6',
},
logModeButtonText: {
color: '#888',
fontSize: 11,
fontWeight: '500',
},
logModeButtonTextActive: {
color: '#fff',
},
controls: { controls: {
flexDirection: 'row', flexDirection: 'row',
padding: Spacing.md, padding: Spacing.md,
@ -666,19 +1294,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
borderRadius: 10, borderRadius: 10,
}, },
speakerButton: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 10,
},
speakerOn: {
backgroundColor: '#f59e0b', // Orange when speaker is ON
},
speakerOff: {
backgroundColor: '#4b5563', // Gray when earpiece
},
platformBadge: { platformBadge: {
flex: 1, flex: 1,
alignItems: 'flex-end', alignItems: 'flex-end',
@ -721,4 +1336,68 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
marginTop: 12, marginTop: 12,
}, },
// ========== LIVE STATUS PANEL STYLES ==========
liveStatusPanel: {
backgroundColor: '#1a1a1a',
borderBottomWidth: 1,
borderBottomColor: '#333',
padding: Spacing.sm,
gap: 6,
},
liveStatusRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
liveStatusLabel: {
color: '#888',
fontSize: 11,
fontWeight: '600',
width: 55,
},
agentStateBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
backgroundColor: '#333',
},
agentStateSpeaking: {
backgroundColor: '#22c55e',
},
agentStateThinking: {
backgroundColor: '#f59e0b',
},
agentStateListening: {
backgroundColor: '#3b82f6',
},
agentStateText: {
color: '#fff',
fontSize: 11,
fontWeight: '700',
},
micLevelContainer: {
flex: 1,
height: 8,
backgroundColor: '#333',
borderRadius: 4,
overflow: 'hidden',
},
micLevelBar: {
height: '100%',
backgroundColor: '#22c55e',
borderRadius: 4,
},
micLevelText: {
color: '#888',
fontSize: 11,
fontWeight: '600',
width: 35,
textAlign: 'right',
},
transcriptText: {
flex: 1,
color: '#e5e5e5',
fontSize: 11,
fontStyle: 'italic',
},
}); });

View File

@ -7,6 +7,7 @@ import { Stack, router, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import * as SplashScreen from 'expo-splash-screen'; import * as SplashScreen from 'expo-splash-screen';
import 'react-native-reanimated'; import 'react-native-reanimated';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { AuthProvider, useAuth } from '@/contexts/AuthContext';
@ -60,6 +61,7 @@ function RootLayoutNav() {
export default function RootLayout() { export default function RootLayout() {
return ( return (
<KeyboardProvider>
<AuthProvider> <AuthProvider>
<BeneficiaryProvider> <BeneficiaryProvider>
<VoiceTranscriptProvider> <VoiceTranscriptProvider>
@ -67,5 +69,6 @@ export default function RootLayout() {
</VoiceTranscriptProvider> </VoiceTranscriptProvider>
</BeneficiaryProvider> </BeneficiaryProvider>
</AuthProvider> </AuthProvider>
</KeyboardProvider>
); );
} }

View File

@ -9,24 +9,99 @@
* Features: * Features:
* - Phone call-like UI with Julia avatar * - Phone call-like UI with Julia avatar
* - Call duration timer * - Call duration timer
* - Mute/unmute and speaker toggle * - Mute/unmute toggle
* - Proper cleanup on unmount * - Proper cleanup on unmount
*/ */
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef, useState, useMemo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Animated, Easing, Dimensions } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Animated, Easing, Dimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext'; import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useLiveKitRoom, ConnectionState } from '@/hooks/useLiveKitRoom'; import { useLiveKitRoom, ConnectionState } from '@/hooks/useLiveKitRoom';
import { api } from '@/services/api';
import type { Beneficiary } from '@/types';
import type { BeneficiaryData } from '@/services/livekitService';
const { width: SCREEN_WIDTH } = Dimensions.get('window'); const { width: SCREEN_WIDTH } = Dimensions.get('window');
export default function VoiceCallScreen() { export default function VoiceCallScreen() {
const router = useRouter(); const router = useRouter();
const { clearTranscript, addTranscriptEntry } = useVoiceTranscript(); const { clearTranscript, addTranscriptEntry } = useVoiceTranscript();
const { currentBeneficiary, debugDeploymentId } = useBeneficiary();
// Beneficiary state for building beneficiaryData
const [beneficiaries, setBeneficiaries] = useState<Beneficiary[]>([]);
const [beneficiariesLoaded, setBeneficiariesLoaded] = useState(false);
// Load beneficiaries on mount
useEffect(() => {
const loadBeneficiaries = async () => {
try {
const response = await api.getAllBeneficiaries();
if (response.ok && response.data) {
setBeneficiaries(response.data);
console.log('[VoiceCall] Beneficiaries loaded:', response.data.length);
}
} catch (error) {
console.warn('[VoiceCall] Failed to load beneficiaries:', error);
} finally {
setBeneficiariesLoaded(true);
}
};
loadBeneficiaries();
}, []);
// Build beneficiaryData for voice agent
const beneficiaryData = useMemo((): BeneficiaryData | undefined => {
// PRIORITY 1: If debugDeploymentId is set (from Debug screen), use it
if (debugDeploymentId) {
console.log('[VoiceCall] Using DEBUG deployment ID:', debugDeploymentId);
return {
deploymentId: debugDeploymentId,
beneficiaryNamesDict: {},
};
}
// PRIORITY 2: Use beneficiaries from API
// Safety check - ensure beneficiaries is an array
if (!Array.isArray(beneficiaries) || beneficiaries.length === 0) {
console.log('[VoiceCall] No beneficiaries yet, skipping beneficiaryData');
return undefined;
}
try {
// Build beneficiary_names_dict from all beneficiaries
// Format: {"21": "papa", "69": "David"}
const beneficiaryNamesDict: Record<string, string> = {};
beneficiaries.forEach(b => {
// Safety: check that b exists and has id and name
if (b && b.id != null && b.name) {
beneficiaryNamesDict[String(b.id)] = b.name;
}
});
// Get deployment_id from current beneficiary or fallback to first one
const deploymentId = currentBeneficiary?.id != null
? String(currentBeneficiary.id)
: beneficiaries[0]?.id != null
? String(beneficiaries[0].id)
: '21';
console.log('[VoiceCall] BeneficiaryData:', { deploymentId, beneficiaryNamesDict });
return {
deploymentId,
beneficiaryNamesDict,
};
} catch (error) {
console.error('[VoiceCall] Error building beneficiaryData:', error);
return undefined;
}
}, [beneficiaries, currentBeneficiary, debugDeploymentId]);
// LiveKit hook - ALL logic is here // LiveKit hook - ALL logic is here
const { const {
@ -42,6 +117,7 @@ export default function VoiceCallScreen() {
toggleMute, toggleMute,
} = useLiveKitRoom({ } = useLiveKitRoom({
userId: `user-${Date.now()}`, userId: `user-${Date.now()}`,
beneficiaryData,
onTranscript: (role, text) => { onTranscript: (role, text) => {
addTranscriptEntry(role, text); addTranscriptEntry(role, text);
}, },
@ -52,16 +128,59 @@ export default function VoiceCallScreen() {
const rotateAnim = useRef(new Animated.Value(0)).current; const rotateAnim = useRef(new Animated.Value(0)).current;
const avatarScale = useRef(new Animated.Value(0.8)).current; const avatarScale = useRef(new Animated.Value(0.8)).current;
// Clear transcript and start call on mount // Clear transcript on mount
useEffect(() => { useEffect(() => {
clearTranscript(); clearTranscript();
connect();
return () => {
// Cleanup handled by the hook
};
}, []); }, []);
// Track if connect has been called to prevent duplicate calls
const connectCalledRef = useRef(false);
// Start call ONLY after beneficiaryData is ready
// IMPORTANT: We must wait for beneficiaryData to be populated!
// Without deploymentId, Julia AI agent won't know which beneficiary to talk about.
useEffect(() => {
// Prevent duplicate connect calls
if (connectCalledRef.current) return;
// If debugDeploymentId is set, connect immediately (don't wait for beneficiaries)
if (debugDeploymentId && beneficiaryData?.deploymentId) {
console.log('[VoiceCall] Starting call with DEBUG deploymentId:', debugDeploymentId);
connectCalledRef.current = true;
connect();
return;
}
// Otherwise, only connect when beneficiaries are loaded AND beneficiaryData is ready
if (beneficiariesLoaded && beneficiaryData?.deploymentId) {
console.log('[VoiceCall] Starting call with beneficiaryData:', JSON.stringify(beneficiaryData));
connectCalledRef.current = true;
connect();
} else if (beneficiariesLoaded) {
console.log('[VoiceCall] Waiting for beneficiaryData... Current state:', {
beneficiariesLoaded,
beneficiariesCount: beneficiaries.length,
beneficiaryData: beneficiaryData ? JSON.stringify(beneficiaryData) : 'undefined'
});
}
}, [beneficiariesLoaded, beneficiaryData, beneficiaries.length, connect, debugDeploymentId]);
// Fallback: if beneficiaryData doesn't arrive in 5 seconds, connect anyway
// This handles edge cases where API fails or user has no beneficiaries
useEffect(() => {
if (connectCalledRef.current) return;
const timeout = setTimeout(() => {
if (!connectCalledRef.current && beneficiariesLoaded) {
console.warn('[VoiceCall] Timeout: beneficiaryData not ready after 5s, connecting without it');
connectCalledRef.current = true;
connect();
}
}, 5000);
return () => clearTimeout(timeout);
}, [beneficiariesLoaded, connect]);
// Navigate back on disconnect or error // Navigate back on disconnect or error
useEffect(() => { useEffect(() => {
if (state === 'disconnected' || state === 'error') { if (state === 'disconnected' || state === 'error') {
@ -402,7 +521,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingVertical: Spacing.xl, paddingVertical: Spacing.xl,
paddingHorizontal: Spacing.lg, paddingHorizontal: Spacing.lg,
gap: 48, // Space between Mute and End Call buttons gap: 40, // Space between 2 buttons (Mute, End Call)
}, },
controlButton: { controlButton: {
alignItems: 'center', alignItems: 'center',

View File

@ -5,6 +5,7 @@ import {
Text, Text,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
Platform,
type TextInputProps, type TextInputProps,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@ -121,6 +122,11 @@ const styles = StyleSheet.create({
paddingHorizontal: Spacing.md, paddingHorizontal: Spacing.md,
fontSize: FontSizes.base, fontSize: FontSizes.base,
color: AppColors.textPrimary, color: AppColors.textPrimary,
// Fix for Android password field text visibility
...(Platform.OS === 'android' && {
fontFamily: 'Roboto',
includeFontPadding: false,
}),
}, },
inputWithLeftIcon: { inputWithLeftIcon: {
paddingLeft: 0, paddingLeft: 0,

View File

@ -7,12 +7,17 @@ interface BeneficiaryContextType {
clearCurrentBeneficiary: () => void; clearCurrentBeneficiary: () => void;
// Helper to format beneficiary context for AI // Helper to format beneficiary context for AI
getBeneficiaryContext: () => string; getBeneficiaryContext: () => string;
// Debug: Override deployment ID for testing (used by Debug screen)
debugDeploymentId: string | null;
setDebugDeploymentId: (id: string | null) => void;
} }
const BeneficiaryContext = createContext<BeneficiaryContextType | undefined>(undefined); const BeneficiaryContext = createContext<BeneficiaryContextType | undefined>(undefined);
export function BeneficiaryProvider({ children }: { children: React.ReactNode }) { export function BeneficiaryProvider({ children }: { children: React.ReactNode }) {
const [currentBeneficiary, setCurrentBeneficiary] = useState<Beneficiary | null>(null); const [currentBeneficiary, setCurrentBeneficiary] = useState<Beneficiary | null>(null);
// Debug: Override deployment ID for testing purposes
const [debugDeploymentId, setDebugDeploymentId] = useState<string | null>(null);
const clearCurrentBeneficiary = useCallback(() => { const clearCurrentBeneficiary = useCallback(() => {
setCurrentBeneficiary(null); setCurrentBeneficiary(null);
@ -70,6 +75,8 @@ export function BeneficiaryProvider({ children }: { children: React.ReactNode })
setCurrentBeneficiary, setCurrentBeneficiary,
clearCurrentBeneficiary, clearCurrentBeneficiary,
getBeneficiaryContext, getBeneficiaryContext,
debugDeploymentId,
setDebugDeploymentId,
}} }}
> >
{children} {children}

View File

@ -27,7 +27,7 @@
"credentialsSource": "remote" "credentialsSource": "remote"
}, },
"android": { "android": {
"buildType": "apk" "buildType": "app-bundle"
} }
} }
}, },

View File

@ -20,7 +20,7 @@ const isIOSSimulator = (): boolean => {
return PlatformConstants?.interfaceIdiom === 'simulator' || return PlatformConstants?.interfaceIdiom === 'simulator' ||
PlatformConstants?.isSimulator === true; PlatformConstants?.isSimulator === true;
}; };
import { getToken, VOICE_NAME } from '@/services/livekitService'; import { getToken, VOICE_NAME, BeneficiaryData } from '@/services/livekitService';
import { import {
configureAudioForVoiceCall, configureAudioForVoiceCall,
stopAudioSession, stopAudioSession,
@ -51,6 +51,7 @@ export interface LogEntry {
// Hook options // Hook options
export interface UseLiveKitRoomOptions { export interface UseLiveKitRoomOptions {
userId: string; userId: string;
beneficiaryData?: BeneficiaryData;
onTranscript?: (role: 'user' | 'assistant', text: string) => void; onTranscript?: (role: 'user' | 'assistant', text: string) => void;
autoConnect?: boolean; autoConnect?: boolean;
} }
@ -85,7 +86,7 @@ export interface UseLiveKitRoomReturn {
* Main hook for LiveKit voice calls * Main hook for LiveKit voice calls
*/ */
export function useLiveKitRoom(options: UseLiveKitRoomOptions): UseLiveKitRoomReturn { export function useLiveKitRoom(options: UseLiveKitRoomOptions): UseLiveKitRoomReturn {
const { userId, onTranscript, autoConnect = false } = options; const { userId, beneficiaryData, onTranscript, autoConnect = false } = options;
// State // State
const [state, setState] = useState<ConnectionState>('idle'); const [state, setState] = useState<ConnectionState>('idle');
@ -248,7 +249,7 @@ export function useLiveKitRoom(options: UseLiveKitRoomOptions): UseLiveKitRoomRe
setState('requesting_token'); setState('requesting_token');
logInfo('STEP 4/6: Requesting token from server...'); logInfo('STEP 4/6: Requesting token from server...');
const tokenResult = await getToken(userId); const tokenResult = await getToken(userId, beneficiaryData);
if (!tokenResult.success || !tokenResult.data) { if (!tokenResult.success || !tokenResult.data) {
const errorMsg = tokenResult.error || 'Failed to get token'; const errorMsg = tokenResult.error || 'Failed to get token';
@ -514,7 +515,7 @@ export function useLiveKitRoom(options: UseLiveKitRoomOptions): UseLiveKitRoomRe
// Cleanup // Cleanup
await stopAudioSession(); await stopAudioSession();
} }
}, [userId, onTranscript, logInfo, logWarn, logError, logSuccess]); }, [userId, beneficiaryData, onTranscript, logInfo, logWarn, logError, logSuccess]);
// =================== // ===================
// DISCONNECT FUNCTION // DISCONNECT FUNCTION

View File

@ -1,142 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Platform, Alert } from 'react-native';
import { debugLogger } from '@/services/DebugLogger';
// Try to import native module
let ExpoSpeechRecognitionModule: any = null;
let SPEECH_RECOGNITION_AVAILABLE = false;
try {
const speechRecognition = require('expo-speech-recognition');
ExpoSpeechRecognitionModule = speechRecognition.ExpoSpeechRecognitionModule;
if (ExpoSpeechRecognitionModule) {
SPEECH_RECOGNITION_AVAILABLE = true;
debugLogger.info('STT', 'Speech recognition module loaded successfully');
}
} catch (e) {
debugLogger.warn('STT', 'Speech recognition not available', e);
console.log('[useSpeechRecognition] expo-speech-recognition not available');
}
export interface SpeechRecognitionResult {
transcript: string;
isFinal: boolean;
}
export interface UseSpeechRecognitionReturn {
isListening: boolean;
recognizedText: string;
startListening: (options?: { continuous?: boolean }) => Promise<void>;
stopListening: () => void;
isAvailable: boolean;
hasPermission: boolean;
requestPermission: () => Promise<boolean>;
}
export function useSpeechRecognition(): UseSpeechRecognitionReturn {
const [isListening, setIsListening] = useState(false);
const [recognizedText, setRecognizedText] = useState('');
const [hasPermission, setHasPermission] = useState(false);
// Callbacks
const onResultRef = useRef<((result: SpeechRecognitionResult) => void) | null>(null);
useEffect(() => {
if (!SPEECH_RECOGNITION_AVAILABLE || !ExpoSpeechRecognitionModule) {
debugLogger.warn('STT', 'Cannot setup listeners - module not available');
return;
}
debugLogger.info('STT', 'Setting up speech recognition event listeners');
const subscriptions: any[] = [];
if (ExpoSpeechRecognitionModule.addListener) {
subscriptions.push(
ExpoSpeechRecognitionModule.addListener('start', () => {
debugLogger.info('STT', 'Speech recognition started');
setIsListening(true);
})
);
subscriptions.push(
ExpoSpeechRecognitionModule.addListener('end', () => {
debugLogger.info('STT', 'Speech recognition ended');
setIsListening(false);
})
);
subscriptions.push(
ExpoSpeechRecognitionModule.addListener('result', (event: any) => {
const transcript = event.results?.[0]?.transcript || '';
const isFinal = event.results?.[0]?.isFinal || false;
debugLogger.log('STT', `Recognized: "${transcript}" (${isFinal ? 'FINAL' : 'interim'})`);
setRecognizedText(transcript);
})
);
subscriptions.push(
ExpoSpeechRecognitionModule.addListener('error', (event: any) => {
debugLogger.error('STT', 'Speech recognition error', event);
setIsListening(false);
console.warn('[Speech] Error:', event);
})
);
}
return () => {
debugLogger.info('STT', 'Cleaning up speech recognition listeners');
subscriptions.forEach(sub => sub.remove?.());
};
}, []);
const requestPermission = async () => {
if (!SPEECH_RECOGNITION_AVAILABLE) {
debugLogger.warn('STT', 'Cannot request permission - module not available');
return false;
}
debugLogger.info('STT', 'Requesting microphone permissions');
const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync();
setHasPermission(result.granted);
debugLogger.log('STT', `Permission ${result.granted ? 'granted' : 'denied'}`);
return result.granted;
};
const startListening = async (options?: { continuous?: boolean }) => {
if (!SPEECH_RECOGNITION_AVAILABLE) {
debugLogger.error('STT', 'Cannot start - speech recognition not available');
Alert.alert('Not Available', 'Voice recognition is not available on this device.');
return;
}
try {
// Reset text
setRecognizedText('');
debugLogger.info('STT', `Starting speech recognition (continuous: ${options?.continuous ?? false})`);
await ExpoSpeechRecognitionModule.start({
lang: 'en-US',
interimResults: true,
maxAlternatives: 1,
continuous: options?.continuous ?? false,
});
} catch (e) {
debugLogger.error('STT', 'Failed to start listening', e);
console.error('Failed to start listening', e);
setIsListening(false);
}
};
const stopListening = () => {
debugLogger.info('STT', 'Stopping speech recognition');
if (SPEECH_RECOGNITION_AVAILABLE) {
ExpoSpeechRecognitionModule.stop();
}
setIsListening(false);
};
return {
isListening,
recognizedText,
startListening,
stopListening,
isAvailable: SPEECH_RECOGNITION_AVAILABLE,
hasPermission,
requestPermission
};
}

View File

@ -1,141 +0,0 @@
"""
WellNuo Voice Agent - Julia AI
LiveKit Agents Cloud deployment
Uses Deepgram STT/TTS + OpenAI GPT-4o LLM
"""
import os
import json
from dotenv import load_dotenv
from livekit import agents
from livekit.agents import Agent, AgentSession, RoomEventHandler
from livekit.plugins import deepgram, openai, silero
load_dotenv(".env.local")
# Ferdinand data for demo (in production, fetch from API)
FERDINAND_DATA = {
"client": {
"name": "Ferdinand Zmrzli",
"address": "661 Encore Way"
},
"today_alerts": [
{"type": "fall_detected", "time": "06:32", "severity": "critical", "location": "bathroom"},
{"type": "short_sleep", "time": "06:30", "severity": "high", "note": "Only 5 hours sleep (normal: 7-8)"},
{"type": "missed_medication", "time": "08:30", "severity": "high", "note": "Morning medication not taken"}
],
"yesterday_alerts": [
{"type": "high_bathroom_frequency", "time": "15:00", "severity": "medium", "note": "8 visits (normal: 5-6)"}
],
"summary": {
"total_alerts_7days": 12,
"critical": 2,
"high": 4,
"medium": 4,
"low": 2
}
}
def build_system_prompt() -> str:
"""Build Julia AI system prompt with Ferdinand context"""
client = FERDINAND_DATA["client"]
alerts = FERDINAND_DATA["today_alerts"]
has_critical = any(a["severity"] in ["critical", "high"] for a in alerts)
alerts_text = ""
for alert in alerts:
emoji = "RED" if alert["severity"] == "critical" else "ORANGE" if alert["severity"] == "high" else "YELLOW"
alerts_text += f" [{emoji}] {alert['type'].replace('_', ' ').upper()} at {alert['time']}"
if alert.get("note"):
alerts_text += f" - {alert['note']}"
if alert.get("location"):
alerts_text += f" ({alert['location']})"
alerts_text += "\n"
return f"""You are Julia, a compassionate AI wellness assistant for WellNuo app.
You help caregivers monitor their loved ones' wellbeing.
CRITICAL: You are ALWAYS talking about {client['name']} (the beneficiary), NOT about yourself!
BENEFICIARY INFORMATION:
- Name: {client['name']}
- Address: {client['address']}
- Monitoring Period: Last 7 days
TODAY'S ALERTS:
{alerts_text}
7-DAY SUMMARY:
- Total alerts: {FERDINAND_DATA['summary']['total_alerts_7days']}
- Critical: {FERDINAND_DATA['summary']['critical']}
- High: {FERDINAND_DATA['summary']['high']}
- Medium: {FERDINAND_DATA['summary']['medium']}
- Low: {FERDINAND_DATA['summary']['low']}
CONVERSATION RULES:
1. When user asks "how are you?" or "how's it going?" - ALWAYS respond about {client['name']}'s status, NOT about yourself as AI
- NEVER say "I'm doing well as an AI" - the user wants to know about their loved one!
2. When user asks "what's happening?" or "any updates?" - report {client['name']}'s current status and alerts
3. ALWAYS assume questions are about {client['name']} unless explicitly about app features
RESPONSE STYLE - BE CONCISE, NOT PUSHY:
- DON'T overwhelm with information immediately
- First give a SHORT summary, then ASK if they want details
- Example opening: "Hi! {'I have some important updates about ' + client['name'] + '. Would you like to hear them?' if has_critical else client['name'] + ' is doing well today. Anything specific you would like to know?'}"
- Wait for user to ask before giving long explanations
- Keep initial responses to 1-2 sentences max
- Only elaborate when user asks "tell me more", "what happened?", etc.
BAD (too pushy): "Hi! Ferdinand had a fall at 6:32 AM in the bathroom, his sleep was only 5 hours, he missed his morning medication..."
GOOD (concise): "Hi! I have some concerns about {client['name']} today - there was an incident this morning. Want me to tell you more?"
You're speaking with a caregiver who cares deeply about {client['name']}."""
class JuliaAssistant(Agent):
"""Julia AI Voice Assistant for WellNuo"""
def __init__(self) -> None:
super().__init__(
instructions=build_system_prompt(),
)
# Create the agent server
server = agents.AgentServer()
@server.rtc_session()
async def julia_session(ctx: agents.JobContext):
"""Main voice session handler"""
# Create the agent session with STT, LLM, TTS
session = AgentSession(
stt=deepgram.STT(model="nova-2"),
llm=openai.LLM(
model="gpt-4o",
api_key=os.getenv("OPENAI_API_KEY"),
),
tts=deepgram.TTS(model="aura-asteria-en"),
vad=silero.VAD.load(),
)
# Start the session
await session.start(
room=ctx.room,
agent=JuliaAssistant(),
)
# Generate initial greeting
await session.generate_reply(
instructions="Greet the user warmly. If there are critical alerts, mention you have important updates. Keep it brief - 1 sentence max."
)
if __name__ == "__main__":
agents.cli.run_app(server)

View File

@ -6,3 +6,9 @@ id = "CA_Yd3qcuYEVKKE"
[build] [build]
dockerfile = "Dockerfile" dockerfile = "Dockerfile"
[env]
# Deepgram for TTS
DEEPGRAM_API_KEY = "cec33b489b0ba12c4e4f1ea888e887e88fba5848"
# AssemblyAI for STT (best accuracy - correctly recognizes "dad" vs "dead")
ASSEMBLYAI_API_KEY = "42e753b65b6a4360ae4a77ac76961857"

View File

@ -12,7 +12,8 @@ dependencies = [
"livekit-agents[silero]~=1.3", "livekit-agents[silero]~=1.3",
"livekit-plugins-noise-cancellation~=0.2", "livekit-plugins-noise-cancellation~=0.2",
"livekit-plugins-deepgram~=1.0", "livekit-plugins-deepgram~=1.0",
"livekit-plugins-openai~=1.0", # Removed assemblyai - was giving garbage transcriptions
# Deepgram Nova-2 is faster and more accurate
"python-dotenv", "python-dotenv",
"aiohttp", "aiohttp",
] ]

View File

@ -1,9 +1,13 @@
""" """
WellNuo Voice Agent - Julia AI WellNuo Voice Agent - Julia Robust (NO BARGE-IN)
LiveKit Agents Cloud deployment LiveKit Agents Cloud deployment
Uses WellNuo ask_wellnuo_ai API for LLM responses, Deepgram for STT/TTS Uses WellNuo ask_wellnuo_ai API for LLM responses, Deepgram for STT/TTS
ROBUST MODE: Barge-in is DISABLED - user cannot interrupt the agent.
This prevents hallucinations from background noise being interpreted as speech.
""" """
import json
import logging import logging
import os import os
import random import random
@ -23,7 +27,7 @@ from livekit.agents import (
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions
from livekit.plugins import deepgram, noise_cancellation, silero from livekit.plugins import deepgram, noise_cancellation, silero
logger = logging.getLogger("julia-ai") logger = logging.getLogger("julia-robust")
# WellNuo API Configuration # WellNuo API Configuration
WELLNUO_API_URL = "https://eluxnetworks.net/function/well-api/api" WELLNUO_API_URL = "https://eluxnetworks.net/function/well-api/api"
@ -140,10 +144,20 @@ def normalize_question(user_message: str) -> str:
class WellNuoLLM(llm.LLM): class WellNuoLLM(llm.LLM):
"""Custom LLM that uses WellNuo ask_wellnuo_ai API.""" """Custom LLM that uses WellNuo ask_wellnuo_ai API."""
def __init__(self): def __init__(
self,
deployment_id: str | None = None,
beneficiary_names_dict: dict | None = None,
):
super().__init__() super().__init__()
self._token = None self._token = None
self._model_name = "wellnuo-voice-ask" self._model_name = "wellnuo-voice-ask"
# Dynamic values from participant metadata (or fallback to env/defaults)
self._deployment_id = deployment_id or DEPLOYMENT_ID
# SINGLE_DEPLOYMENT_MODE: if beneficiary_names_dict is empty or None,
# WellNuo API will automatically use the beneficiary name for this deployment_id
# This is the Lite mode - we don't need to pass the names dict
self._beneficiary_names_dict = beneficiary_names_dict if beneficiary_names_dict else None
@property @property
def model(self) -> str: def model(self) -> str:
@ -199,8 +213,22 @@ class WellNuoLLM(llm.LLM):
"user_name": WELLNUO_USER, "user_name": WELLNUO_USER,
"token": token, "token": token,
"question": normalized_question, "question": normalized_question,
"deployment_id": DEPLOYMENT_ID, "deployment_id": self._deployment_id,
} }
# Add beneficiary_names_dict ONLY if it's not empty
# In SINGLE_DEPLOYMENT_MODE (Lite app), we don't send names dict
# WellNuo API will use the beneficiary name for this deployment_id
if self._beneficiary_names_dict:
data["beneficiary_names_dict"] = json.dumps(
self._beneficiary_names_dict
)
logger.info(
f"Full mode: Using beneficiary_names_dict: {self._beneficiary_names_dict}"
)
else:
logger.info(
f"Single deployment mode: deployment_id={self._deployment_id}, no beneficiary_names_dict"
)
async with session.post(WELLNUO_API_URL, data=data) as resp: async with session.post(WELLNUO_API_URL, data=data) as resp:
result = await resp.json() result = await resp.json()
@ -286,8 +314,83 @@ class WellNuoLLMStream(llm.LLMStream):
def prewarm(proc: JobProcess): def prewarm(proc: JobProcess):
"""Preload VAD model for faster startup.""" """Preload VAD model for faster startup.
proc.userdata["vad"] = silero.VAD.load()
ROBUST MODE: VAD is still needed for detecting when user FINISHES speaking,
but we'll disable interruptions in the AgentSession.
High thresholds to only respond to clear, deliberate speech.
"""
proc.userdata["vad"] = silero.VAD.load(
min_silence_duration=1.5, # Wait 1.5s of silence before ending speech (very patient)
min_speech_duration=0.3, # Require 0.3s of speech to start (filter short noises)
activation_threshold=0.6, # Higher threshold - only clear speech triggers (default: 0.5)
)
async def wait_for_participant_with_metadata(
ctx: JobContext, timeout: float = 10.0
) -> tuple[str | None, dict | None]:
"""
Wait for a remote participant with metadata to join, then extract beneficiary data.
The mobile app passes this data through the LiveKit token metadata:
{
"deploymentId": "21",
"beneficiaryNamesDict": {"21": "papa", "69": "David"}
}
IMPORTANT: This function waits up to `timeout` seconds for a participant
with metadata to appear. This fixes the race condition where the agent
connects before the user's metadata is available.
"""
import asyncio
deployment_id = None
beneficiary_names_dict = None
start_time = asyncio.get_event_loop().time()
attempt = 0
while asyncio.get_event_loop().time() - start_time < timeout:
attempt += 1
# Check all remote participants for metadata
for participant in ctx.room.remote_participants.values():
metadata = participant.metadata
if metadata:
try:
data = json.loads(metadata)
deployment_id = data.get("deploymentId")
beneficiary_names_dict = data.get("beneficiaryNamesDict")
if deployment_id:
logger.info(
f"[Attempt {attempt}] Extracted from participant "
f"{participant.identity}: deployment_id={deployment_id}, "
f"beneficiary_names_dict={beneficiary_names_dict}"
)
return deployment_id, beneficiary_names_dict
except json.JSONDecodeError:
logger.warning(
f"Failed to parse participant metadata: {metadata}"
)
# Log waiting status every 2 seconds
if attempt % 4 == 0:
logger.info(
f"Waiting for participant with metadata... "
f"({int(asyncio.get_event_loop().time() - start_time)}s elapsed, "
f"participants: {len(ctx.room.remote_participants)})"
)
await asyncio.sleep(0.5)
# Timeout reached - log and return None
logger.warning(
f"Timeout ({timeout}s) waiting for participant metadata. "
f"Participants: {len(ctx.room.remote_participants)}"
)
return None, None
async def entrypoint(ctx: JobContext): async def entrypoint(ctx: JobContext):
@ -296,18 +399,48 @@ async def entrypoint(ctx: JobContext):
# CRITICAL: Must connect to room first before accessing ctx.room # CRITICAL: Must connect to room first before accessing ctx.room
await ctx.connect() await ctx.connect()
logger.info(f"Starting Julia AI session in room {ctx.room.name}") logger.info(f"Starting Julia ROBUST (no barge-in) session in room {ctx.room.name}")
logger.info(f"Using WellNuo ask_wellnuo_ai API with deployment_id: {DEPLOYMENT_ID}")
# Wait for participant with metadata - short timeout since metadata arrives immediately if present
# The mobile app sends deploymentId via token metadata
deployment_id, beneficiary_names_dict = await wait_for_participant_with_metadata(
ctx, timeout=2.0 # 2 seconds is enough - if metadata exists, it arrives within 0.5s
)
# Use deployment_id from metadata, or fall back to default
effective_deployment_id = deployment_id or DEPLOYMENT_ID
logger.info(f"Using deployment_id={effective_deployment_id} (from_metadata={deployment_id is not None})")
# ROBUST MODE: Barge-in DISABLED
# User cannot interrupt the agent while it's speaking.
# This prevents hallucinations from background noise.
session = AgentSession( session = AgentSession(
# Deepgram Nova-2 for accurate speech-to-text # Deepgram Nova-2 model for best STT accuracy
stt=deepgram.STT(model="nova-2"), stt=deepgram.STT(
# WellNuo voice_ask API for LLM model="nova-2-general",
llm=WellNuoLLM(), language="en-US",
smart_format=True, # Better punctuation and formatting
no_delay=True, # Faster response for real-time
),
# WellNuo voice_ask API for LLM with dynamic beneficiary data
llm=WellNuoLLM(
deployment_id=effective_deployment_id,
beneficiary_names_dict=beneficiary_names_dict,
),
# Deepgram Aura Asteria for natural female voice # Deepgram Aura Asteria for natural female voice
tts=deepgram.TTS(model="aura-asteria-en"), tts=deepgram.TTS(model="aura-asteria-en"),
# Silero VAD for voice activity detection # Silero VAD for voice activity detection (strict settings)
vad=ctx.proc.userdata["vad"], vad=ctx.proc.userdata["vad"],
# DISABLE INTERRUPTIONS COMPLETELY:
# allow_interruptions=False means user cannot interrupt agent while speaking
# This prevents "hallucinations" from random noises being interpreted as speech
allow_interruptions=False,
# CRITICAL: Discard any audio captured while agent is speaking
# This ensures user speech during agent output is completely ignored
# See: https://github.com/livekit/agents/issues/4316
discard_audio_if_uninterruptible=True,
# Require longer speech before processing (filter out short noises)
min_interruption_duration=2.0,
) )
# Start the session with Julia assistant # Start the session with Julia assistant
@ -332,6 +465,7 @@ if __name__ == "__main__":
entrypoint_fnc=entrypoint, entrypoint_fnc=entrypoint,
prewarm_fnc=prewarm, prewarm_fnc=prewarm,
# Agent name must match what token requests (AGENT_NAME in livekit.js) # Agent name must match what token requests (AGENT_NAME in livekit.js)
agent_name="julia-ai", # ROBUST version - separate from julia-ai
agent_name="julia-robust",
) )
) )

View File

@ -751,7 +751,6 @@ dependencies = [
{ name = "livekit-agents", extra = ["silero"] }, { name = "livekit-agents", extra = ["silero"] },
{ name = "livekit-plugins-deepgram" }, { name = "livekit-plugins-deepgram" },
{ name = "livekit-plugins-noise-cancellation" }, { name = "livekit-plugins-noise-cancellation" },
{ name = "livekit-plugins-openai" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
] ]
@ -768,7 +767,6 @@ requires-dist = [
{ name = "livekit-agents", extras = ["silero"], specifier = "~=1.3" }, { name = "livekit-agents", extras = ["silero"], specifier = "~=1.3" },
{ name = "livekit-plugins-deepgram", specifier = "~=1.0" }, { name = "livekit-plugins-deepgram", specifier = "~=1.0" },
{ name = "livekit-plugins-noise-cancellation", specifier = "~=0.2" }, { name = "livekit-plugins-noise-cancellation", specifier = "~=0.2" },
{ name = "livekit-plugins-openai", specifier = "~=1.0" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
] ]
@ -844,9 +842,6 @@ codecs = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
] ]
images = [
{ name = "pillow" },
]
silero = [ silero = [
{ name = "livekit-plugins-silero" }, { name = "livekit-plugins-silero" },
] ]
@ -923,19 +918,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/4d/37be8da861607f392d07bb0f1c6b57c635db249095084abcbfaaaab6d7b5/livekit_plugins_noise_cancellation-0.2.5-py3-none-win_amd64.whl", hash = "sha256:5879d28120a6b47a7d557832d9432683710987f79e9b514171898be36534380b", size = 65757107, upload-time = "2025-06-30T14:49:59.053Z" }, { url = "https://files.pythonhosted.org/packages/9f/4d/37be8da861607f392d07bb0f1c6b57c635db249095084abcbfaaaab6d7b5/livekit_plugins_noise_cancellation-0.2.5-py3-none-win_amd64.whl", hash = "sha256:5879d28120a6b47a7d557832d9432683710987f79e9b514171898be36534380b", size = 65757107, upload-time = "2025-06-30T14:49:59.053Z" },
] ]
[[package]]
name = "livekit-plugins-openai"
version = "1.3.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "livekit-agents", extra = ["codecs", "images"] },
{ name = "openai", extra = ["realtime"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/e3/30/44a724703548e729280f4a420ac3ec8d58b61dd4e79fff529060689f147f/livekit_plugins_openai-1.3.11.tar.gz", hash = "sha256:61d77152f96213003b9bdea48c86a44a07a70456a41c664e82a2b5ae99a7f72a", size = 48754, upload-time = "2026-01-14T18:45:46.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/68/1093471d954f24789e8a17d2f56deaa3831d86a0edbc747a63b8eb49c113/livekit_plugins_openai-1.3.11-py3-none-any.whl", hash = "sha256:c54964cd987fa8a9e341f66bb30baeb02bfeea62a87eebaf19ae52b0486e8224", size = 56582, upload-time = "2026-01-14T18:45:45.744Z" },
]
[[package]] [[package]]
name = "livekit-plugins-silero" name = "livekit-plugins-silero"
version = "1.3.11" version = "1.3.11"
@ -1289,11 +1271,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" }, { url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" },
] ]
[package.optional-dependencies]
realtime = [
{ name = "websockets" },
]
[[package]] [[package]]
name = "opentelemetry-api" name = "opentelemetry-api"
version = "1.39.1" version = "1.39.1"
@ -1416,79 +1393,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
] ]
[[package]]
name = "pillow"
version = "12.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" },
{ url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" },
{ url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" },
{ url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" },
{ url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" },
{ url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" },
{ url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" },
{ url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" },
{ url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" },
{ url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" },
{ url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" },
{ url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" },
{ url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" },
{ url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" },
{ url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" },
{ url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" },
{ url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" },
{ url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" },
{ url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" },
{ url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" },
{ url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" },
{ url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" },
{ url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" },
{ url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" },
{ url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
{ url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
{ url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
{ url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
{ url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
{ url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
{ url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
{ url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
{ url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
{ url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
{ url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
{ url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
{ url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
{ url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
{ url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
{ url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
{ url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
{ url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
{ url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" },
{ url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" },
{ url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" },
{ url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" },
{ url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" },
{ url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@ -2091,65 +1995,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
] ]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]] [[package]]
name = "yarl" name = "yarl"
version = "1.22.0" version = "1.22.0"

View File

@ -1,4 +1,4 @@
livekit-agents[silero]~=1.3 livekit-agents[silero]~=1.3
livekit-plugins-deepgram~=1.0 livekit-plugins-deepgram~=1.0
livekit-plugins-openai~=1.0
python-dotenv~=1.0 python-dotenv~=1.0
aiohttp~=3.9

View File

@ -10,7 +10,8 @@ app.use(express.json());
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || 'APIEivUcPW3WSrV'; const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || 'APIEivUcPW3WSrV';
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || 'A65mc5KUKE0VGdZNaMRwe6uJpA9ZQPAxS66akZTOfmL'; const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || 'A65mc5KUKE0VGdZNaMRwe6uJpA9ZQPAxS66akZTOfmL';
const LIVEKIT_URL = 'wss://live-kit-demo-70txlh6a.livekit.cloud'; const LIVEKIT_URL = 'wss://live-kit-demo-70txlh6a.livekit.cloud';
const AGENT_NAME = 'julia-ai'; // ROBUST MODE: Use julia-robust agent (no barge-in)
const AGENT_NAME = 'julia-robust';
// Health check // Health check
app.get('/health', (req, res) => { app.get('/health', (req, res) => {

37
package-lock.json generated
View File

@ -29,8 +29,6 @@
"expo-linking": "~8.0.10", "expo-linking": "~8.0.10",
"expo-router": "~6.0.19", "expo-router": "~6.0.19",
"expo-secure-store": "^15.0.8", "expo-secure-store": "^15.0.8",
"expo-speech": "~14.0.8",
"expo-speech-recognition": "^3.0.1",
"expo-splash-screen": "~31.0.12", "expo-splash-screen": "~31.0.12",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8", "expo-symbols": "~1.0.8",
@ -41,6 +39,7 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.20.6",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
@ -7386,26 +7385,6 @@
"node": ">=20.16.0" "node": ">=20.16.0"
} }
}, },
"node_modules/expo-speech": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-speech/-/expo-speech-14.0.8.tgz",
"integrity": "sha512-UjBFCFv58nutlLw92L7kUS0ZjbOOfaTdiEv/HbjvMrT6BfldoOLLBZbaEcEhDdZK36NY/kass0Kzxk+co6vxSQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-speech-recognition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-3.0.1.tgz",
"integrity": "sha512-sMyOE1Vq1635B2oG0irgv6MZo0axIva3yNMmy86fwSqcFI+y9GhfOqZnYx3PFq7A0tNP2AlaNZLA9+tkgBcLhQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-splash-screen": { "node_modules/expo-splash-screen": {
"version": "31.0.12", "version": "31.0.12",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.12.tgz", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.12.tgz",
@ -11329,6 +11308,20 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-keyboard-controller": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.20.6.tgz",
"integrity": "sha512-RS6FjIjTFtAMQGdcXp3m6jUs1XgDa8qkpO5c4ix1S5HS0z3L2E1LUOY5rD73YUADOO3MfQN1z3JkHdBtzKucbg==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-reanimated": ">=3.0.0"
}
},
"node_modules/react-native-reanimated": { "node_modules/react-native-reanimated": {
"version": "4.1.6", "version": "4.1.6",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",

View File

@ -32,8 +32,6 @@
"expo-linking": "~8.0.10", "expo-linking": "~8.0.10",
"expo-router": "~6.0.19", "expo-router": "~6.0.19",
"expo-secure-store": "^15.0.8", "expo-secure-store": "^15.0.8",
"expo-speech": "~14.0.8",
"expo-speech-recognition": "^3.0.1",
"expo-splash-screen": "~31.0.12", "expo-splash-screen": "~31.0.12",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8", "expo-symbols": "~1.0.8",
@ -44,6 +42,7 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.20.6",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",

View File

@ -5,12 +5,29 @@
*/ */
// Julia Token Server (dedicated endpoint for LiveKit tokens) // Julia Token Server (dedicated endpoint for LiveKit tokens)
// Production: Use remote Julia Token Server
const JULIA_TOKEN_SERVER = 'https://wellnuo.smartlaunchhub.com/julia'; const JULIA_TOKEN_SERVER = 'https://wellnuo.smartlaunchhub.com/julia';
// Voice configuration // Voice configuration
export const VOICE_ID = 'Asteria'; export const VOICE_ID = 'Asteria';
export const VOICE_NAME = 'Asteria'; export const VOICE_NAME = 'Asteria';
// ============================================================================
// SINGLE_DEPLOYMENT_MODE
// When true: sends only deploymentId (no beneficiaryNamesDict)
// When false: sends both deploymentId AND beneficiaryNamesDict
//
// Use true for WellNuo Lite (single beneficiary per user)
// Use false for full WellNuo app (multiple beneficiaries)
// ============================================================================
export const SINGLE_DEPLOYMENT_MODE = true;
// Beneficiary data to pass to voice agent
export interface BeneficiaryData {
deploymentId: string;
beneficiaryNamesDict: Record<string, string>;
}
// API Response types // API Response types
export interface LiveKitTokenResponse { export interface LiveKitTokenResponse {
success: boolean; success: boolean;
@ -25,10 +42,37 @@ export interface LiveKitTokenResponse {
/** /**
* Get a LiveKit access token from Julia Token Server * Get a LiveKit access token from Julia Token Server
* No authentication required - token server is dedicated for voice AI * No authentication required - token server is dedicated for voice AI
* @param userId - User identifier
* @param beneficiaryData - Optional beneficiary data to pass to voice agent
*/ */
export async function getToken(userId: string): Promise<LiveKitTokenResponse> { export async function getToken(
userId: string,
beneficiaryData?: BeneficiaryData
): Promise<LiveKitTokenResponse> {
try { try {
console.log('[LiveKit] Getting token for user:', userId); console.log('[LiveKit] Getting token for user:', userId);
console.log('[LiveKit] SINGLE_DEPLOYMENT_MODE:', SINGLE_DEPLOYMENT_MODE);
// Prepare request body based on SINGLE_DEPLOYMENT_MODE
let requestBody: { userId: string; beneficiaryData?: BeneficiaryData };
if (SINGLE_DEPLOYMENT_MODE && beneficiaryData) {
// In single deployment mode: send only deploymentId, no beneficiaryNamesDict
requestBody = {
userId,
beneficiaryData: {
deploymentId: beneficiaryData.deploymentId,
beneficiaryNamesDict: {}, // Empty - no list of names
},
};
console.log('[LiveKit] Single deployment mode - sending only deploymentId:', beneficiaryData.deploymentId);
} else {
// Full mode: send everything
requestBody = { userId, beneficiaryData };
if (beneficiaryData) {
console.log('[LiveKit] Full mode - sending beneficiary data:', beneficiaryData);
}
}
// Request LiveKit token from Julia Token Server // Request LiveKit token from Julia Token Server
const response = await fetch(`${JULIA_TOKEN_SERVER}/token`, { const response = await fetch(`${JULIA_TOKEN_SERVER}/token`, {
@ -36,7 +80,7 @@ export async function getToken(userId: string): Promise<LiveKitTokenResponse> {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ userId }), body: JSON.stringify(requestBody),
}); });
if (!response.ok) { if (!response.ok) {

View File

@ -50,30 +50,76 @@ export async function configureAudioForVoiceCall(): Promise<void> {
} }
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
// iOS-specific configuration // iOS-specific configuration with fallback strategies
console.log('[AudioSession] Step 1: Setting Apple audio config...'); // Try multiple configurations in order of preference
await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord',
// Note: removed 'allowBluetoothA2DP' - it's incompatible with playAndRecord
// on some iOS versions and causes "status -50" error.
// 'allowBluetooth' (HFP profile) is sufficient for voice calls.
audioCategoryOptions: [
'allowBluetooth',
'defaultToSpeaker',
'mixWithOthers',
],
audioMode: 'voiceChat',
});
console.log('[AudioSession] Step 2: Setting default output...'); const configs = [
// Strategy 1: videoChat mode (speaker by default, no problematic options)
{
name: 'videoChat',
config: {
audioCategory: 'playAndRecord',
audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'],
audioMode: 'videoChat',
},
},
// Strategy 2: voiceChat mode (more compatible, but earpiece by default)
{
name: 'voiceChat',
config: {
audioCategory: 'playAndRecord',
audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'],
audioMode: 'voiceChat',
},
},
// Strategy 3: Minimal config (most compatible)
{
name: 'minimal',
config: {
audioCategory: 'playAndRecord',
audioCategoryOptions: [],
audioMode: 'default',
},
},
];
let configSuccess = false;
let lastError: any = null;
for (const { name, config } of configs) {
try {
console.log(`[AudioSession] Trying ${name} configuration...`);
await AudioSession.setAppleAudioConfiguration(config);
console.log(`[AudioSession] ${name} configuration succeeded!`);
configSuccess = true;
break;
} catch (err) {
console.warn(`[AudioSession] ${name} config failed:`, err);
lastError = err;
// Continue to next strategy
}
}
if (!configSuccess) {
console.error('[AudioSession] All iOS configurations failed!');
throw lastError || new Error('All audio configurations failed');
}
console.log('[AudioSession] Starting audio session...');
await AudioSession.startAudioSession();
// Try to set speaker output (non-critical, don't throw on failure)
try {
console.log('[AudioSession] Setting default output to speaker...');
await AudioSession.configureAudio({ await AudioSession.configureAudio({
ios: { ios: {
defaultOutput: 'speaker', defaultOutput: 'speaker',
}, },
}); });
} catch (outputErr) {
console.log('[AudioSession] Step 3: Starting audio session...'); console.warn('[AudioSession] Could not set speaker output:', outputErr);
await AudioSession.startAudioSession(); // Continue anyway - audio will work, just maybe on earpiece
}
} else if (Platform.OS === 'android') { } else if (Platform.OS === 'android') {
// Android-specific configuration // Android-specific configuration
// IMPORTANT: Using 'music' stream type to force output to speaker // IMPORTANT: Using 'music' stream type to force output to speaker
@ -154,18 +200,15 @@ export async function reconfigureAudioForPlayback(): Promise<void> {
} }
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
// Just reconfigure the same settings - this "refreshes" the audio routing // Reconfigure with same safe settings - this "refreshes" the audio routing
await AudioSession.setAppleAudioConfiguration({ await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord', audioCategory: 'playAndRecord',
// Note: removed 'allowBluetoothA2DP' - it's incompatible with playAndRecord
// on some iOS versions and causes "status -50" error.
// 'allowBluetooth' (HFP profile) is sufficient for voice calls.
audioCategoryOptions: [ audioCategoryOptions: [
'allowBluetooth', 'allowBluetooth',
'defaultToSpeaker',
'mixWithOthers', 'mixWithOthers',
], ],
audioMode: 'voiceChat', // Use 'videoChat' - defaults to speaker
audioMode: 'videoChat',
}); });
} else if (Platform.OS === 'android') { } else if (Platform.OS === 'android') {
// Reconfigure Android audio to ensure speaker output // Reconfigure Android audio to ensure speaker output
@ -209,22 +252,20 @@ export async function setAudioOutput(useSpeaker: boolean): Promise<void> {
} }
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
// iOS: Configure audio output // iOS: Update configuration based on desired output
// Use 'videoChat' mode for speaker, 'voiceChat' for earpiece
await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord',
audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'],
audioMode: useSpeaker ? 'videoChat' : 'voiceChat',
});
// Also set default output
await AudioSession.configureAudio({ await AudioSession.configureAudio({
ios: { ios: {
defaultOutput: useSpeaker ? 'speaker' : 'earpiece', defaultOutput: useSpeaker ? 'speaker' : 'earpiece',
}, },
}); });
// Also update the full configuration to ensure it takes effect
// Note: removed 'allowBluetoothA2DP' - causes "status -50" error
await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord',
audioCategoryOptions: useSpeaker
? ['allowBluetooth', 'defaultToSpeaker', 'mixWithOthers']
: ['allowBluetooth', 'mixWithOthers'],
audioMode: 'voiceChat',
});
} else if (Platform.OS === 'android') { } else if (Platform.OS === 'android') {
// Android: Switch stream type to control speaker/earpiece // Android: Switch stream type to control speaker/earpiece
// - 'music' stream goes to speaker by default // - 'music' stream goes to speaker by default