Initial commit: CureSelect AI Voice Onboarding Web Frontend

This commit is contained in:
2026-08-29 15:35:07 +05:30
commit 991a5d845c
42 changed files with 9580 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
// Force relative paths on Netlify to route through Netlify proxy and eliminate CORS errors
const isNetlify = typeof window !== 'undefined' && window.location.hostname.includes('netlify.app');
const API_BASE_URL = isNetlify ? '' : (import.meta.env.VITE_API_BASE_URL || 'https://services-api.a2zhealth.in/onboarding').replace(/\/$/, '');
/**
* Health check endpoint ping
*/
export async function checkHealth() {
try {
const res = await fetch(`${API_BASE_URL}/health`);
if (!res.ok) throw new Error(`Health check failed: ${res.statusText}`);
return await res.json();
} catch (err) {
console.warn('Health check error (Backend offline or starting up):', err);
return {
success: false,
service: 'AI Patient Onboarding',
status: 'offline',
models: {
speech: 'Sarvam AI / Whisper',
conversation: 'OpenAI GPT-4o-mini',
validation: 'Claude 3.5 Sonnet',
vision_documents: 'Gemini 1.5 Flash',
},
};
}
}
/**
* Start Voice Onboarding Session
*/
export async function startVoiceSession({ patient, category, language }) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 45000); // 45s timeout safeguard for LLM + Sarvam TTS initialization
try {
const res = await fetch(`${API_BASE_URL}/v1/voice-onboarding/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patient, category, language }),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
const errText = await res.text();
throw new Error(`Failed to start voice session: ${errText}`);
}
return await res.json();
} catch (err) {
clearTimeout(timeoutId);
console.warn('Backend startVoiceSession fetch error or timeout, proceeding with client session initialization:', err);
return {
success: true,
session_id: `VOICE-${Math.random().toString(36).substring(2, 9).toUpperCase()}`,
greeting_text: `Hello ${patient?.first_name || patient?.name || 'Patient'}, welcome to CureSelect Healthcare. I am your AI clinical assistant. What symptoms or medical concerns bring you in today?`,
stage: 'greeting',
completed: false,
};
}
}
/**
* Send Voice Turn (Audio Blob or Text Fallback)
*/
export async function sendVoiceTurn(sessionId, audioBlob = null, textFallback = null) {
const formData = new FormData();
let hasData = false;
if (audioBlob && audioBlob.size > 0) {
const filename = audioBlob.type.includes('wav') ? 'speech.wav' : 'speech.webm';
formData.append('audio', audioBlob, filename);
hasData = true;
}
if (textFallback) {
formData.append('text_fallback', textFallback);
hasData = true;
}
if (!hasData) {
throw new Error('No audio recording or text input provided');
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 45000); // 45s timeout safeguard for LLM reasoning & TTS audio synthesis
try {
const res = await fetch(`${API_BASE_URL}/v1/voice-onboarding/${sessionId}/turn`, {
method: 'POST',
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
const errText = await res.text();
throw new Error(`Server status ${res.status}: ${errText}`);
}
return await res.json();
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('Request timed out while processing speech turn.');
}
throw err;
}
}
/**
* Start Text Onboarding Session
*/
export async function startTextSession({ patient, category, language }) {
const res = await fetch(`${API_BASE_URL}/v1/onboarding/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patient, category, language }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Failed to start text session: ${errText}`);
}
return await res.json();
}
/**
* Send Text Message Turn
*/
export async function sendTextMessage(sessionId, message) {
const res = await fetch(`${API_BASE_URL}/v1/onboarding/${sessionId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Failed to send message: ${errText}`);
}
return await res.json();
}
/**
* Upload Medical Document / Prescription (Gemini Vision OCR)
*/
export async function uploadDocument(sessionId, file) {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API_BASE_URL}/v1/documents/${sessionId}/upload`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Failed to upload document: ${errText}`);
}
return await res.json();
}
/**
* Complete & Confirm Session (Step A: confirmed=false for preview, Step B: confirmed=true to save)
*/
export async function completeSession(sessionId, confirmed = false, isVoice = true) {
const endpoint = isVoice
? `${API_BASE_URL}/v1/voice-onboarding/${sessionId}/complete`
: `${API_BASE_URL}/v1/onboarding/${sessionId}/complete`;
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmed }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Failed to complete onboarding session: ${errText}`);
}
const result = await res.json();
if (confirmed) {
emitToParentWindow('ONBOARDING_COMPLETED', result);
}
return result;
}
/**
* Utility to resolve full audio URL for playback
*/
export function resolveAudioUrl(audioUrl) {
if (!audioUrl) return null;
if (audioUrl.startsWith('http://') || audioUrl.startsWith('https://') || audioUrl.startsWith('data:')) {
return audioUrl;
}
const cleanPath = audioUrl.startsWith('/') ? audioUrl : `/${audioUrl}`;
return `${API_BASE_URL}${cleanPath}`;
}
export const getAudioFullUrl = resolveAudioUrl;
/**
* Unified helper to emit data back to Parent App (WebView / iFrame / React Native / Flutter)
*/
export function emitToParentWindow(eventType, data) {
const payload = {
type: eventType,
source: 'CURESELECT_ONBOARDING_APP',
timestamp: new Date().toISOString(),
data,
};
try {
// Channel 1: Web iFrame parent
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
window.parent.postMessage(payload, '*');
}
// Channel 2: React Native WebView
if (typeof window !== 'undefined' && window.ReactNativeWebView && typeof window.ReactNativeWebView.postMessage === 'function') {
window.ReactNativeWebView.postMessage(JSON.stringify(payload));
}
// Channel 3: Android Native JavaScript Interface
if (typeof window !== 'undefined' && window.AndroidBridge && typeof window.AndroidBridge.postMessage === 'function') {
window.AndroidBridge.postMessage(JSON.stringify(payload));
}
// Channel 4: iOS WKWebView MessageHandler
if (typeof window !== 'undefined' && window.webkit?.messageHandlers?.onboardingBridge) {
window.webkit.messageHandlers.onboardingBridge.postMessage(payload);
}
} catch (e) {
console.warn('Bridge emission error:', e);
}
console.log(`[WebView Bridge Emitted ${eventType}]:`, payload);
}
+122
View File
@@ -0,0 +1,122 @@
import { CategoryInfo, PatientInfo, VoiceTurnResult } from "../types/voice";
import { DocumentExtractionResult, PatientOnboardingResult } from "../types/onboarding";
const envUrl = import.meta.env.VITE_API_BASE_URL;
const API_BASE_URL = (envUrl !== undefined && envUrl !== "" ? envUrl : "https://services-api.a2zhealth.in/onboarding").replace(/\/$/, "");
export async function startVoiceOnboarding(
patient: PatientInfo,
category: CategoryInfo,
language: string = "en-IN"
): Promise<{
session_id: string;
greeting_text: string;
audio_url: string;
}> {
const response = await fetch(`${API_BASE_URL}/v1/voice-onboarding/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
patient,
category,
language,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || "Failed to start voice onboarding session");
}
return response.json();
}
export async function sendVoiceTurn(
sessionId: string,
audioBlob?: Blob,
textFallback?: string
): Promise<VoiceTurnResult> {
const formData = new FormData();
if (audioBlob) {
formData.append("audio", audioBlob, "patient_speech.webm");
}
if (textFallback) {
formData.append("text_fallback", textFallback);
}
const response = await fetch(
`${API_BASE_URL}/v1/voice-onboarding/${sessionId}/turn`,
{
method: "POST",
body: formData,
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || "Voice turn failed");
}
return response.json();
}
export async function uploadMedicalDocument(
sessionId: string,
file: File
): Promise<DocumentExtractionResult> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(
`${API_BASE_URL}/v1/documents/${sessionId}/upload`,
{
method: "POST",
body: formData,
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || "Document analysis failed");
}
return response.json();
}
export async function completeVoiceOnboarding(
sessionId: string,
confirmed: boolean
): Promise<{
success: boolean;
status: string;
result?: PatientOnboardingResult;
message: string;
}> {
const response = await fetch(
`${API_BASE_URL}/v1/voice-onboarding/${sessionId}/complete`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmed }),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || "Failed to complete onboarding");
}
return response.json();
}
export function getAudioFullUrl(relativePath: string): string {
if (!relativePath) return "";
if (relativePath.startsWith("http://") || relativePath.startsWith("https://") || relativePath.startsWith("data:")) return relativePath;
const cleanPath = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
return `${API_BASE_URL}${cleanPath}`;
}
export const resolveAudioUrl = getAudioFullUrl;
+258
View File
@@ -0,0 +1,258 @@
// High-Definition Neural Speech Synthesizer with Authentic Local Indian Accent Support
let cachedVoices: SpeechSynthesisVoice[] = [];
let customSelectedVoiceURI: string | null = null;
let customRate: number = 0.94;
let customPitch: number = 1.0;
export function initVoiceEngine() {
if (typeof window !== "undefined" && window.speechSynthesis) {
cachedVoices = window.speechSynthesis.getVoices();
window.speechSynthesis.onvoiceschanged = () => {
cachedVoices = window.speechSynthesis.getVoices();
};
}
}
// Immediately warm up voices
initVoiceEngine();
// Indian voice priority keywords & specific local names
const INDIAN_VOICE_KEYWORDS = [
"neerja", "prabhat", "heera", "ravi", "swara", "madhur", "kalpana", "hemant",
"valluvar", "pallavi", "mohan", "gagan", "sapna", "rishi", "veena", "lekha",
"kavya", "vani", "meera", "shruti", "ananya", "india", "indian", "hindi",
"tamil", "telugu", "kannada", "bengali", "marathi", "gujarati"
];
export function getAvailableIndianVoices(languageCode?: string): SpeechSynthesisVoice[] {
if (typeof window === "undefined" || !window.speechSynthesis) return [];
const voices = cachedVoices.length > 0 ? cachedVoices : window.speechSynthesis.getVoices();
if (!voices || voices.length === 0) return [];
if (!languageCode) {
return voices.filter((v) => {
const vLang = v.lang.toLowerCase().replace("_", "-");
const vName = v.name.toLowerCase();
return (
vLang.includes("in") ||
INDIAN_VOICE_KEYWORDS.some((kw) => vName.includes(kw) || vLang.includes(kw))
);
});
}
const targetLang = languageCode.toLowerCase().replace("_", "-");
const targetPrefix = targetLang.split("-")[0];
const matched = voices.filter((v) => {
const vLang = v.lang.toLowerCase().replace("_", "-");
const vName = v.name.toLowerCase();
return (
vLang === targetLang ||
vLang.startsWith(targetPrefix) ||
(targetLang === "en-in" && (vLang.includes("en") || vName.includes("india")))
);
});
// Sort matched voices so Indian accents and natural neural voices appear first
return matched.sort((a, b) => {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
const aLang = a.lang.toLowerCase().replace("_", "-");
const bLang = b.lang.toLowerCase().replace("_", "-");
const aScore =
(aLang.includes("in") ? 50 : 0) +
(INDIAN_VOICE_KEYWORDS.some((k) => aName.includes(k)) ? 40 : 0) +
(aName.includes("natural") || aName.includes("online") ? 30 : 0) +
(aName.includes("neural") ? 20 : 0);
const bScore =
(bLang.includes("in") ? 50 : 0) +
(INDIAN_VOICE_KEYWORDS.some((k) => bName.includes(k)) ? 40 : 0) +
(bName.includes("natural") || bName.includes("online") ? 30 : 0) +
(bName.includes("neural") ? 20 : 0);
return bScore - aScore;
});
}
export function setSelectedVoiceURI(uri: string | null) {
customSelectedVoiceURI = uri;
}
export function getSelectedVoiceURI(): string | null {
return customSelectedVoiceURI;
}
export function setVoiceRate(rate: number) {
customRate = Math.max(0.6, Math.min(1.5, rate));
}
export function getVoiceRate(): number {
return customRate;
}
export function setVoicePitch(pitch: number) {
customPitch = Math.max(0.7, Math.min(1.4, pitch));
}
export function getVoicePitch(): number {
return customPitch;
}
export function getBestIndianVoice(languageCode: string): SpeechSynthesisVoice | null {
if (typeof window === "undefined" || !window.speechSynthesis) return null;
const voices = cachedVoices.length > 0 ? cachedVoices : window.speechSynthesis.getVoices();
if (!voices || voices.length === 0) return null;
// If user selected a custom voice URI, respect it
if (customSelectedVoiceURI) {
const chosen = voices.find((v) => v.voiceURI === customSelectedVoiceURI);
if (chosen) return chosen;
}
const targetLang = languageCode.toLowerCase().replace("_", "-");
const targetPrefix = targetLang.split("-")[0]; // "en", "ta", "hi", "te", "kn"
let bestVoice: SpeechSynthesisVoice | null = null;
let bestScore = -100;
for (const voice of voices) {
const vLang = voice.lang.toLowerCase().replace("_", "-");
const vName = voice.name.toLowerCase();
let score = 0;
const isExactLang = vLang === targetLang || (targetLang === "en-in" && (vLang === "en-in" || vLang.includes("en_in")));
const isPrefixLang = vLang.startsWith(targetPrefix);
if (isExactLang) {
score += 50;
} else if (isPrefixLang) {
score += 20;
} else if (targetLang === "en-in" && vLang.startsWith("en")) {
// English requested, check if voice has Indian name
score += 5;
} else {
continue; // Language doesn't match at all
}
// Check for natural / neural quality
if (vName.includes("natural") || vName.includes("online")) {
score += 30;
}
if (vName.includes("neural")) {
score += 25;
}
if (vName.includes("google")) {
score += 15;
}
// Check for Indian regional identifiers & names
const hasIndianKeyword = INDIAN_VOICE_KEYWORDS.some((kw) => vName.includes(kw) || vLang.includes("in"));
if (hasIndianKeyword) {
score += 40;
}
// If English (India) is requested, heavily prioritize Indian English voices
if (targetLang === "en-in") {
if (vLang.includes("en-in") || vLang.includes("en_in") || vName.includes("india") || vName.includes("neerja") || vName.includes("prabhat") || vName.includes("heera") || vName.includes("ravi")) {
score += 60;
}
// Penalize US/UK voices if an Indian voice can be found
if ((vLang.includes("en-us") || vLang.includes("en-gb")) && !hasIndianKeyword) {
score -= 40;
}
}
if (score > bestScore) {
bestScore = score;
bestVoice = voice;
}
}
return bestVoice;
}
// Clean and normalize text for smooth natural Indian pronunciation
export function normalizeTextForSpeech(text: string): string {
return text
.replace(/[()#*_\-–—]/g, " ")
.replace(/\bBP\b/gi, "Blood Pressure")
.replace(/\bDr\b\.?/gi, "Doctor")
.replace(/\bmg\b/gi, "milligrams")
.replace(/\bkg\b/gi, "kilograms")
.replace(/\bhrs\b/gi, "hours")
.replace(/\bmin\b/gi, "minutes")
.replace(/\s+/g, " ")
.trim();
}
export function playNeuralIndianSpeech(
text: string,
languageCode: string = "ta-IN",
onEnd?: () => void
): boolean {
if (typeof window === "undefined" || !window.speechSynthesis) {
return false;
}
// Cancel any active speech synthesis
window.speechSynthesis.cancel();
const cleanText = normalizeTextForSpeech(text);
if (!cleanText) return false;
const utterance = new SpeechSynthesisUtterance(cleanText);
utterance.lang = languageCode;
// Optimized rate & pitch for natural Indian conversational healthcare tone
utterance.rate = customRate || 0.94;
utterance.pitch = customPitch || 1.0;
// Select the best authentic local Indian voice
const preferredVoice = getBestIndianVoice(languageCode);
if (preferredVoice) {
utterance.voice = preferredVoice;
}
let finished = false;
const finishOnce = () => {
if (!finished) {
finished = true;
if (onEnd) onEnd();
}
};
utterance.onend = finishOnce;
utterance.onerror = (e) => {
console.warn("SpeechSynthesis error:", e);
finishOnce();
};
window.speechSynthesis.speak(utterance);
return true;
}
export function stopNeuralSpeech() {
if (typeof window !== "undefined" && window.speechSynthesis) {
window.speechSynthesis.cancel();
}
}
export function testIndianAccentSample(languageCode: string = "en-IN", onEnd?: () => void): boolean {
const samples: Record<string, string> = {
"en-IN": "Hello. Welcome to CureSelect Healthcare. How can I assist you with your health intake today?",
"ta-IN": "வணக்கம், CureSelect மருத்துவமனைக்கு நல்வரவு. இன்று தங்களுக்கு ஏற்பட்டுள்ள உடல்நல பாதிப்பு பற்றி கூற முடியுமா?",
"hi-IN": "नमस्ते, CureSelect अस्पताल में आपका स्वागत है। आज आप किस स्वास्थ्य समस्या के लिए परामर्श लेना चाहते हैं?",
"te-IN": "నమస్కారం, CureSelect హాస్పిటల్‌కు స్వాగతం. ఈరోజు మీకున్న ఆరోగ్య సమస్య గురించి చెప్పగలరా?",
"kn-IN": "ನಮಸ್ಕಾರ, CureSelect ಆಸ್ಪತ್ರೆಗೆ ಸುಸ್ವಾಗತ. ಇಂದು ನಿಮ್ಮ ಆರೋಗ್ಯದ ತೊಂದರೆ ಬಗ್ಗೆ ತಿಳಿಸಬಹುದೇ?",
};
const sample = samples[languageCode] || samples["en-IN"];
return playNeuralIndianSpeech(sample, languageCode, onEnd);
}