Chat screen now supports both: - Text messaging (keyboard input) - High-quality Ultravox voice calls (WebRTC) Features: - Voice call button in input bar (phone icon) - Green status bar when call is active - Transcripts from voice calls appear in chat history - Voice badge on messages from voice conversation - Mute button during calls - Auto-end call when leaving screen Background audio configured for iOS (audio, voip modes)
273 lines
8.5 KiB
TypeScript
273 lines
8.5 KiB
TypeScript
/**
|
|
* Ultravox Voice AI Service
|
|
* Creates calls via Ultravox API and manages voice configuration
|
|
*/
|
|
|
|
// Import Ferdinand data
|
|
import ferdinandData from '@/assets/data/ferdinand_7days_events.json';
|
|
|
|
// API Configuration
|
|
const ULTRAVOX_API_URL = 'https://api.ultravox.ai/api';
|
|
const ULTRAVOX_API_KEY = '4miSVLym.HF3lV9y4euiuzcEbPPTLHEugrOu4jpNU';
|
|
|
|
// Fixed voice - Sarah only
|
|
export const VOICE_ID = 'Sarah';
|
|
export const VOICE_NAME = 'Sarah';
|
|
|
|
// Tool definitions for function calling
|
|
export interface UltravoxTool {
|
|
temporaryTool: {
|
|
modelToolName: string;
|
|
description: string;
|
|
dynamicParameters?: Array<{
|
|
name: string;
|
|
location: string;
|
|
schema: {
|
|
type: string;
|
|
description: string;
|
|
};
|
|
required: boolean;
|
|
}>;
|
|
client?: Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
export const ULTRAVOX_TOOLS: UltravoxTool[] = [
|
|
{
|
|
temporaryTool: {
|
|
modelToolName: 'navigateToDashboard',
|
|
description: 'Navigate to the Dashboard screen to show wellness overview, charts, and real-time status. Use when user asks to see the dashboard, overview, charts, or wants to check the current status visually.',
|
|
client: {},
|
|
},
|
|
},
|
|
{
|
|
temporaryTool: {
|
|
modelToolName: 'navigateToBeneficiaries',
|
|
description: 'Navigate to the beneficiaries list screen when user wants to see or manage their loved ones',
|
|
client: {},
|
|
},
|
|
},
|
|
{
|
|
temporaryTool: {
|
|
modelToolName: 'navigateToProfile',
|
|
description: 'Navigate to the user profile settings screen',
|
|
client: {},
|
|
},
|
|
},
|
|
];
|
|
|
|
// Build context from Ferdinand data
|
|
function buildFerdinandContext(): string {
|
|
const client = ferdinandData.client;
|
|
const summary = ferdinandData.summary;
|
|
|
|
// Get today's alerts
|
|
const todayData = ferdinandData.days.find(d => d.date === 'today');
|
|
const yesterdayData = ferdinandData.days.find(d => d.date === 'yesterday');
|
|
|
|
let context = `
|
|
BENEFICIARY INFORMATION:
|
|
- Name: ${client.name}
|
|
- Address: ${client.address}
|
|
- Monitoring Period: Last 7 days
|
|
|
|
CURRENT STATUS (Today - ${todayData?.day || 'Wednesday'}):
|
|
`;
|
|
|
|
// Add today's alerts with severity
|
|
if (todayData?.alerts && todayData.alerts.length > 0) {
|
|
context += `⚠️ ALERTS TODAY:\n`;
|
|
todayData.alerts.forEach(alert => {
|
|
const emoji = alert.severity === 'critical' ? '🔴' : alert.severity === 'high' ? '🟠' : alert.severity === 'medium' ? '🟡' : '🟢';
|
|
context += ` ${emoji} ${alert.type.replace(/_/g, ' ').toUpperCase()} at ${alert.time}`;
|
|
if (alert.note) context += ` - ${alert.note}`;
|
|
if (alert.location) context += ` (${alert.location})`;
|
|
context += '\n';
|
|
});
|
|
}
|
|
|
|
// Add yesterday's alerts
|
|
if (yesterdayData?.alerts && yesterdayData.alerts.length > 0) {
|
|
context += `\nYESTERDAY'S ALERTS:\n`;
|
|
yesterdayData.alerts.forEach(alert => {
|
|
const emoji = alert.severity === 'critical' ? '🔴' : alert.severity === 'high' ? '🟠' : alert.severity === 'medium' ? '🟡' : '🟢';
|
|
context += ` ${emoji} ${alert.type.replace(/_/g, ' ')} at ${alert.time}`;
|
|
if (alert.note) context += ` - ${alert.note}`;
|
|
context += '\n';
|
|
});
|
|
}
|
|
|
|
// 7-day summary
|
|
context += `
|
|
7-DAY SUMMARY:
|
|
- Total alerts: ${summary.total_alerts}
|
|
- Critical: ${summary.alerts_by_severity.critical}
|
|
- High: ${summary.alerts_by_severity.high}
|
|
- Medium: ${summary.alerts_by_severity.medium}
|
|
- Low: ${summary.alerts_by_severity.low}
|
|
|
|
KEY CONCERNS THIS WEEK:
|
|
`;
|
|
|
|
// Add all alerts summary
|
|
summary.alerts_summary.forEach(alert => {
|
|
const emoji = alert.severity === 'critical' ? '🔴' : alert.severity === 'high' ? '🟠' : alert.severity === 'medium' ? '🟡' : '🟢';
|
|
context += ` ${emoji} ${alert.date}: ${alert.type.replace(/_/g, ' ')}\n`;
|
|
});
|
|
|
|
// Add typical daily pattern from today's events
|
|
if (todayData?.events) {
|
|
const wakeUp = todayData.events.find(e => e.event === 'woke_up');
|
|
const sleep = todayData.events.find(e => e.event === 'fell_asleep');
|
|
const meals = todayData.events.filter(e => e.event === 'eating' || e.event === 'finished_eating');
|
|
const medications = todayData.events.filter(e => e.event === 'medication_taken');
|
|
const bathroom = todayData.events.filter(e => e.event === 'urination');
|
|
|
|
context += `
|
|
TODAY'S ACTIVITY PATTERN:
|
|
- Wake up: ${wakeUp?.time || 'N/A'}
|
|
- Meals: ${meals.length / 2} meals detected
|
|
- Medications: ${medications.length} doses taken
|
|
- Bathroom visits: ${bathroom.length} times
|
|
- Current location: ${todayData.events[todayData.events.length - 1]?.location || 'bedroom'}
|
|
`;
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
// System prompt generator with Ferdinand context
|
|
export function getSystemPrompt(): string {
|
|
const context = buildFerdinandContext();
|
|
|
|
return `You are Julia, a compassionate and knowledgeable AI wellness assistant for WellNuo app.
|
|
Your role is to help caregivers monitor and understand the wellbeing of their loved ones.
|
|
|
|
${context}
|
|
|
|
IMPORTANT GUIDELINES:
|
|
- Be warm, empathetic, and supportive in your responses
|
|
- You have FULL access to ${ferdinandData.client.name}'s wellness data from the last 7 days
|
|
- When asked about status, alerts, or concerns - refer to the actual data above
|
|
- Prioritize critical and high severity alerts when discussing concerns
|
|
- The FALL DETECTED today at 06:32 is the most urgent concern - acknowledge it if user asks about current status
|
|
- You can navigate the app using available tools when the user requests it
|
|
- If user asks to "show dashboard", "open dashboard", "see the overview" - use the navigateToDashboard tool
|
|
- Keep responses conversational and natural for voice interaction
|
|
- Speak in a calm, reassuring tone
|
|
- Be specific with times and details from the data
|
|
- If asked about something not in the data, say you don't have that information
|
|
|
|
Remember: You're speaking with a caregiver who wants the best for their loved one.
|
|
Be supportive and helpful while maintaining appropriate boundaries about medical advice.`;
|
|
}
|
|
|
|
// API Response types
|
|
export interface CreateCallResponse {
|
|
callId: string;
|
|
joinUrl: string;
|
|
created: string;
|
|
ended?: string;
|
|
model: string;
|
|
voice: string;
|
|
firstSpeaker: string;
|
|
transcriptOptional: boolean;
|
|
recordingEnabled: boolean;
|
|
}
|
|
|
|
export interface UltravoxError {
|
|
error: string;
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* Create a new Ultravox call
|
|
*/
|
|
export async function createCall(options: {
|
|
systemPrompt: string;
|
|
voice?: string;
|
|
tools?: UltravoxTool[];
|
|
firstSpeaker?: 'FIRST_SPEAKER_AGENT' | 'FIRST_SPEAKER_USER';
|
|
}): Promise<{ success: true; data: CreateCallResponse } | { success: false; error: string }> {
|
|
try {
|
|
const response = await fetch(`${ULTRAVOX_API_URL}/calls`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-API-Key': ULTRAVOX_API_KEY,
|
|
},
|
|
body: JSON.stringify({
|
|
systemPrompt: options.systemPrompt,
|
|
model: 'fixie-ai/ultravox',
|
|
voice: options.voice || VOICE_ID,
|
|
firstSpeaker: options.firstSpeaker || 'FIRST_SPEAKER_AGENT',
|
|
selectedTools: options.tools || ULTRAVOX_TOOLS,
|
|
medium: { webRtc: {} },
|
|
recordingEnabled: false,
|
|
maxDuration: '1800s', // 30 minutes max
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
console.error('[Ultravox] API error:', response.status, errorData);
|
|
return {
|
|
success: false,
|
|
error: errorData.message || `API error: ${response.status}`,
|
|
};
|
|
}
|
|
|
|
const data: CreateCallResponse = await response.json();
|
|
console.log('[Ultravox] Call created:', data.callId);
|
|
return { success: true, data };
|
|
} catch (error) {
|
|
console.error('[Ultravox] Create call error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to create call',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get call details
|
|
*/
|
|
export async function getCall(callId: string): Promise<CreateCallResponse | null> {
|
|
try {
|
|
const response = await fetch(`${ULTRAVOX_API_URL}/calls/${callId}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-API-Key': ULTRAVOX_API_KEY,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('[Ultravox] Get call error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* End a call
|
|
*/
|
|
export async function endCall(callId: string): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(`${ULTRAVOX_API_URL}/calls/${callId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'X-API-Key': ULTRAVOX_API_KEY,
|
|
},
|
|
});
|
|
|
|
return response.ok;
|
|
} catch (error) {
|
|
console.error('[Ultravox] End call error:', error);
|
|
return false;
|
|
}
|
|
}
|