1 Commits
Author SHA1 Message Date
Gokulvarthan-Ramesh ff49d975af Initial commit: Add README.md 2026-08-29 15:39:50 +05:30
41 changed files with 0 additions and 9671 deletions
-1
View File
@@ -1 +0,0 @@
VITE_API_BASE_URL=https://services-api.a2zhealth.in/onboarding
-7
View File
@@ -1,7 +0,0 @@
# CureSelect AI Voice Onboarding Frontend Environment Configuration
# Production API Endpoint
VITE_API_BASE_URL=https://services-api.a2zhealth.in/onboarding
# Local Development Endpoint (Uncomment when running local Python FastAPI backend)
# VITE_API_BASE_URL=http://127.0.0.1:8000
-20
View File
@@ -1,20 +0,0 @@
# Dependencies
node_modules
.pnpm-store
# Production build outputs
dist
dist-ssr
*.local
# Environment variables
.env.local
.env.*.local
# IDE & OS files
.DS_Store
Thumbs.db
.vscode/
.idea/
*.suo
*.user
-541
View File
@@ -1,541 +0,0 @@
# AI Patient Onboarding — Backend API Documentation
This document provides a comprehensive guide for integrating with the **AI Patient Onboarding FastAPI Backend**. It covers all REST API endpoints, request/response models, voice integration flows, medical document processing, and EHR integration.
---
## Table of Contents
1. [Architecture & Server Overview](#1-architecture--server-overview)
2. [Environment Configuration (`.env`)](#2-environment-configuration-env)
3. [Running the Backend Locally](#3-running-the-backend-locally)
4. [Voice Onboarding API (`/v1/voice-onboarding`)](#4-voice-onboarding-api-v1voice-onboarding)
- [4.1 Start Voice Session](#41-start-voice-session)
- [4.2 Send Voice / Speech Turn](#42-send-voice--speech-turn)
- [4.3 Fetch Synthesized Voice Audio](#43-fetch-synthesized-voice-audio)
- [4.4 Complete & Confirm Session](#44-complete--confirm-session)
5. [Text Onboarding API (`/v1/onboarding`)](#5-text-onboarding-api-v1onboarding)
- [5.1 Start Text Session](#51-start-text-session)
- [5.2 Send Text Message](#52-send-text-message)
- [5.3 Complete Text Onboarding](#53-complete-text-onboarding)
6. [Medical Document & Vision API (`/v1/documents`)](#6-medical-document--vision-api-v1documents)
7. [System Health Check (`/health`)](#7-system-health-check-health)
8. [Connecting External Hospital EMR / EHR System](#8-connecting-external-hospital-emr--ehr-system)
9. [Multi-Language & Clinical Specialties](#9-multi-language--clinical-specialties)
10. [Client Integration Code Samples](#10-client-integration-code-samples)
---
## 1. Architecture & Server Overview
- **Framework:** FastAPI (Python 3.10+) with SQLAlchemy ORM
- **Default Base URL:** `http://127.0.0.1:8000`
- **Interactive Swagger Documentation:** `http://127.0.0.1:8000/docs`
- **OpenAPI JSON Spec:** `http://127.0.0.1:8000/openapi.json`
- **CORS:** Pre-configured to allow all origins (`*`) for easy React web and mobile client integration.
```
+------------------+ +-------------------------------+ +-----------------------+
| Client Device | | FastAPI Backend Engine | | Hospital EHR / Database|
+------------------+ +-------------------------------+ +-----------------------+
| | |
| --- POST /start ----------------> | (Initialize Session + State) |
| <-- Greeting Text & Audio URL --- | (Synthesized TTS Audio) |
| | |
| --- POST /turn (Audio / Text) --> | (Sarvam STT -> GPT-4o -> State) |
| <-- Next Question & Audio URL --- | |
| | |
| --- POST /upload (Prescription) > | (Gemini Vision OCR -> Extract Meds) |
| <-- Extracted Findings ---------- | |
| | |
| --- POST /complete (confirmed) -> | (Extract EHR JSON) ---------------->| POST /patients
| <-- Final Success Status -------- | <--- 200 OK Record Saved -----------|
```
---
## 2. Environment Configuration (`.env`)
Create or update the `.env` file in the `ai-patient-onboarding/` directory:
```ini
APP_NAME="AI Patient Onboarding"
APP_ENV=development
APP_VERSION=1.0.0
# Database URL
DATABASE_URL="sqlite:///./data/onboarding.db"
# AI Provider API Keys
OPENAI_API_KEY="sk-..."
OPENAI_MODEL="gpt-4o-mini"
ANTHROPIC_API_KEY="sk-ant-..."
ANTHROPIC_MODEL="claude-3-5-sonnet-20241022"
GEMINI_API_KEY="AIzaSy..."
GEMINI_MODEL="gemini-1.5-flash"
SARVAM_API_KEY="your_sarvam_key"
# External Destination Hospital EHR / Patient API (Optional)
PATIENT_API_BASE_URL="https://your-hospital-emr.com/api/v1"
PATIENT_API_TOKEN="your_secure_bearer_token"
```
---
## 3. Running the Backend Locally
```bash
# Navigate to the backend directory
cd ai-patient-onboarding
# Activate Python Virtual Environment
# Windows PowerShell:
.\venv\Scripts\Activate.ps1
# Linux / macOS:
# source venv/bin/activate
# Install dependencies if not already installed
pip install -r requirements.txt
# Start FastAPI development server
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
---
## 4. Voice Onboarding API (`/v1/voice-onboarding`)
### 4.1 Start Voice Session
Creates a new voice onboarding session, initializes dynamic state, and returns localized assistant greeting with audio.
- **URL:** `POST /v1/voice-onboarding/start`
- **Headers:** `Content-Type: application/json`
- **Request Body:** *(Note: `category` is optional and defaults to Auto-Triage General Medicine)*
```json
{
"patient": {
"id": "PAT-9842",
"name": "Arun Kumar",
"age": 42,
"gender": "male",
"abha_id": "91-1234-5678-9012",
"phone": "+91 98765 43210"
},
"category": {
"type": "general",
"code": "general_medicine",
"name": "General Medicine / Primary Care (Auto-Triage)"
},
"language": "ta-IN"
}
```
> 💡 **Automatic Specialty Auto-Triage**: If `category` is omitted, the API defaults to General Medicine. During the conversation, the AI automatically analyzes the patient's symptoms and assigns the exact specialty (`suggested_specialty`) in real-time.
- **Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-78A1BF2C",
"patient_id": "PAT-9842",
"category": {
"type": "speciality",
"code": "cardio",
"name": "Cardiology"
},
"language": "en-IN",
"status": "in_progress",
"stage": "greeting",
"greeting_text": "Hello Arun Kumar, welcome to CureSelect Cardiology department. What symptoms or discomfort are you experiencing today?",
"audio_url": "/v1/voice-onboarding/audio/voice_greeting_89bf1a.wav",
"response_time_ms": 166.84,
"response_time_sec": 0.167
}
```
---
### 4.2 Send Voice / Speech Turn
Submits the patient's recorded audio or transcribed speech. The backend runs multi-model Indic speech recognition (Sarvam/Whisper), extracts medical entities, updates conversational state, and generates the next question with audio TTS.
- **URL:** `POST /v1/voice-onboarding/{session_id}/turn`
- **Headers:** `Content-Type: multipart/form-data`
- **Form Data Parameters:**
- `audio` *(Optional File)*: WebM, WAV, or MP3 recorded audio file from the user's microphone.
- `text_fallback` *(Optional String)*: Real-time browser speech recognition transcript fallback.
- **Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-78A1BF2C",
"patient_transcript": "I have had severe chest pain and breathlessness since yesterday evening.",
"ai_text": "I understand. Does the chest pain radiate to your left arm or jaw, and are you currently taking any blood pressure or heart medications?",
"stage": "symptoms_inquiry",
"completed": false,
"audio_url": "/v1/voice-onboarding/audio/speech_reply_30ef2.wav",
"suggested_specialty": {
"type": "specialist",
"code": "cardiology",
"name": "Cardiology / Heart Care"
},
"response_time_ms": 1824.12,
"response_time_sec": 1.824
}
```
---
### 4.3 Fetch Synthesized Voice Audio
Streams audio file generated for assistant responses.
- **URL:** `GET /v1/voice-onboarding/audio/{audio_id}`
- **Response:** `audio/wav` or `audio/mpeg` binary stream.
---
### 4.4 Complete & Confirm Session
Extracts complete structured clinical results. Supports a two-step confirmation workflow (review preview -> permanent submission).
- **URL:** `POST /v1/voice-onboarding/{session_id}/complete`
- **Headers:** `Content-Type: application/json`
#### Step A: Request Review Preview (`confirmed: false`)
```json
{
"confirmed": false
}
```
**Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-78A1BF2C",
"status": "awaiting_confirmation",
"result": {
"patient_id": "PAT-9842",
"abha_id": "91-1234-5678-9012",
"phone": "+91 98765 43210",
"insurance_scheme": "PM-JAY",
"category": {
"type": "speciality",
"code": "cardio",
"name": "Cardiology"
},
"chief_complaint": "Acute onset retrosternal chest pain and dyspnea",
"symptoms": [
{
"name": "Chest tightness",
"duration": "1 day",
"severity": "Moderate to Severe",
"description": "Retrosternal pressure aggravated by exertion"
}
],
"medical_history": ["Hypertension (5 years)"],
"medications": ["Amlodipine 5mg OD"],
"allergies": ["Penicillin"],
"dietary_habits": "Vegetarian",
"additional_information": "Non-smoker",
"language": "en-IN",
"onboarding_completed": true
},
"message": "Please review the collected information and confirm by voice or tap."
}
```
#### Step B: Confirm & Submit to EHR (`confirmed: true`)
```json
{
"confirmed": true
}
```
**Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-78A1BF2C",
"status": "submitted_and_cleaned",
"result": { ... },
"message": "Voice onboarding successfully saved to patient record."
}
```
---
## 5. Text Onboarding API (`/v1/onboarding`)
For text chat interfaces or non-voice kiosks.
### 5.1 Start Text Session
- **URL:** `POST /v1/onboarding/start`
- **Body:** Same as Voice Start (`patient`, `category`, `language`).
- **Response:** Returns `session_id`, `greeting_message`, and initial state.
### 5.2 Send Text Message
- **URL:** `POST /v1/onboarding/{session_id}/message`
- **Body:**
```json
{
"message": "I've had knee pain for 3 days after a run."
}
```
- **Response:**
```json
{
"success": true,
"session_id": "ONB-84A12B1C",
"stage": "symptoms_inquiry",
"message": "Is the knee swollen or locking when you walk?",
"status": "in_progress"
}
```
### 5.3 Complete Text Onboarding
- **URL:** `POST /v1/onboarding/{session_id}/complete`
- **Body:** `{"confirmed": true}`
---
## 6. Medical Document & Vision API (`/v1/documents`)
Uploads patient medical documents (prescriptions, discharge summaries, lab reports) to extract medications, allergies, and diagnoses using Gemini 1.5 Flash Vision, automatically injecting findings into the active session state.
- **URL:** `POST /v1/documents/{session_id}/upload`
- **Headers:** `Content-Type: multipart/form-data`
- **Form Field:** `file` *(Binary image or PDF file)*
- **Response (`200 OK`):**
```json
{
"success": true,
"session_id": "VOICE-78A1BF2C",
"document_type": "prescription",
"summary": "Prescription from Cardiologist dated 15-Aug-2024",
"findings": ["Ejection Fraction 55%", "Normal Sinus Rhythm"],
"medications_found": ["Atorvastatin 20mg", "Metoprolol 25mg"],
"allergies_found": ["Sulfa drugs"],
"message": "Medical document analyzed and integrated into onboarding session."
}
```
---
## 7. System Health Check (`/health`)
- **URL:** `GET /health`
- **Response (`200 OK`):**
```json
{
"success": true,
"service": "AI Patient Onboarding",
"status": "healthy",
"models": {
"speech": "Sarvam AI (Indian Languages) / OpenAI Whisper",
"conversation": "OpenAI GPT-4o-mini",
"validation": "Claude 3.5 Sonnet",
"vision_documents": "Gemini 1.5 Flash"
}
}
```
---
## 8. Connecting External Hospital EMR / EHR System
When onboarding is finalized, the backend invokes `PatientSubmissionService` located at:
`app/services/patient_submission_service.py`
### Customizing the Destination Forwarding:
```python
import httpx
from app.config import PATIENT_API_BASE_URL, PATIENT_API_TOKEN
class PatientSubmissionService:
async def submit(self, result: dict) -> dict:
if not PATIENT_API_BASE_URL:
# Local fallback mock
return {
"success": True,
"message": "Patient data saved locally.",
"patient_id": result.get("patient_id"),
}
# Forward JSON payload to hospital central server
async with httpx.AsyncClient(timeout=15.0) as client:
headers = {
"Authorization": f"Bearer {PATIENT_API_TOKEN}",
"Content-Type": "application/json",
}
response = await client.post(
f"{PATIENT_API_BASE_URL}/patients/{result.get('patient_id')}/onboarding",
json=result,
headers=headers,
)
if response.status_code in [200, 201]:
return {"success": True, "data": response.json()}
return {
"success": False,
"error": f"Hospital API error: {response.text}",
}
```
---
## 9. Multi-Language & Clinical Specialties
### Supported Languages
| Code | Language | Speech Engine |
|---|---|---|
| `en-IN` / `en` | English (Indian accent) | Sarvam / OpenAI Whisper |
| `hi-IN` / `hi` | Hindi (हिन्दी) | Sarvam Bulbul / Whisper |
| `ta-IN` / `ta` | Tamil (தமிழ்) | Sarvam Bulbul / Whisper |
| `te-IN` / `te` | Telugu (తెలుగు) | Sarvam Bulbul / Whisper |
| `kn-IN` / `kn` | Kannada (ಕನ್ನಡ) | Sarvam Bulbul / Whisper |
| `bn-IN` / `bn` | Bengali (বাংলা) | Sarvam Bulbul / Whisper |
| `ml-IN` / `ml` | Malayalam (മലയാളം) | Sarvam Bulbul / Whisper |
| `mr-IN` / `mr` | Marathi (मराठी) | Sarvam Bulbul / Whisper |
| `gu-IN` / `gu` | Gujarati (ગુજરાતી) | Sarvam Bulbul / Whisper |
### Clinical Specialties Supported
- `general_medicine` - General Medicine / Primary Care
- `cardio` - Cardiology
- `ortho` - Orthopedics
- `peds` - Pediatrics
- `derma` - Dermatology
- `neuro` - Neurology
- `gastro` - Gastroenterology
- `ent` - ENT / Otorhinolaryngology
- `gyn` - Obstetrics & Gynecology
---
## 10. Client Integration Code Samples
### 10.1 Python Client (`httpx`)
```python
import httpx
import asyncio
API_BASE = "http://127.0.0.1:8000"
async def main():
async with httpx.AsyncClient() as client:
# 1. Start Session
start_res = await client.post(f"{API_BASE}/v1/voice-onboarding/start", json={
"patient": {"id": "P-1001", "name": "Deepak Sharma", "age": 45, "gender": "male"},
"category": {"type": "speciality", "code": "cardio", "name": "Cardiology"},
"language": "hi-IN"
})
session = start_res.json()
session_id = session["session_id"]
print(f"Session started: {session_id}")
print(f"Greeting: {session['greeting_text']}")
# 2. Send Turn
turn_res = await client.post(
f"{API_BASE}/v1/voice-onboarding/{session_id}/turn",
data={"text_fallback": "मुझे सीने में भारीपन महसूस हो रहा है"}
)
turn_data = turn_res.json()
print(f"AI Response: {turn_data['ai_text']}")
# 3. Finalize & Submit
complete_res = await client.post(
f"{API_BASE}/v1/voice-onboarding/{session_id}/complete",
json={"confirmed": True}
)
print(f"EHR Saved: {complete_res.json()}")
if __name__ == "__main__":
asyncio.run(main())
```
### 10.2 JavaScript / TypeScript (`fetch`)
```typescript
const API_BASE = "http://127.0.0.1:8000";
// Start voice onboarding
export async function startVoiceOnboarding(patient: any, category: any, language = "en-IN") {
const res = await fetch(`${API_BASE}/v1/voice-onboarding/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ patient, category, language })
});
return res.json();
}
// Send audio turn
export async function sendVoiceTurn(sessionId: string, audioBlob?: Blob, textFallback?: string) {
const formData = new FormData();
if (audioBlob) formData.append("audio", audioBlob, "speech.webm");
if (textFallback) formData.append("text_fallback", textFallback);
const res = await fetch(`${API_BASE}/v1/voice-onboarding/${sessionId}/turn`, {
method: "POST",
body: formData
});
return res.json();
}
// Upload medical report
export async function uploadPrescription(sessionId: string, file: File) {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${API_BASE}/v1/documents/${sessionId}/upload`, {
method: "POST",
body: formData
});
return res.json();
}
// Complete onboarding
export async function completeOnboarding(sessionId: string, confirmed: boolean) {
const res = await fetch(`${API_BASE}/v1/voice-onboarding/${sessionId}/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmed })
});
return res.json();
}
```
### 10.3 cURL Commands
#### Start Session:
```bash
curl -X POST "http://127.0.0.1:8000/v1/voice-onboarding/start" \
-H "Content-Type: application/json" \
-d '{
"patient": {"id": "P-101", "name": "Arun Kumar", "age": 40, "gender": "male"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"},
"language": "en-IN"
}'
```
#### Send Voice Turn (with text fallback):
```bash
curl -X POST "http://127.0.0.1:8000/v1/voice-onboarding/VOICE-XXXXX/turn" \
-F "text_fallback=I have had high fever and sore throat since yesterday"
```
#### Upload Prescription:
```bash
curl -X POST "http://127.0.0.1:8000/v1/documents/VOICE-XXXXX/upload" \
-F "file=@/path/to/prescription.pdf"
```
#### Confirm & Submit:
```bash
curl -X POST "http://127.0.0.1:8000/v1/voice-onboarding/VOICE-XXXXX/complete" \
-H "Content-Type: application/json" \
-d '{"confirmed": true}'
```
-18
View File
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Patient Onboarding Portal | CureSelect Healthcare</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400..800;1,9..40,400..800&family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700;800;900&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Sora:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body class="bg-gradient-to-br from-slate-50 via-sky-50 to-indigo-50 text-slate-900 font-sans antialiased min-h-screen selection:bg-cyan-500 selection:text-white">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-20
View File
@@ -1,20 +0,0 @@
[build]
publish = "dist"
command = "npm run build"
[[redirects]]
from = "/v1/*"
to = "https://services-api.a2zhealth.in/onboarding/v1/:splat"
status = 200
force = true
[[redirects]]
from = "/health"
to = "https://services-api.a2zhealth.in/onboarding/health"
status = 200
force = true
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
-2680
View File
File diff suppressed because it is too large Load Diff
-25
View File
@@ -1,25 +0,0 @@
{
"name": "ai-patient-onboarding-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node node_modules/vite/bin/vite.js --host --port 5173",
"build": "node node_modules/vite/bin/vite.js build",
"preview": "node node_modules/vite/bin/vite.js preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.6"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-3
View File
@@ -1,3 +0,0 @@
/v1/* https://services-api.a2zhealth.in/onboarding/v1/:splat 200!
/health https://services-api.a2zhealth.in/onboarding/health 200!
/* /index.html 200
-258
View File
@@ -1,258 +0,0 @@
import React, { useState, useEffect } from 'react';
import Header from './components/Header';
import PatientSetupForm from './components/PatientSetupForm';
import VoiceChat from './components/VoiceChat';
import DocumentUploader from './components/DocumentUploader';
import SummaryModal from './components/SummaryModal';
import {
checkHealth,
startVoiceSession,
startTextSession,
sendVoiceTurn,
sendTextMessage,
} from './services/api';
export default function App() {
const [healthStatus, setHealthStatus] = useState(null);
const [activeSession, setActiveSession] = useState(null);
const [isVoiceMode, setIsVoiceMode] = useState(true);
const [isLoadingSession, setIsLoadingSession] = useState(false);
const [isProcessingTurn, setIsProcessingTurn] = useState(false);
// Modals visibility
const [showDocumentUploader, setShowDocumentUploader] = useState(false);
const [showSummaryModal, setShowSummaryModal] = useState(false);
const [uploadedDocuments, setUploadedDocuments] = useState([]);
// Check Backend Health Status on Mount and periodically
useEffect(() => {
async function pingHealth() {
const health = await checkHealth();
setHealthStatus(health);
}
pingHealth();
const interval = setInterval(pingHealth, 15000);
return () => clearInterval(interval);
}, []);
// Ingest Patient Data from URL Search Params or Parent postMessage (WebView Bridge)
useEffect(() => {
if (typeof window === 'undefined') return;
// Method 1: Check URL Query Parameters (e.g. ?first_name=Ramesh&phone=9876543210&autostart=true)
const urlParams = new URLSearchParams(window.location.search);
const autostart = urlParams.get('autostart') === 'true' || urlParams.get('autoStart') === 'true';
const firstName = urlParams.get('first_name') || urlParams.get('firstName') || urlParams.get('name');
if (autostart && firstName) {
const patient = {
first_name: firstName,
last_name: urlParams.get('last_name') || urlParams.get('lastName') || '',
age: urlParams.get('age') || '30',
gender: urlParams.get('gender') || 'Male',
phone: urlParams.get('phone') || '',
email: urlParams.get('email') || '',
};
const category = {
type: 'general',
code: urlParams.get('category') || 'general_medicine',
name: urlParams.get('category_name') || 'General Medicine / Primary Care',
};
const language = urlParams.get('language') || urlParams.get('lang') || 'ta-IN';
const mode = urlParams.get('mode') || 'voice';
handleStartSession({ patient, category, language, mode });
}
// Method 2: Listen for postMessage from Parent App (Web iFrame / WebView)
const handleParentBridgeMessage = (event) => {
if (!event.data) return;
let payload = event.data;
if (typeof payload === 'string') {
try { payload = JSON.parse(payload); } catch (_) { return; }
}
if (payload.type === 'INIT_PATIENT_ONBOARDING' && payload.data) {
const { patient, category, language, mode } = payload.data;
if (patient) {
handleStartSession({
patient,
category: category || { type: 'general', code: 'general_medicine', name: 'General Medicine / Primary Care' },
language: language || 'ta-IN',
mode: mode || 'voice',
});
}
}
};
window.addEventListener('message', handleParentBridgeMessage);
return () => window.removeEventListener('message', handleParentBridgeMessage);
}, []);
// Handle Session Initialization (Start Voice / Text Session)
const handleStartSession = async ({ patient, category, language, mode }) => {
// Prime audio context synchronously inside user click gesture event
if (typeof window !== 'undefined') {
try {
const silentAudio = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=");
silentAudio.play().then(() => silentAudio.pause()).catch(() => {});
} catch (_) {}
}
setIsLoadingSession(true);
const useVoice = mode ? mode === 'voice' : true;
setIsVoiceMode(useVoice);
try {
let sessionRes;
if (useVoice) {
sessionRes = await startVoiceSession({ patient, category, language });
} else {
sessionRes = await startTextSession({ patient, category, language });
}
setActiveSession({
...sessionRes,
patient,
category: sessionRes?.category || category,
language: sessionRes?.language || language,
greeting_text: sessionRes?.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?`,
});
} catch (err) {
console.error('Failed to start session:', err);
// Fallback mock session if backend server is unreachable
setActiveSession({
success: true,
session_id: `VOICE-${Math.random().toString(36).substring(2, 9).toUpperCase()}`,
patient_id: patient.id,
patient,
category,
language,
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?`,
status: 'in_progress',
stage: 'greeting',
completed: false,
});
} finally {
setIsLoadingSession(false);
}
};
// Handle Turn Submission (Voice Blob or Text)
const handleSendTurn = async ({ audioBlob, textFallback }) => {
if (!activeSession) return null;
setIsProcessingTurn(true);
try {
let turnRes;
if (isVoiceMode) {
turnRes = await sendVoiceTurn(activeSession.session_id, audioBlob, textFallback);
} else {
turnRes = await sendTextMessage(activeSession.session_id, textFallback);
}
if (turnRes) {
setActiveSession((prev) => ({
...prev,
stage: turnRes.stage || prev.stage,
completed: Boolean(turnRes.completed || turnRes.stage === 'completed' || turnRes.stage === 'finished'),
}));
}
return turnRes;
} catch (err) {
console.error('Error submitting turn:', err);
// Fallback local mock response for seamless UI demo if network error
return {
success: true,
session_id: activeSession.session_id,
patient_transcript: textFallback || 'Audio turn recorded',
ai_text: 'Thank you for providing that detail. Do you currently experience any shortness of breath or radiating pain?',
stage: 'symptoms_inquiry',
completed: false,
audio_url: null,
};
} finally {
setIsProcessingTurn(false);
}
};
// Document Upload Callback
const handleDocumentUploaded = (docResult) => {
setUploadedDocuments((prev) => [...prev, docResult]);
};
// Reset Session
const handleResetSession = () => {
if (window.confirm('Are you sure you want to end this session and start a new patient onboarding?')) {
setActiveSession(null);
setUploadedDocuments([]);
setShowDocumentUploader(false);
setShowSummaryModal(false);
}
};
return (
<div className="min-h-screen flex flex-col justify-between bg-gradient-to-br from-purple-50/50 via-slate-50 to-indigo-50/50 text-slate-900 selection:bg-purple-500 selection:text-white overflow-x-hidden">
{/* Header Bar */}
<Header
healthStatus={healthStatus}
activeSession={activeSession}
onResetSession={handleResetSession}
/>
{/* Main Content Area */}
<main className="flex-1 flex flex-col justify-center py-2 sm:py-4">
{!activeSession ? (
<PatientSetupForm
onStartSession={handleStartSession}
isLoading={isLoadingSession}
/>
) : (
<VoiceChat
session={activeSession}
isVoiceMode={isVoiceMode}
onSendTurn={handleSendTurn}
onOpenDocumentUploader={() => setShowDocumentUploader(true)}
onCompleteSession={() => setShowSummaryModal(true)}
isProcessingTurn={isProcessingTurn}
documentsCount={uploadedDocuments.length}
/>
)}
</main>
{/* Prescription & Medical Document Uploader Modal */}
{showDocumentUploader && activeSession && (
<DocumentUploader
sessionId={activeSession.session_id}
onClose={() => setShowDocumentUploader(false)}
onDocumentUploaded={handleDocumentUploaded}
/>
)}
{/* Clinical Summary & EHR 2-Step Confirmation Modal */}
{showSummaryModal && activeSession && (
<SummaryModal
sessionId={activeSession.session_id}
isVoiceMode={isVoiceMode}
onClose={() => setShowSummaryModal(false)}
onSubmittedSuccessfully={() => {
// Keep modal open on success view
}}
/>
)}
{/* Footer */}
<footer className="py-2.5 px-4 border-t border-slate-200/80 text-center text-[11px] sm:text-xs text-slate-500 font-medium bg-white/70 backdrop-blur-md shrink-0">
CureSelect Healthcare AI Engine
</footer>
</div>
);
}
-648
View File
@@ -1,648 +0,0 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import {
FileText,
Keyboard,
Send,
Sparkles,
RotateCcw,
VolumeX,
Volume2,
Settings,
} from "lucide-react";
import { Header } from "./components/Header";
import { StepProgressBar } from "./components/StepProgressBar";
import { LanguageStep } from "./components/LanguageStep";
import { SpecialtyStep } from "./components/SpecialtyStep";
import { VoiceOrb } from "./components/VoiceOrb";
import { AudioWaveform } from "./components/AudioWaveform";
import { LiveTranscript } from "./components/LiveTranscript";
import { DocumentUploadModal } from "./components/DocumentUploadModal";
import { ConfirmationModal } from "./components/ConfirmationModal";
import { VoiceSettingsModal } from "./components/VoiceSettingsModal";
import { useAudioRecorder } from "./hooks/useAudioRecorder";
import {
startVoiceOnboarding,
sendVoiceTurn,
completeVoiceOnboarding,
getAudioFullUrl,
} from "./services/api";
import { playNeuralIndianSpeech, stopNeuralSpeech } from "./services/voiceSynthesis";
import {
CategoryInfo,
MessageItem,
PatientInfo,
VoiceState,
} from "./types/voice";
import { PatientOnboardingResult } from "./types/onboarding";
const DEFAULT_PATIENT: PatientInfo = {
id: "PAT-10023",
name: "Ravi Kumar",
age: 35,
gender: "male",
abha_id: "91-5642-1289-4091",
phone: "+91 98401 23456",
insurance_scheme: "PM-JAY / Ayushman Bharat",
dietary_habits: "South Indian Vegetarian",
};
const DEFAULT_CATEGORY: CategoryInfo = {
type: "speciality",
code: "cardiology",
name: "Cardiology (Heart & BP)",
};
export function App() {
// Wizard Step: 1 = Language, 2 = Specialty, 3 = Onboarding
const [currentStep, setCurrentStep] = useState<number>(1);
// Intake State
const [patient, setPatient] = useState<PatientInfo>(DEFAULT_PATIENT);
const [category, setCategory] = useState<CategoryInfo>(DEFAULT_CATEGORY);
const [language, setLanguage] = useState<string>("ta-IN");
const [sessionId, setSessionId] = useState<string | null>(null);
const [voiceState, setVoiceState] = useState<VoiceState>("idle");
const [currentStage, setCurrentStage] = useState<string>("greeting");
const [messages, setMessages] = useState<MessageItem[]>([]);
const [isDocModalOpen, setIsDocModalOpen] = useState(false);
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const [isVoiceSettingsOpen, setIsVoiceSettingsOpen] = useState(false);
const [onboardingResult, setOnboardingResult] = useState<PatientOnboardingResult | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [showKeyboardFallback, setShowKeyboardFallback] = useState(false);
const [textInput, setTextInput] = useState("");
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
const sessionIdRef = useRef<string | null>(null);
const setSessionIdWithRef = (id: string | null) => {
sessionIdRef.current = id;
setSessionId(id);
};
const recorderRef = useRef<{ startRecording: () => void; stopRecording: () => void } | null>(null);
// Automated Hands-Free Speech Playback & Auto-Listen Loop
const playAudioResponse = useCallback((
audioUrl: string,
speechTextOrAutoListen?: string | boolean,
autoListenParam: boolean = true
) => {
const speechText = typeof speechTextOrAutoListen === "string" ? speechTextOrAutoListen : undefined;
const autoListenOnEnd = typeof speechTextOrAutoListen === "boolean" ? speechTextOrAutoListen : autoListenParam;
if (currentAudioRef.current) {
currentAudioRef.current.pause();
}
stopNeuralSpeech();
if (isMuted) {
if (autoListenOnEnd) {
setVoiceState("listening");
setTimeout(() => {
if (recorderRef.current) recorderRef.current.startRecording();
}, 300);
}
return;
}
setVoiceState("ai_speaking");
const onSpeechFinished = () => {
if (autoListenOnEnd) {
setVoiceState("listening");
setTimeout(() => {
if (recorderRef.current) {
recorderRef.current.startRecording();
}
}, 200);
} else {
setVoiceState("idle");
}
};
// 1. Play Backend High-Fidelity Audio Stream (Sarvam AI Native Indian Accent) if available
if (audioUrl) {
const fullUrl = getAudioFullUrl(audioUrl);
console.log(`[Audio] Attempting to play backend audio stream: ${fullUrl}`);
const audio = new Audio(fullUrl);
currentAudioRef.current = audio;
audio.onended = () => {
console.log(`[Audio SUCCESS] Audio stream finished playing.`);
onSpeechFinished();
};
audio.onerror = (e) => {
console.error(`[Audio ERROR] Failed to load audio stream from ${fullUrl}:`, e);
if (speechText) {
console.log(`[Audio Fallback] Triggering Web Speech API fallback...`);
const spoke = playNeuralIndianSpeech(speechText, language, onSpeechFinished);
if (spoke) return;
}
onSpeechFinished();
};
audio.play().then(() => {
console.log(`[Audio SUCCESS] Audio stream started playing! URL: ${fullUrl}`);
}).catch((err) => {
console.warn(`[Audio BLOCKED/FAILED] play() failed (Autoplay/CORS). URL: ${fullUrl}`, err);
if (speechText) {
console.log(`[Audio Fallback] Triggering Web Speech API fallback...`);
const spoke = playNeuralIndianSpeech(speechText, language, onSpeechFinished);
if (spoke) return;
}
onSpeechFinished();
});
return;
}
// 2. Direct browser Web Speech if no audio URL provided
if (speechText) {
const spoke = playNeuralIndianSpeech(speechText, language, onSpeechFinished);
if (spoke) return;
}
onSpeechFinished();
}, [isMuted, language]);
// Audio Recorder Hook (Handles Continuous Hands-Free VAD + Live Transcript)
const handleAudioCaptured = useCallback(async (audioBlob: Blob, textTranscript?: string) => {
const activeSessionId = sessionIdRef.current || sessionId;
if (!activeSessionId) {
console.warn("No active session ID for voice turn");
return;
}
const startTime = performance.now();
try {
setVoiceState("processing");
const turnRes = await sendVoiceTurn(activeSessionId, audioBlob, textTranscript);
const responseTimeMs = Math.round(performance.now() - startTime);
const hasSpeech = Boolean(turnRes.patient_transcript && turnRes.patient_transcript.trim());
// Add AI response message with latency metric
const aiMsg: MessageItem = {
id: `ai-${Date.now() + 1}`,
role: "assistant",
text: turnRes.ai_text,
timestamp: new Date().toLocaleTimeString(),
audioUrl: turnRes.audio_url,
latencyMs: responseTimeMs,
};
if (hasSpeech) {
// Add patient transcribed message ONLY if patient actually spoke something
const patientMsg: MessageItem = {
id: `p-${Date.now()}`,
role: "patient",
text: turnRes.patient_transcript,
timestamp: new Date().toLocaleTimeString(),
};
setMessages((prev) => [...prev, patientMsg, aiMsg]);
} else {
setMessages((prev) => [...prev, aiMsg]);
}
setCurrentStage(turnRes.stage);
if (turnRes.completed) {
handleReviewCompletion();
} else {
// Only auto-listen if speech was actually detected, avoiding infinite silent loops
playAudioResponse(turnRes.audio_url, turnRes.ai_text, hasSpeech);
}
} catch (err) {
console.error("Voice turn processing error:", err);
setVoiceState("error");
}
}, [sessionId, playAudioResponse]);
const recorder = useAudioRecorder({
language: language,
onAudioCaptured: handleAudioCaptured,
});
useEffect(() => {
recorderRef.current = recorder;
}, [recorder]);
// Pre-unlock browser autoplay policy synchronously inside user click gestures
const primeAudioContext = () => {
try {
const silentAudio = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=");
silentAudio.play().then(() => {
silentAudio.pause();
}).catch(() => {});
} catch (_) {}
};
// Start Onboarding Session
const handleStartSession = async (chosenCategory?: CategoryInfo, chosenLanguage?: string) => {
primeAudioContext();
const cat = chosenCategory || category;
const lang = chosenLanguage || language;
const startTime = performance.now();
try {
setVoiceState("starting");
const data = await startVoiceOnboarding(patient, cat, lang);
const responseTimeMs = Math.round(performance.now() - startTime);
setSessionIdWithRef(data.session_id);
setCurrentStage("greeting");
const greetingMsg: MessageItem = {
id: `greet-${Date.now()}`,
role: "assistant",
text: data.greeting_text,
timestamp: new Date().toLocaleTimeString(),
audioUrl: data.audio_url,
latencyMs: responseTimeMs,
};
setMessages([greetingMsg]);
playAudioResponse(data.audio_url, data.greeting_text, true);
} catch (err) {
console.error("Failed to start voice session:", err);
setVoiceState("error");
}
};
// Main Orb Click Handler (Only needed if user manually wants to toggle/interrupt)
const handleOrbClick = () => {
primeAudioContext();
if (voiceState === "idle" || voiceState === "error") {
if (!sessionId) {
handleStartSession();
} else {
setVoiceState("listening");
if (recorderRef.current) recorderRef.current.startRecording();
}
} else if (voiceState === "listening") {
// Manual commit early if desired
if (recorderRef.current) recorderRef.current.stopRecording();
} else if (voiceState === "ai_speaking") {
// User interrupts AI speech to speak immediately
if (currentAudioRef.current) {
currentAudioRef.current.pause();
}
setVoiceState("listening");
if (recorderRef.current) recorderRef.current.startRecording();
} else if (voiceState === "confirmation") {
setIsConfirmModalOpen(true);
}
};
// Text Fallback Submit
const handleTextSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!textInput.trim() || !sessionId) return;
const userText = textInput.trim();
setTextInput("");
const startTime = performance.now();
try {
setVoiceState("processing");
const turnRes = await sendVoiceTurn(sessionId, undefined, userText);
const responseTimeMs = Math.round(performance.now() - startTime);
const patientMsg: MessageItem = {
id: `p-${Date.now()}`,
role: "patient",
text: userText,
timestamp: new Date().toLocaleTimeString(),
};
const aiMsg: MessageItem = {
id: `ai-${Date.now() + 1}`,
role: "assistant",
text: turnRes.ai_text,
timestamp: new Date().toLocaleTimeString(),
audioUrl: turnRes.audio_url,
latencyMs: responseTimeMs,
};
setMessages((prev) => [...prev, patientMsg, aiMsg]);
setCurrentStage(turnRes.stage);
if (turnRes.completed) {
handleReviewCompletion();
} else if (turnRes.audio_url) {
playAudioResponse(turnRes.audio_url, true);
} else {
setVoiceState("idle");
}
} catch (err) {
console.error("Text fallback submission failed:", err);
setVoiceState("error");
}
};
// Review Completion Summary
const handleReviewCompletion = async () => {
if (!sessionId) return;
try {
setVoiceState("processing");
const res = await completeVoiceOnboarding(sessionId, false);
if (res.result) {
setOnboardingResult(res.result);
setIsConfirmModalOpen(true);
setVoiceState("confirmation");
}
} catch (err) {
console.error("Failed to load completion review:", err);
setVoiceState("error");
}
};
// Final Confirmation & Submission to Hospital EHR
const handleFinalConfirm = async () => {
if (!sessionId) return;
try {
setIsSubmitting(true);
const res = await completeVoiceOnboarding(sessionId, true);
if (res.success) {
setIsConfirmModalOpen(false);
setVoiceState("completed");
const sysMsg: MessageItem = {
id: `sys-${Date.now()}`,
role: "system",
text: "✅ Onboarding successfully saved to Hospital EHR record.",
timestamp: new Date().toLocaleTimeString(),
};
setMessages((prev) => [...prev, sysMsg]);
}
} catch (err) {
console.error("Final submission failed:", err);
alert("Submission error. Temporary session retained for retry.");
} finally {
setIsSubmitting(false);
}
};
// Reset Session
const handleReset = () => {
if (currentAudioRef.current) {
currentAudioRef.current.pause();
}
if (recorderRef.current) {
recorderRef.current.stopRecording();
}
setSessionIdWithRef(null);
setVoiceState("idle");
setMessages([]);
setOnboardingResult(null);
setCurrentStage("greeting");
};
return (
<div style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}>
{/* Navigation Header */}
<Header
patient={patient}
category={category}
language={language}
onLanguageChange={setLanguage}
onCategoryChange={setCategory}
voiceState={voiceState}
onOpenVoiceSettings={() => setIsVoiceSettingsOpen(true)}
/>
{/* Main Wizard Container */}
<div style={{ flex: 1, maxWidth: "1280px", width: "100%", margin: "0 auto", padding: "24px 28px" }}>
{/* Step Progress Bar */}
<StepProgressBar
currentStep={currentStep}
onStepClick={(step) => {
if (voiceState === "idle" || voiceState === "completed" || voiceState === "error") {
setCurrentStep(step);
}
}}
/>
{/* STEP 1: Language Selection */}
{currentStep === 1 && (
<LanguageStep
selectedLanguage={language}
onSelectLanguage={setLanguage}
onNext={() => setCurrentStep(2)}
/>
)}
{/* STEP 2: Specialty Selection */}
{currentStep === 2 && (
<SpecialtyStep
selectedSpecialty={category}
patient={patient}
onSelectSpecialty={setCategory}
onUpdatePatient={setPatient}
onBack={() => setCurrentStep(1)}
onNext={(cat) => {
setCurrentStep(3);
handleStartSession(cat);
}}
/>
)}
{/* STEP 3: Voice Onboarding Intake */}
{currentStep === 3 && (
<main style={{
display: "grid",
gridTemplateColumns: "1.1fr 0.9fr",
gap: "28px",
alignItems: "start",
}}>
{/* Left Column: Voice Orb & Interactive Controls */}
<section className="glass-panel" style={{
padding: "32px 28px",
display: "flex",
flexDirection: "column",
alignItems: "center",
minHeight: "560px",
justifyContent: "space-between",
position: "relative",
}}>
{/* Top Specialty & Language Status Badge */}
<div style={{ display: "flex", justifyContent: "space-between", width: "100%", alignItems: "center" }}>
<span className="badge-specialty">
{category.name} • {language === "ta-IN" ? "தமிழ்" : language === "hi-IN" ? "हिन्दी" : language === "te-IN" ? "తెలుగు" : language === "kn-IN" ? "ಕನ್ನಡ" : "English"}
</span>
<div style={{ display: "flex", gap: "8px" }}>
<button
onClick={() => {
handleReset();
setCurrentStep(1);
}}
className="btn-secondary"
style={{ padding: "6px 12px", fontSize: "12px" }}
title="Change Language & Specialty"
>
<Settings size={14} /> Change Config
</button>
<button
onClick={() => setIsMuted(!isMuted)}
className="btn-secondary"
style={{ padding: "6px 12px", fontSize: "12px" }}
title={isMuted ? "Unmute AI Voice" : "Mute AI Voice"}
>
{isMuted ? <VolumeX size={14} color="#f87171" /> : <Volume2 size={14} color="#38bdf8" />}
</button>
{sessionId && (
<button
onClick={handleReset}
className="btn-secondary"
style={{ padding: "6px 12px", fontSize: "12px" }}
title="Restart Session"
>
<RotateCcw size={14} /> Reset
</button>
)}
</div>
</div>
{/* Central Glowing Voice Sphere (Clean Hands-Free) */}
<div style={{ width: "100%", display: "flex", flexDirection: "column", alignItems: "center" }}>
<VoiceOrb
state={voiceState}
audioLevel={recorder.audioLevel}
isSpeechDetected={recorder.isSpeechDetected}
onClick={handleOrbClick}
/>
{/* Dynamic Soundwave Visualizer */}
<AudioWaveform
state={voiceState}
audioLevel={recorder.audioLevel}
/>
</div>
{/* Bottom Action Tray: Document Upload + Keyboard Fallback + Review */}
<div style={{ width: "100%", display: "flex", flexDirection: "column", gap: "12px" }}>
<div style={{ display: "flex", justifyContent: "center", gap: "10px", flexWrap: "wrap" }}>
{/* Document Upload Button */}
<button
onClick={() => setIsDocModalOpen(true)}
disabled={!sessionId}
className="btn-secondary"
style={{ opacity: !sessionId ? 0.5 : 1 }}
>
<FileText size={16} color="#8b5cf6" />
Upload Prescription / Report
</button>
{/* Keyboard Mode Toggle */}
<button
onClick={() => setShowKeyboardFallback(!showKeyboardFallback)}
className="btn-secondary"
>
<Keyboard size={16} color="#38bdf8" />
{showKeyboardFallback ? "Hide Keyboard" : "Type instead"}
</button>
{/* Review & Complete Trigger */}
{sessionId && (
<button
onClick={handleReviewCompletion}
className="btn-primary"
style={{ background: "linear-gradient(135deg, #10b981, #06b6d4)" }}
>
<Sparkles size={16} /> Review Summary
</button>
)}
</div>
{/* Keyboard Input Fallback Box */}
{showKeyboardFallback && (
<form onSubmit={handleTextSubmit} style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
<input
type="text"
placeholder="Type your response to the doctor's assistant..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
disabled={!sessionId}
style={{
flex: 1,
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.15)",
borderRadius: "999px",
padding: "10px 18px",
color: "#f8fafc",
fontSize: "14px",
outline: "none",
}}
/>
<button
type="submit"
disabled={!textInput.trim() || !sessionId}
className="btn-primary"
style={{ padding: "10px 20px" }}
>
<Send size={15} />
</button>
</form>
)}
</div>
</section>
{/* Right Column: Live Clinical Dialogue Transcript */}
<section style={{ width: "100%" }}>
<LiveTranscript
messages={messages}
currentStage={currentStage}
isProcessing={voiceState === "processing"}
onPlayAudio={(url) => playAudioResponse(url, false)}
/>
</section>
</main>
)}
</div>
{/* Gemini Vision Document Upload Modal */}
{sessionId && (
<DocumentUploadModal
sessionId={sessionId}
isOpen={isDocModalOpen}
onClose={() => setIsDocModalOpen(false)}
onExtractionComplete={(docRes) => {
const docMsg: MessageItem = {
id: `doc-${Date.now()}`,
role: "system",
text: `📄 Document Analyzed (${docRes.document_type}): ${docRes.summary}`,
timestamp: new Date().toLocaleTimeString(),
};
setMessages((prev) => [...prev, docMsg]);
}}
/>
)}
{/* Confirmation & Hospital Submission Modal */}
<ConfirmationModal
isOpen={isConfirmModalOpen}
result={onboardingResult}
isSubmitting={isSubmitting}
onConfirm={handleFinalConfirm}
onCancel={() => setIsConfirmModalOpen(false)}
/>
{/* Indian Voice & Accent Settings Modal */}
<VoiceSettingsModal
isOpen={isVoiceSettingsOpen}
onClose={() => setIsVoiceSettingsOpen(false)}
language={language}
/>
</div>
);
}
export default App;
-55
View File
@@ -1,55 +0,0 @@
import React from "react";
import { VoiceState } from "../types/voice";
interface AudioWaveformProps {
state: VoiceState;
audioLevel: number;
}
export const AudioWaveform: React.FC<AudioWaveformProps> = ({ state, audioLevel }) => {
if (state !== "listening" && state !== "ai_speaking") {
return null;
}
const barCount = 18;
const isListening = state === "listening";
return (
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "4px",
height: "44px",
margin: "12px 0",
}}>
{Array.from({ length: barCount }).map((_, idx) => {
// Calculate dynamic height based on audio level or animation
let height = 6;
if (isListening) {
const factor = Math.sin((idx / barCount) * Math.PI) * (audioLevel / 100);
height = Math.max(6, Math.min(38, Math.round(6 + factor * 32 + (Math.random() * 8))));
} else {
// AI Speaking rhythmic wave
const phase = (idx + Date.now() / 150) % barCount;
height = Math.max(8, Math.min(36, Math.round(10 + Math.sin(phase) * 18 + 8)));
}
return (
<div
key={idx}
style={{
width: "4px",
height: `${height}px`,
borderRadius: "999px",
background: isListening
? "linear-gradient(to top, #f43f5e, #fb7185)"
: "linear-gradient(to top, #06b6d4, #3b82f6)",
transition: "height 0.1s ease",
}}
/>
);
})}
</div>
);
};
-179
View File
@@ -1,179 +0,0 @@
import React from "react";
import { CheckCircle2, ShieldCheck, Heart, Pill, AlertTriangle, Send } from "lucide-react";
import { PatientOnboardingResult } from "../types/onboarding";
interface ConfirmationModalProps {
isOpen: boolean;
result: PatientOnboardingResult | null;
isSubmitting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
isOpen,
result,
isSubmitting,
onConfirm,
onCancel,
}) => {
if (!isOpen || !result) return null;
return (
<div style={{
position: "fixed",
inset: 0,
background: "rgba(0, 0, 0, 0.8)",
backdropFilter: "blur(10px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 60,
padding: "20px",
}}>
<div className="glass-panel" style={{
width: "100%",
maxWidth: "600px",
padding: "32px",
background: "#0d1322",
border: "1px solid rgba(16, 185, 129, 0.3)",
boxShadow: "0 0 50px rgba(16, 185, 129, 0.15)",
maxHeight: "90vh",
overflowY: "auto",
}}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", gap: "12px", marginBottom: "20px" }}>
<div style={{
width: "44px",
height: "44px",
borderRadius: "50%",
background: "linear-gradient(135deg, #10b981, #06b6d4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<ShieldCheck size={26} color="#ffffff" />
</div>
<div>
<h3 style={{ fontSize: "20px", fontWeight: "700" }}>Review & Confirm OPD Intake</h3>
<p style={{ fontSize: "13px", color: "var(--text-secondary)", margin: 0 }}>
Specialty: {result.category.name} | OPD ID: {result.patient_id} {result.abha_id ? `| ABHA: ${result.abha_id}` : ""}
</p>
</div>
</div>
{/* Structured Summary Cards */}
<div style={{ display: "flex", flexDirection: "column", gap: "14px", marginBottom: "24px" }}>
{/* Chief Complaint */}
<div style={{
background: "rgba(255, 255, 255, 0.03)",
borderRadius: "14px",
padding: "14px 18px",
border: "1px solid rgba(255, 255, 255, 0.06)",
}}>
<div style={{ fontSize: "12px", color: "#38bdf8", fontWeight: "600", textTransform: "uppercase", marginBottom: "4px" }}>
Chief Complaint / Reason for Visit
</div>
<div style={{ fontSize: "15px", fontWeight: "600" }}>
{result.chief_complaint || "Not specified"}
</div>
</div>
{/* Reported Symptoms */}
<div style={{
background: "rgba(255, 255, 255, 0.03)",
borderRadius: "14px",
padding: "14px 18px",
border: "1px solid rgba(255, 255, 255, 0.06)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "12px", color: "#fb7185", fontWeight: "600", textTransform: "uppercase", marginBottom: "8px" }}>
<Heart size={14} /> Reported Symptoms
</div>
{result.symptoms && result.symptoms.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{result.symptoms.map((s, i) => (
<div key={i} style={{ fontSize: "14px", display: "flex", justifyContent: "space-between", background: "rgba(255, 255, 255, 0.02)", padding: "6px 10px", borderRadius: "8px" }}>
<span style={{ fontWeight: "500" }}>• {s.name}</span>
<span style={{ color: "var(--text-secondary)", fontSize: "13px" }}>
{s.duration ? `Duration: ${s.duration}` : ""} {s.severity ? `(${s.severity})` : ""}
</span>
</div>
))}
</div>
) : (
<span style={{ fontSize: "13px", color: "var(--text-muted)" }}>None reported</span>
)}
</div>
{/* Current Medications & Allergies Grid */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>
{/* Medications */}
<div style={{
background: "rgba(255, 255, 255, 0.03)",
borderRadius: "14px",
padding: "14px",
border: "1px solid rgba(255, 255, 255, 0.06)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "12px", color: "#a78bfa", fontWeight: "600", textTransform: "uppercase", marginBottom: "6px" }}>
<Pill size={14} /> Medications
</div>
{result.medications && result.medications.length > 0 ? (
<ul style={{ paddingLeft: "16px", fontSize: "13px", margin: 0 }}>
{result.medications.map((m, i) => (
<li key={i}>{m}</li>
))}
</ul>
) : (
<span style={{ fontSize: "13px", color: "var(--text-muted)" }}>No regular medications</span>
)}
</div>
{/* Allergies */}
<div style={{
background: "rgba(255, 255, 255, 0.03)",
borderRadius: "14px",
padding: "14px",
border: "1px solid rgba(255, 255, 255, 0.06)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "12px", color: "#facc15", fontWeight: "600", textTransform: "uppercase", marginBottom: "6px" }}>
<AlertTriangle size={14} /> Allergies
</div>
{result.allergies && result.allergies.length > 0 ? (
<ul style={{ paddingLeft: "16px", fontSize: "13px", margin: 0, color: "#f87171" }}>
{result.allergies.map((a, i) => (
<li key={i}>{a}</li>
))}
</ul>
) : (
<span style={{ fontSize: "13px", color: "#34d399" }}>No known drug allergies</span>
)}
</div>
</div>
</div>
{/* Action Controls */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<button
onClick={onCancel}
className="btn-secondary"
disabled={isSubmitting}
>
Edit / Continue Voice
</button>
<button
onClick={onConfirm}
className="btn-primary"
disabled={isSubmitting}
style={{
background: "linear-gradient(135deg, #10b981, #059669)",
boxShadow: "0 4px 20px rgba(16, 185, 129, 0.4)",
}}
>
<Send size={16} />
{isSubmitting ? "Submitting to Hospital EHR..." : "Confirm & Save to Record"}
</button>
</div>
</div>
</div>
);
};
-224
View File
@@ -1,224 +0,0 @@
import React, { useState } from "react";
import { X, Upload, FileText, CheckCircle, AlertCircle, Sparkles } from "lucide-react";
import { uploadMedicalDocument } from "../services/api";
import { DocumentExtractionResult } from "../types/onboarding";
interface DocumentUploadModalProps {
sessionId: string;
isOpen: boolean;
onClose: () => void;
onExtractionComplete: (result: DocumentExtractionResult) => void;
}
export const DocumentUploadModal: React.FC<DocumentUploadModalProps> = ({
sessionId,
isOpen,
onClose,
onExtractionComplete,
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<DocumentExtractionResult | null>(null);
if (!isOpen) return null;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0]);
setError(null);
setResult(null);
}
};
const handleUpload = async () => {
if (!selectedFile) return;
try {
setIsUploading(true);
setError(null);
const res = await uploadMedicalDocument(sessionId, selectedFile);
setResult(res);
onExtractionComplete(res);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : "Document analysis failed";
setError(errorMessage);
} finally {
setIsUploading(false);
}
};
return (
<div style={{
position: "fixed",
inset: 0,
background: "rgba(0, 0, 0, 0.75)",
backdropFilter: "blur(8px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 50,
padding: "20px",
}}>
<div className="glass-panel" style={{
width: "100%",
maxWidth: "520px",
padding: "28px",
background: "#0f172a",
position: "relative",
}}>
{/* Close Button */}
<button
onClick={onClose}
style={{
position: "absolute",
top: "20px",
right: "20px",
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
}}
>
<X size={20} />
</button>
{/* Modal Title */}
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "8px" }}>
<div style={{
width: "36px",
height: "36px",
borderRadius: "10px",
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<FileText size={20} color="#ffffff" />
</div>
<div>
<h3 style={{ fontSize: "18px", fontWeight: "700" }}>Upload Medical Document</h3>
<p style={{ fontSize: "12px", color: "var(--text-secondary)", margin: 0 }}>
Gemini Vision analyzes prescriptions, previous lab tests & reports
</p>
</div>
</div>
{/* Upload Dropzone */}
<div style={{
margin: "20px 0",
padding: "24px",
border: "2px dashed rgba(255, 255, 255, 0.15)",
borderRadius: "16px",
textAlign: "center",
background: "rgba(255, 255, 255, 0.02)",
}}>
<Upload size={32} color="#60a5fa" style={{ marginBottom: "12px" }} />
<p style={{ fontSize: "14px", fontWeight: "600", marginBottom: "4px" }}>
{selectedFile ? selectedFile.name : "Select or drop medical file"}
</p>
<p style={{ fontSize: "12px", color: "var(--text-muted)", marginBottom: "14px" }}>
Supports PDF, PNG, JPG, or Scanned Doctor Prescription
</p>
<label style={{
display: "inline-block",
padding: "8px 18px",
borderRadius: "999px",
background: "rgba(255, 255, 255, 0.08)",
color: "var(--text-primary)",
fontSize: "13px",
fontWeight: "600",
cursor: "pointer",
border: "1px solid rgba(255, 255, 255, 0.15)",
}}>
Browse File
<input
type="file"
accept=".pdf,image/*"
onChange={handleFileChange}
style={{ display: "none" }}
/>
</label>
</div>
{/* Error message */}
{error && (
<div style={{
display: "flex",
alignItems: "center",
gap: "8px",
color: "#f87171",
fontSize: "13px",
marginBottom: "16px",
}}>
<AlertCircle size={16} />
<span>{error}</span>
</div>
)}
{/* Extracted Findings Preview */}
{result && (
<div style={{
background: "rgba(16, 185, 129, 0.1)",
border: "1px solid rgba(16, 185, 129, 0.3)",
borderRadius: "14px",
padding: "16px",
marginBottom: "20px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", color: "#34d399", fontWeight: "600", fontSize: "14px", marginBottom: "8px" }}>
<CheckCircle size={16} />
<span>Extracted by Gemini Vision:</span>
</div>
<p style={{ fontSize: "13px", color: "var(--text-secondary)", marginBottom: "10px" }}>
{result.summary}
</p>
{result.findings && result.findings.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
{result.findings.map((f, i) => (
<span key={i} style={{
fontSize: "12px",
padding: "3px 8px",
borderRadius: "6px",
background: "rgba(255, 255, 255, 0.08)",
color: "#f8fafc",
}}>
{f.test}: <strong>{f.value} {f.unit || ""}</strong> ({f.interpretation || "Observed"})
</span>
))}
</div>
)}
</div>
)}
{/* Action Buttons */}
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px" }}>
<button
onClick={onClose}
className="btn-secondary"
disabled={isUploading}
>
Close
</button>
{!result ? (
<button
onClick={handleUpload}
disabled={!selectedFile || isUploading}
className="btn-primary"
style={{ opacity: !selectedFile || isUploading ? 0.6 : 1 }}
>
<Sparkles size={16} />
{isUploading ? "Analyzing with Gemini..." : "Extract with Gemini"}
</button>
) : (
<button
onClick={onClose}
className="btn-primary"
>
Done
</button>
)}
</div>
</div>
</div>
);
};
-204
View File
@@ -1,204 +0,0 @@
import React, { useState } from 'react';
import { UploadCloud, FileText, CheckCircle2, AlertCircle, Loader2, X, Pill, ShieldAlert, Activity } from 'lucide-react';
import { uploadDocument } from '../services/api';
export default function DocumentUploader({ sessionId, onClose, onDocumentUploaded }) {
const [selectedFile, setSelectedFile] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadResult, setUploadResult] = useState(null);
const [error, setError] = useState(null);
const handleFileChange = (e) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0]);
setError(null);
setUploadResult(null);
}
};
const handleUpload = async (e) => {
e.preventDefault();
if (!selectedFile || !sessionId) return;
setIsUploading(true);
setError(null);
try {
const result = await uploadDocument(sessionId, selectedFile);
setUploadResult(result);
if (onDocumentUploaded) {
onDocumentUploaded(result);
}
} catch (err) {
console.error('Document upload error:', err);
setError(err.message || 'Failed to process medical document');
} finally {
setIsUploading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md">
<div className="glass-card rounded-3xl max-w-xl w-full p-6 border border-slate-200/90 bg-white/95 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-slate-400 hover:text-slate-700 p-1.5 rounded-xl hover:bg-slate-100 transition-all"
>
<X className="h-5 w-5" />
</button>
<div className="mb-6">
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-cyan-100 text-cyan-800 text-xs font-bold mb-2 border border-cyan-200">
<FileText className="h-3.5 w-3.5" />
<span>Gemini Vision OCR & Medical Extractor</span>
</div>
<h3 className="text-xl font-extrabold text-slate-900">Upload Medical Document</h3>
<p className="text-xs text-slate-600 mt-1 font-medium">
Upload doctor's prescription, discharge summary, or lab result image to automatically extract active medications and allergies into this session.
</p>
</div>
{/* Upload Form */}
{!uploadResult ? (
<form onSubmit={handleUpload} className="space-y-4">
<div className="border-2 border-dashed border-cyan-200 hover:border-cyan-500 rounded-2xl p-6 text-center transition-all bg-sky-50/50">
<input
type="file"
accept="image/*,.pdf"
id="medical-doc-input"
onChange={handleFileChange}
className="hidden"
/>
<label
htmlFor="medical-doc-input"
className="cursor-pointer flex flex-col items-center justify-center space-y-2"
>
<div className="h-12 w-12 rounded-full bg-cyan-100 border border-cyan-200 flex items-center justify-center text-cyan-700">
<UploadCloud className="h-6 w-6" />
</div>
<div className="text-sm font-bold text-slate-900">
{selectedFile ? selectedFile.name : 'Click or Drag prescription file here'}
</div>
<div className="text-xs text-slate-500 font-medium">Supports PNG, JPG, WEBP or PDF (Max 10MB)</div>
</label>
</div>
{error && (
<div className="p-3 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 text-xs flex items-center space-x-2">
<AlertCircle className="h-4 w-4 shrink-0 text-rose-600" />
<span>{error}</span>
</div>
)}
<div className="flex justify-end space-x-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-600 hover:bg-slate-100"
>
Cancel
</button>
<button
type="submit"
disabled={!selectedFile || isUploading}
className="px-5 py-2 rounded-xl bg-gradient-to-r from-cyan-600 to-indigo-600 hover:from-cyan-500 hover:to-indigo-500 text-white font-bold text-xs flex items-center space-x-2 disabled:opacity-40 transition-all shadow-md shadow-cyan-600/20 cursor-pointer"
>
{isUploading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Analyzing with Gemini Vision...</span>
</>
) : (
<span>Analyze Document</span>
)}
</button>
</div>
</form>
) : (
/* Parsed Findings Results */
<div className="space-y-4">
<div className="p-4 rounded-2xl bg-emerald-50 border border-emerald-200 text-emerald-900 text-xs flex items-center space-x-3">
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
<div>
<div className="font-bold text-sm">Document Analyzed Successfully</div>
<div>{uploadResult.message}</div>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-200 space-y-3 text-xs">
<div>
<span className="text-slate-500 font-semibold">Document Type: </span>
<span className="text-cyan-900 font-bold capitalize">{uploadResult.document_type || 'Prescription'}</span>
</div>
<div>
<span className="text-slate-500 font-semibold">Summary: </span>
<span className="text-slate-800 font-medium">{uploadResult.summary}</span>
</div>
{/* Medications Found */}
{uploadResult.medications_found?.length > 0 && (
<div>
<div className="font-bold text-cyan-800 flex items-center space-x-1.5 mb-1">
<Pill className="h-3.5 w-3.5 text-cyan-600" />
<span>Medications Extracted:</span>
</div>
<div className="flex flex-wrap gap-1.5">
{uploadResult.medications_found.map((med, idx) => (
<span key={idx} className="px-2.5 py-1 rounded-lg bg-sky-100 border border-sky-200 text-sky-900 font-mono font-medium">
{med}
</span>
))}
</div>
</div>
)}
{/* Allergies Found */}
{uploadResult.allergies_found?.length > 0 && (
<div>
<div className="font-bold text-rose-800 flex items-center space-x-1.5 mb-1">
<ShieldAlert className="h-3.5 w-3.5 text-rose-600" />
<span>Allergies Flagged:</span>
</div>
<div className="flex flex-wrap gap-1.5">
{uploadResult.allergies_found.map((alg, idx) => (
<span key={idx} className="px-2 py-0.5 rounded-lg bg-rose-100 border border-rose-200 text-rose-800 font-mono font-medium">
{alg}
</span>
))}
</div>
</div>
)}
{/* Findings */}
{uploadResult.findings?.length > 0 && (
<div>
<div className="font-bold text-slate-800 flex items-center space-x-1.5 mb-1">
<Activity className="h-3.5 w-3.5 text-indigo-600" />
<span>Clinical Findings:</span>
</div>
<ul className="list-disc list-inside text-slate-800 space-y-0.5 pl-1 font-medium">
{uploadResult.findings.map((finding, idx) => (
<li key={idx}>{finding}</li>
))}
</ul>
</div>
)}
</div>
<div className="flex justify-end pt-2">
<button
onClick={onClose}
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-600 to-indigo-600 text-white font-bold text-xs transition-all shadow-md shadow-cyan-600/20"
>
Done & Continue Onboarding
</button>
</div>
</div>
)}
</div>
</div>
);
}
-77
View File
@@ -1,77 +0,0 @@
import React from 'react';
import { Heart, ShieldCheck, Globe, Sun, Headphones, Radio, ChevronDown } from 'lucide-react';
export default function Header({ healthStatus, activeSession, onResetSession }) {
const isHealthy = healthStatus?.status === 'healthy';
return (
<header className="sticky top-0 z-40 bg-white/90 backdrop-blur-md border-b border-slate-200/60 shadow-2xs">
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
{/* Left: Brand Logo, Title & Tagline */}
<div className="flex items-center space-x-3">
<div className="h-10 w-10 rounded-2xl bg-gradient-to-tr from-violet-600 via-purple-600 to-indigo-600 p-0.5 shadow-md shadow-purple-500/20 shrink-0">
<div className="h-full w-full bg-white rounded-[14px] flex items-center justify-center">
<Heart className="h-5 w-5 text-purple-600 fill-purple-600/20" />
</div>
</div>
<div>
<h1 className="font-black text-lg tracking-tight text-slate-900 leading-tight">
CareVoice AI
</h1>
<p className="text-[11px] text-slate-500 font-medium">Intelligent. Human. Caring.</p>
</div>
</div>
{/* Right side Header Items */}
<div className="flex items-center space-x-3 sm:space-x-5">
{/* Badge 1: HIPAA & DPDP Compliant */}
<div className="hidden md:flex items-center space-x-2 text-xs font-semibold text-slate-700">
<div className="w-8 h-8 rounded-xl bg-slate-100 flex items-center justify-center text-slate-700 border border-slate-200/60">
<ShieldCheck className="w-4 h-4 text-slate-700" />
</div>
<div className="text-[11px] leading-tight">
<span className="block font-bold text-slate-900">HIPAA & DPDP</span>
<span className="text-slate-500 font-medium">Compliant</span>
</div>
</div>
{/* Badge 2: 15+ Indian Languages */}
<div className="hidden lg:flex items-center space-x-2 text-xs font-semibold text-slate-700">
<div className="w-8 h-8 rounded-xl bg-slate-100 flex items-center justify-center text-slate-700 border border-slate-200/60">
<Globe className="w-4 h-4 text-slate-700" />
</div>
<div className="text-[11px] leading-tight">
<span className="block font-bold text-slate-900">15+ Indian</span>
<span className="text-slate-500 font-medium">Languages</span>
</div>
</div>
{/* Theme Toggle Sun Button */}
<button
type="button"
className="w-9 h-9 rounded-xl bg-slate-100/80 hover:bg-slate-200/80 text-slate-600 flex items-center justify-center transition-colors border border-slate-200/60"
title="Toggle theme"
>
<Sun className="w-4 h-4 text-slate-600" />
</button>
{/* Need Help? Pill Button */}
<button
type="button"
className="flex items-center space-x-2 px-3.5 py-2 rounded-full bg-purple-50 hover:bg-purple-100 text-purple-700 border border-purple-200/80 text-xs font-bold transition-all shadow-2xs"
>
<Headphones className="w-3.5 h-3.5 text-purple-600" />
<span>Need Help?</span>
</button>
</div>
</div>
</header>
);
}
-228
View File
@@ -1,228 +0,0 @@
import React from "react";
import { Activity, Globe, Shield, Sparkles, Volume2 } from "lucide-react";
import { CategoryInfo, PatientInfo, VoiceState } from "../types/voice";
interface HeaderProps {
patient: PatientInfo;
category: CategoryInfo;
language: string;
onLanguageChange: (lang: string) => void;
onCategoryChange: (cat: CategoryInfo) => void;
voiceState: VoiceState;
onOpenVoiceSettings?: () => void;
}
const SPECIALTIES: CategoryInfo[] = [
{ type: "speciality", code: "cardiology", name: "Cardiology" },
{ type: "speciality", code: "mental_health", name: "Mental Health" },
{ type: "general", code: "general", name: "General Consultation" },
{ type: "speciality", code: "dermatology", name: "Dermatology" },
{ type: "speciality", code: "pediatrics", name: "Pediatrics" },
{ type: "speciality", code: "orthopedics", name: "Orthopedics" },
];
export const Header: React.FC<HeaderProps> = ({
patient,
category,
language,
onLanguageChange,
onCategoryChange,
voiceState,
onOpenVoiceSettings,
}) => {
const isSessionLocked = voiceState !== "idle" && voiceState !== "completed" && voiceState !== "error";
return (
<header style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "16px 28px",
borderBottom: "1px solid rgba(255, 255, 255, 0.08)",
background: "rgba(10, 13, 20, 0.8)",
backdropFilter: "blur(12px)",
position: "sticky",
top: 0,
zIndex: 40,
}}>
{/* Brand & Logo */}
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div style={{
width: "42px",
height: "42px",
borderRadius: "14px",
background: "linear-gradient(135deg, #06b6d4, #3b82f6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 0 20px rgba(6, 182, 212, 0.4)",
}}>
<Activity size={24} color="#ffffff" />
</div>
<div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={{ fontSize: "20px", fontWeight: "700", letterSpacing: "-0.02em" }}>
CureSelect
</span>
<span style={{
fontSize: "11px",
padding: "2px 8px",
borderRadius: "999px",
background: "rgba(6, 182, 212, 0.15)",
color: "#06b6d4",
border: "1px solid rgba(6, 182, 212, 0.3)",
fontWeight: "600",
display: "flex",
alignItems: "center",
gap: "4px"
}}>
<Sparkles size={10} /> Voice AI
</span>
</div>
<p style={{ fontSize: "12px", color: "var(--text-muted)", margin: 0 }}>
Hospital Patient Intake Assistant
</p>
</div>
</div>
{/* Patient & Specialty Context */}
<div style={{ display: "flex", alignItems: "center", gap: "14px" }}>
<div style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 14px",
background: "rgba(255, 255, 255, 0.04)",
borderRadius: "12px",
border: "1px solid rgba(255, 255, 255, 0.08)",
}}>
<div style={{
width: "28px",
height: "28px",
borderRadius: "50%",
background: "rgba(59, 130, 246, 0.2)",
color: "#60a5fa",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontWeight: "600",
}}>
{patient.name.charAt(0)}
</div>
<div style={{ textAlign: "left" }}>
<div style={{ fontSize: "13px", fontWeight: "600", color: "var(--text-primary)" }}>
{patient.name} ({patient.age}y, {patient.gender})
</div>
<div style={{ fontSize: "11px", color: "var(--text-muted)" }}>
ID: {patient.id}
</div>
</div>
</div>
{/* Specialty Selector */}
<select
disabled={isSessionLocked}
value={category.code}
onChange={(e) => {
const found = SPECIALTIES.find((s) => s.code === e.target.value);
if (found) onCategoryChange(found);
}}
style={{
background: "rgba(18, 24, 38, 0.9)",
color: "#38bdf8",
border: "1px solid rgba(56, 189, 248, 0.3)",
borderRadius: "12px",
padding: "8px 14px",
fontSize: "13px",
fontWeight: "600",
cursor: isSessionLocked ? "not-allowed" : "pointer",
outline: "none",
}}
>
{SPECIALTIES.map((s) => (
<option key={s.code} value={s.code}>
{s.name}
</option>
))}
</select>
{/* Language Selector */}
<div style={{
display: "flex",
alignItems: "center",
gap: "6px",
background: "rgba(255, 255, 255, 0.04)",
padding: "6px 12px",
borderRadius: "12px",
border: "1px solid rgba(255, 255, 255, 0.08)",
}}>
<Globe size={15} color="#94a3b8" />
<select
disabled={isSessionLocked}
value={language}
onChange={(e) => onLanguageChange(e.target.value)}
style={{
background: "transparent",
color: "var(--text-primary)",
border: "none",
fontSize: "13px",
fontWeight: "500",
cursor: isSessionLocked ? "not-allowed" : "pointer",
outline: "none",
}}
>
<option value="ta-IN" style={{ background: "#0a0d14" }}>Tamil (தமிழ்)</option>
<option value="en-IN" style={{ background: "#0a0d14" }}>English (India)</option>
<option value="hi-IN" style={{ background: "#0a0d14" }}>Hindi (हिन्दी)</option>
<option value="te-IN" style={{ background: "#0a0d14" }}>Telugu (తెలుగు)</option>
<option value="kn-IN" style={{ background: "#0a0d14" }}>Kannada (ಕನ್ನಡ)</option>
</select>
</div>
{/* Indian Voice & Accent Settings Button */}
{onOpenVoiceSettings && (
<button
onClick={onOpenVoiceSettings}
title="Configure Indian Neural Voice & Accent Calibration"
style={{
display: "flex",
alignItems: "center",
gap: "6px",
background: "rgba(6, 182, 212, 0.12)",
color: "#38bdf8",
border: "1px solid rgba(6, 182, 212, 0.3)",
padding: "7px 12px",
borderRadius: "12px",
fontSize: "12px",
fontWeight: "600",
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
<Volume2 size={15} />
<span>Voice & Accent</span>
</button>
)}
{/* Medical Safety Seal */}
<div style={{
display: "flex",
alignItems: "center",
gap: "6px",
color: "#34d399",
fontSize: "12px",
fontWeight: "500",
background: "rgba(16, 185, 129, 0.1)",
padding: "6px 12px",
borderRadius: "999px",
border: "1px solid rgba(16, 185, 129, 0.2)",
}}>
<Shield size={14} />
<span>Intake Portal</span>
</div>
</div>
</header>
);
};
-159
View File
@@ -1,159 +0,0 @@
import React from "react";
import { Sparkles, ArrowRight } from "lucide-react";
interface LanguageOption {
code: string;
name: string;
nativeName: string;
greeting: string;
flag: string;
accentColor: string;
}
const LANGUAGES: LanguageOption[] = [
{
code: "ta-IN",
name: "Tamil",
nativeName: "தமிழ்",
greeting: "வணக்கம்! தமிழில் தொடரவும்",
flag: "🇮🇳",
accentColor: "#f59e0b",
},
{
code: "en-IN",
name: "English",
nativeName: "English (India)",
greeting: "Hello! Continue in English",
flag: "🇬🇧",
accentColor: "#3b82f6",
},
{
code: "hi-IN",
name: "Hindi",
nativeName: "हिन्दी",
greeting: "नमस्ते! हिन्दी में जारी रखें",
flag: "🇮🇳",
accentColor: "#ef4444",
},
{
code: "te-IN",
name: "Telugu",
nativeName: "తెలుగు",
greeting: "నమస్కారం! తెలుగులో కొనసాగించండి",
flag: "🇮🇳",
accentColor: "#10b981",
},
{
code: "kn-IN",
name: "Kannada",
nativeName: "ಕನ್ನಡ",
greeting: "ನಮಸ್ಕಾರ! ಕನ್ನಡದಲ್ಲಿ ಮುಂದುವರಿಯಿರಿ",
flag: "🇮🇳",
accentColor: "#8b5cf6",
},
];
interface LanguageStepProps {
selectedLanguage: string;
onSelectLanguage: (code: string) => void;
onNext: () => void;
}
export const LanguageStep: React.FC<LanguageStepProps> = ({
selectedLanguage,
onSelectLanguage,
onNext,
}) => {
const handleCardClick = (code: string) => {
onSelectLanguage(code);
// Instant single-tap transition to Step 2
onNext();
};
return (
<div className="glass-panel" style={{
maxWidth: "760px",
width: "100%",
margin: "0 auto",
padding: "36px 32px",
textAlign: "center",
}}>
{/* Title */}
<div style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
background: "rgba(6, 182, 212, 0.12)",
color: "#06b6d4",
padding: "4px 14px",
borderRadius: "999px",
fontSize: "12px",
fontWeight: "600",
marginBottom: "12px",
}}>
<Sparkles size={14} /> Step 1 of 3 • Tap a Language to Proceed
</div>
<h2 style={{ fontSize: "28px", fontWeight: "800", marginBottom: "8px", letterSpacing: "-0.02em" }}>
Choose Your Language
</h2>
<p style={{ fontSize: "14px", color: "var(--text-secondary)", marginBottom: "28px", maxWidth: "480px", margin: "0 auto 28px auto" }}>
Tap your native language to immediately choose your medical department.
</p>
{/* Language Cards Grid (Instant Tap) */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: "16px",
}}>
{LANGUAGES.map((lang) => {
const isSelected = selectedLanguage === lang.code;
return (
<button
key={lang.code}
onClick={() => handleCardClick(lang.code)}
className="lang-card"
style={{
background: isSelected
? "rgba(6, 182, 212, 0.14)"
: "rgba(255, 255, 255, 0.03)",
border: isSelected
? "2px solid #06b6d4"
: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "18px",
padding: "22px 18px",
cursor: "pointer",
textAlign: "left",
transition: "all 0.18s ease",
display: "flex",
flexDirection: "column",
gap: "6px",
boxShadow: isSelected ? "0 0 24px rgba(6, 182, 212, 0.25)" : "none",
outline: "none",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%" }}>
<span style={{ fontSize: "28px" }}>{lang.flag}</span>
<ArrowRight size={16} color={isSelected ? "#06b6d4" : "var(--text-muted)"} />
</div>
<div style={{ fontSize: "19px", fontWeight: "700", color: "#ffffff", marginTop: "4px" }}>
{lang.nativeName}
</div>
<div style={{ fontSize: "12px", color: "var(--text-secondary)", fontWeight: "500" }}>
{lang.name}
</div>
<div style={{ fontSize: "11px", color: "#38bdf8", marginTop: "4px" }}>
{lang.greeting}
</div>
</button>
);
})}
</div>
</div>
);
};
-319
View File
@@ -1,319 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { Bot, User, Sparkles, Clock, Zap } from "lucide-react";
import { MessageItem } from "../types/voice";
interface LiveTranscriptProps {
messages: MessageItem[];
onPlayAudio?: (audioUrl: string) => void;
currentStage: string;
isProcessing?: boolean;
}
function formatLatency(ms?: number): { text: string; color: string; bg: string; border: string } | null {
if (ms === undefined || ms === null) return null;
const text = ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`;
if (ms < 1200) {
return {
text,
color: "#34d399",
bg: "rgba(16, 185, 129, 0.15)",
border: "rgba(16, 185, 129, 0.3)",
};
} else if (ms < 2500) {
return {
text,
color: "#38bdf8",
bg: "rgba(6, 182, 212, 0.15)",
border: "rgba(6, 182, 212, 0.3)",
};
} else {
return {
text,
color: "#fbbf24",
bg: "rgba(245, 158, 11, 0.15)",
border: "rgba(245, 158, 11, 0.3)",
};
}
}
export const LiveTranscript: React.FC<LiveTranscriptProps> = ({
messages,
currentStage,
isProcessing,
}) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [elapsedSeconds, setElapsedSeconds] = useState<number>(0);
// Live stopwatch while AI is processing
useEffect(() => {
let interval: number | null = null;
if (isProcessing) {
const start = Date.now();
setElapsedSeconds(0);
interval = window.setInterval(() => {
setElapsedSeconds(parseFloat(((Date.now() - start) / 1000).toFixed(1)));
}, 100);
} else {
setElapsedSeconds(0);
}
return () => {
if (interval) clearInterval(interval);
};
}, [isProcessing]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isProcessing]);
// Find latest AI response latency
const latestAIMessage = [...messages].reverse().find((m) => m.role === "assistant" && m.latencyMs !== undefined);
const latestLatency = latestAIMessage?.latencyMs ? formatLatency(latestAIMessage.latencyMs) : null;
return (
<div className="glass-panel" style={{
display: "flex",
flexDirection: "column",
height: "560px",
overflow: "hidden",
padding: "24px",
}}>
{/* Header */}
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingBottom: "16px",
borderBottom: "1px solid rgba(255, 255, 255, 0.08)",
marginBottom: "16px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Sparkles size={16} color="#06b6d4" />
<span style={{ fontSize: "14px", fontWeight: "700", textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--text-secondary)" }}>
Live Conversation
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{/* Latest Response Time Badge */}
{latestLatency && !isProcessing && (
<div
title="Latest AI Response Latency"
style={{
fontSize: "11px",
padding: "3px 10px",
borderRadius: "999px",
background: latestLatency.bg,
color: latestLatency.color,
border: `1px solid ${latestLatency.border}`,
fontWeight: "600",
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<Zap size={11} />
<span>{latestLatency.text}</span>
</div>
)}
{/* Current Stage Badge */}
<div style={{
fontSize: "12px",
padding: "4px 12px",
borderRadius: "999px",
background: "rgba(59, 130, 246, 0.15)",
color: "#60a5fa",
border: "1px solid rgba(59, 130, 246, 0.3)",
fontWeight: "600",
textTransform: "capitalize",
}}>
{currentStage.replace(/_/g, " ")}
</div>
</div>
</div>
{/* Messages Scroll Area */}
<div
ref={scrollRef}
style={{
flex: 1,
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: "16px",
paddingRight: "6px",
}}
>
{messages.length === 0 ? (
<div style={{
margin: "auto",
textAlign: "center",
color: "var(--text-muted)",
fontSize: "14px",
maxWidth: "280px",
}}>
Voice session started. The assistant will speak and listen automatically.
</div>
) : (
messages.map((m) => {
const isAI = m.role === "assistant";
const isSystem = m.role === "system";
const latency = isAI ? formatLatency(m.latencyMs) : null;
if (isSystem) {
return (
<div key={m.id} style={{
alignSelf: "center",
background: "rgba(255, 255, 255, 0.05)",
padding: "6px 14px",
borderRadius: "999px",
fontSize: "12px",
color: "#38bdf8",
border: "1px solid rgba(255, 255, 255, 0.08)",
}}>
{m.text}
</div>
);
}
return (
<div
key={m.id}
style={{
display: "flex",
gap: "10px",
alignSelf: isAI ? "flex-start" : "flex-end",
maxWidth: "88%",
}}
>
{isAI && (
<div style={{
width: "32px",
height: "32px",
borderRadius: "50%",
background: "linear-gradient(135deg, #06b6d4, #3b82f6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
boxShadow: "0 0 12px rgba(6, 182, 212, 0.4)",
}}>
<Bot size={17} color="#fff" />
</div>
)}
<div style={{
background: isAI
? "rgba(30, 41, 59, 0.85)"
: "linear-gradient(135deg, #2563eb, #1d4ed8)",
border: isAI
? "1px solid rgba(255, 255, 255, 0.08)"
: "none",
color: isAI ? "var(--text-primary)" : "#ffffff",
padding: "14px 18px",
borderRadius: isAI ? "20px 20px 20px 4px" : "20px 20px 4px 20px",
fontSize: "14px",
lineHeight: "1.55",
boxShadow: isAI
? "0 4px 16px rgba(0, 0, 0, 0.25)"
: "0 4px 16px rgba(37, 99, 235, 0.35)",
}}>
<p style={{ margin: 0 }}>{m.text}</p>
{/* Timestamp & Latency Metric Footer */}
<div style={{
fontSize: "11px",
display: "flex",
alignItems: "center",
justifyContent: isAI ? "space-between" : "flex-end",
gap: "8px",
marginTop: "8px",
opacity: 0.85,
}}>
<span style={{ opacity: 0.6 }}>{m.timestamp}</span>
{/* Response Latency Pill for AI */}
{latency && (
<span
style={{
fontSize: "10px",
fontWeight: "700",
color: latency.color,
background: latency.bg,
border: `1px solid ${latency.border}`,
padding: "1px 7px",
borderRadius: "999px",
display: "inline-flex",
alignItems: "center",
gap: "3px",
}}
>
<Clock size={10} />
<span>Response: {latency.text}</span>
</span>
)}
</div>
</div>
{!isAI && (
<div style={{
width: "32px",
height: "32px",
borderRadius: "50%",
background: "rgba(59, 130, 246, 0.2)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
border: "1px solid rgba(59, 130, 246, 0.4)",
}}>
<User size={17} color="#60a5fa" />
</div>
)}
</div>
);
})
)}
{/* AI Live Thinking Bubble with Real-Time Stopwatch */}
{isProcessing && (
<div style={{ display: "flex", gap: "10px", alignSelf: "flex-start", maxWidth: "80%" }}>
<div style={{
width: "32px",
height: "32px",
borderRadius: "50%",
background: "linear-gradient(135deg, #06b6d4, #3b82f6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
<Bot size={17} color="#fff" />
</div>
<div style={{
background: "rgba(30, 41, 59, 0.85)",
border: "1px solid rgba(6, 182, 212, 0.3)",
borderRadius: "20px 20px 20px 4px",
padding: "12px 18px",
display: "flex",
alignItems: "center",
gap: "8px",
}}>
<div style={{ width: "6px", height: "6px", borderRadius: "50%", background: "#38bdf8", animation: "pulse 1s infinite" }} />
<div style={{ width: "6px", height: "6px", borderRadius: "50%", background: "#38bdf8", animation: "pulse 1s 0.2s infinite" }} />
<div style={{ width: "6px", height: "6px", borderRadius: "50%", background: "#38bdf8", animation: "pulse 1s 0.4s infinite" }} />
<span style={{ fontSize: "12px", color: "#38bdf8", fontWeight: "600", marginLeft: "4px" }}>
AI Processing... {elapsedSeconds > 0 && `${elapsedSeconds}s`}
</span>
</div>
</div>
)}
</div>
</div>
);
};
-623
View File
@@ -1,623 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
User,
Calendar,
Globe,
Sparkles,
ArrowRight,
HeartPulse,
Mic,
ShieldCheck,
Zap,
Clock,
ChevronDown,
Shield,
Check,
Lock,
} from 'lucide-react';
const INDIAN_LANGUAGES = [
{ code: 'ta-IN', name: 'தமிழ் (Tamil)' },
{ code: 'hi-IN', name: 'हिंदी (Hindi)' },
{ code: 'te-IN', name: 'తెలుగు (Telugu)' },
{ code: 'kn-IN', name: 'ಕನ್ನಡ (Kannada)' },
{ code: 'ml-IN', name: 'മലയാളം (Malayalam)' },
{ code: 'bn-IN', name: 'বাংলা (Bengali)' },
{ code: 'mr-IN', name: 'मराठी (Marathi)' },
{ code: 'gu-IN', name: 'ગુજરાતી (Gujarati)' },
{ code: 'pa-IN', name: 'ਪੰਜਾਬੀ (Punjabi)' },
{ code: 'en-IN', name: 'English (Indian Accent)' },
{ code: 'or-IN', name: 'ଓଡ଼ିଆ (Odia)' },
{ code: 'as-IN', name: 'অসমীয়া (Assamese)' },
{ code: 'ur-IN', name: 'اردو (Urdu)' },
];
export default function PatientSetupForm({ onStartSession, onSubmit, isLoading }) {
const submitHandler = onStartSession || onSubmit;
const [formData, setFormData] = useState({
patient_id: 'PAT-' + Math.floor(1000 + Math.random() * 9000),
first_name: '',
last_name: '',
age: 35,
gender: 'male',
abha_id: '',
phone: '',
language: 'ta-IN',
category: {
type: 'general',
code: 'general_medicine',
name: 'General Medicine / Primary Care (Auto-Triage)',
},
});
// WebView & URL Parameter Auto-fill Listener
useEffect(() => {
if (typeof window !== 'undefined' && window.location.search) {
const params = new URLSearchParams(window.location.search);
const name = params.get('name') || params.get('patient_name');
const age = params.get('age');
const gender = params.get('gender');
const abha_id = params.get('abha_id') || params.get('abha');
const phone = params.get('phone') || params.get('mobile');
const langCode = params.get('language') || params.get('lang');
const autoStart = params.get('auto_start') === 'true' || params.get('autostart') === '1';
if (name || age || gender || phone || langCode) {
const parts = (name || 'Patient').trim().split(' ');
const fn = parts[0] || 'Arun';
const ln = parts.slice(1).join(' ') || '';
setFormData((prev) => ({
...prev,
first_name: fn,
last_name: ln,
age: age ? parseInt(age) : prev.age,
gender: gender || prev.gender,
abha_id: abha_id || prev.abha_id,
phone: phone || prev.phone,
language: langCode || prev.language,
}));
if (autoStart && fn && typeof submitHandler === 'function') {
submitHandler({
patient: {
id: 'PAT-' + Math.floor(1000 + Math.random() * 9000),
name: (fn + ' ' + ln).trim(),
first_name: fn,
last_name: ln,
age: age ? parseInt(age) : 35,
gender: gender || 'male',
abha_id: abha_id || '',
phone: phone || '',
},
category: { type: 'general', code: 'general_medicine', name: 'General Medicine' },
language: langCode || 'ta-IN',
mode: 'voice',
});
}
}
}
const handlePostMessage = (event) => {
if ((event.data?.type === 'INJECT_PATIENT_DATA' || event.data?.type === 'START_ONBOARDING') && event.data.patient) {
const pData = event.data.patient;
const parts = (pData.name || '').trim().split(' ');
const fn = pData.first_name || parts[0] || 'Arun';
const ln = pData.last_name || parts.slice(1).join(' ') || '';
setFormData((prev) => ({
...prev,
first_name: fn,
last_name: ln,
age: pData.age ? parseInt(pData.age) : prev.age,
gender: pData.gender || prev.gender,
abha_id: pData.abha_id || prev.abha_id,
phone: pData.phone || prev.phone,
language: event.data.language || prev.language,
}));
if ((event.data.autoStart || event.data.type === 'START_ONBOARDING') && typeof submitHandler === 'function') {
submitHandler({
patient: {
id: pData.id || 'PAT-' + Math.floor(1000 + Math.random() * 9000),
name: (fn + ' ' + ln).trim(),
first_name: fn,
last_name: ln,
age: pData.age ? parseInt(pData.age) : 35,
gender: pData.gender || 'male',
abha_id: pData.abha_id || '',
phone: pData.phone || '',
},
category: { type: 'general', code: 'general_medicine', name: 'General Medicine' },
language: event.data.language || 'ta-IN',
mode: 'voice',
});
}
}
};
window.addEventListener('message', handlePostMessage);
return () => window.removeEventListener('message', handlePostMessage);
}, []);
const handleChange = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
if (!formData.first_name.trim()) return;
if (typeof submitHandler === 'function') {
submitHandler({
patient: {
id: formData.patient_id,
name: (formData.first_name + ' ' + formData.last_name).trim(),
first_name: formData.first_name,
last_name: formData.last_name,
age: Number(formData.age),
gender: formData.gender,
abha_id: formData.abha_id,
phone: formData.phone,
},
category: formData.category,
language: formData.language,
mode: 'voice',
});
}
};
return (
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-5 w-full">
{/* Main 3-Column Layout: Left Hero, Middle Form, Right Timeline */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 xl:gap-8 items-start">
{/* COLUMN 1: LEFT HERO & SMART SPEAKER DEVICE (4 cols on desktop) */}
<div className="lg:col-span-4 flex flex-col justify-between space-y-4">
{/* Main Title & Subtitle */}
<div className="space-y-1.5">
<h1 className="text-3xl sm:text-4xl lg:text-[44px] font-black text-slate-900 leading-[1.1] tracking-tight">
Smarter Intake. <br />
<span className="bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent">
Better Care.
</span>
</h1>
<p className="text-slate-600 text-xs sm:text-sm leading-relaxed font-medium max-w-sm">
AI voice-first intake that understands, triages and extracts clinical information seamlessly.
</p>
</div>
{/* Horizontal 3 Feature Badges Row */}
<div className="flex items-center space-x-2 sm:space-x-2.5 overflow-x-auto pb-1">
<div className="flex items-center space-x-2 px-3 py-1.5 rounded-xl bg-white/80 backdrop-blur-md border border-slate-200/80 shadow-2xs shrink-0 hover:border-purple-300 transition-colors">
<div className="w-5 h-5 rounded-lg bg-purple-100/80 text-purple-600 flex items-center justify-center">
<Sparkles className="w-3 h-3" />
</div>
<div className="text-[11px] leading-tight">
<span className="font-bold text-slate-900 block">AI Voice</span>
<span className="text-[10px] text-slate-500 font-medium">Natural conversation</span>
</div>
</div>
<div className="flex items-center space-x-2 px-3 py-1.5 rounded-xl bg-white/80 backdrop-blur-md border border-slate-200/80 shadow-2xs shrink-0 hover:border-indigo-300 transition-colors">
<div className="w-5 h-5 rounded-lg bg-indigo-100/80 text-indigo-600 flex items-center justify-center">
<ShieldCheck className="w-3 h-3" />
</div>
<div className="text-[11px] leading-tight">
<span className="font-bold text-slate-900 block">Secure</span>
<span className="text-[10px] text-slate-500 font-medium">Your data is safe</span>
</div>
</div>
<div className="flex items-center space-x-2 px-3 py-1.5 rounded-xl bg-white/80 backdrop-blur-md border border-slate-200/80 shadow-2xs shrink-0 hover:border-violet-300 transition-colors">
<div className="w-5 h-5 rounded-lg bg-violet-100/80 text-violet-600 flex items-center justify-center">
<Zap className="w-3 h-3" />
</div>
<div className="text-[11px] leading-tight">
<span className="font-bold text-slate-900 block">Real-time</span>
<span className="text-[10px] text-slate-500 font-medium">Instant insights</span>
</div>
</div>
</div>
{/* 3D Smart Speaker Image Frame with Animated Floating Speech Bubbles */}
<div className="relative rounded-3xl overflow-hidden border border-slate-200/80 shadow-2xl bg-slate-900 aspect-[4/3] group">
<img
src="/ai_smart_speaker_hero.jpg"
alt="AI Smart Speaker Consultation"
className="w-full h-full object-cover group-hover:scale-[1.03] transition-transform duration-700 ease-out"
/>
{/* Subtle Vignette Overlay for Contrast */}
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/50 via-transparent to-slate-950/20 pointer-events-none" />
{/* Floating Glass Speech Bubble 1 (Top Left, Purple Accent) */}
<div className="absolute top-4 left-4 backdrop-blur-md bg-purple-600/90 text-white px-3.5 py-2 rounded-2xl rounded-tl-none shadow-xl border border-purple-400/40 text-xs font-semibold animate-float-slow">
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-purple-200 animate-ping" />
<span>Hello! 👋 I'm your AI assistant.</span>
</div>
</div>
{/* Floating Glass Speech Bubble 2 (Top Right, Indigo Accent) */}
<div className="absolute top-14 right-4 backdrop-blur-md bg-indigo-600/90 text-white px-3.5 py-2 rounded-2xl rounded-tr-none shadow-xl border border-indigo-400/40 text-xs font-semibold animate-float-reverse">
Let's understand <br />how you're feeling.
</div>
{/* Floating Glass Speech Bubble 3 (Bottom Right, Emerald Accent) */}
<div className="absolute bottom-5 right-4 backdrop-blur-md bg-teal-600/90 text-white px-3.5 py-2 rounded-2xl rounded-br-none shadow-xl border border-teal-400/40 text-xs font-semibold animate-float-slow">
I'll ask a few <br />simple questions.
</div>
</div>
{/* Privacy Card at Bottom of Left Column */}
<div className="bg-white/90 backdrop-blur-md rounded-2xl p-3.5 border border-slate-200/80 shadow-xs flex items-center space-x-3.5 hover:shadow-md transition-shadow">
<div className="w-9 h-9 rounded-xl bg-purple-100/80 text-purple-600 flex items-center justify-center shrink-0">
<Lock className="w-4.5 h-4.5" />
</div>
<div>
<h4 className="text-xs font-bold text-slate-900">Your Privacy Matters</h4>
<p className="text-[11px] text-slate-500 font-medium leading-snug">
We follow industry-leading standards to keep your health information private and secure.
</p>
</div>
</div>
</div>
{/* COLUMN 2: MIDDLE PATIENT INTAKE & TRIAGE FORM CARD (5 cols on desktop) */}
<div className="lg:col-span-5">
<div className="bg-white/95 rounded-3xl p-5 sm:p-6 border border-slate-200/80 shadow-xl shadow-purple-900/5 relative overflow-hidden">
{/* Header of Form Card */}
<div className="flex items-center justify-between mb-3.5 pb-2.5 border-b border-slate-100">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-2xl bg-gradient-to-tr from-violet-600 via-purple-600 to-indigo-700 flex items-center justify-center shadow-md shadow-purple-500/20 shrink-0">
<HeartPulse className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg sm:text-xl font-black text-slate-900 tracking-tight">
Patient Intake & Triage
</h2>
<p className="text-xs text-slate-500 font-medium">
Start your AI voice consultation
</p>
</div>
</div>
{/* AI Voice Ready Pill */}
<div className="flex items-center space-x-1.5 px-3 py-1 rounded-full bg-purple-50 border border-purple-200/80 text-purple-700 text-xs font-semibold shadow-2xs">
<div className="flex items-center space-x-0.5 mr-0.5">
<span className="w-0.5 h-2.5 bg-purple-600 rounded-full animate-pulse" />
<span className="w-0.5 h-3.5 bg-purple-600 rounded-full animate-pulse delay-75" />
<span className="w-0.5 h-2 bg-purple-600 rounded-full animate-pulse" />
</div>
<span>AI Voice Ready</span>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse ml-0.5" />
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-3.5">
{/* AI Auto-Triage Active Banner */}
<div className="bg-gradient-to-r from-violet-50/80 via-purple-50/80 to-indigo-50/80 border border-purple-100 rounded-2xl p-3 flex items-start space-x-2.5">
<div className="w-7 h-7 rounded-xl bg-white text-purple-600 flex items-center justify-center shrink-0 shadow-2xs mt-0.5">
<Sparkles className="w-3.5 h-3.5 text-purple-600" />
</div>
<div className="text-xs text-slate-700 leading-relaxed">
<strong className="font-bold text-purple-950 flex items-center gap-1 mb-0.5">
<Sparkles className="w-3 h-3 text-purple-600" />
AI Dynamic Specialty Auto-Triage Active
</strong>
Our AI will analyze your responses in real-time and route you to the right specialist. No manual department selection needed.
</div>
</div>
{/* First Name & Last Name */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">
First Name <span className="text-rose-500">*</span>
</label>
<div className="relative">
<User className="absolute left-3 top-2.5 w-4 h-4 text-slate-400" />
<input
type="text"
required
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
placeholder="e.g. Arun"
className="w-full pl-9 pr-3 py-2 bg-slate-50/70 border border-slate-200 focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 rounded-xl text-slate-900 text-xs font-semibold outline-none transition-all"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">
Last Name <span className="text-rose-500">*</span>
</label>
<div className="relative">
<User className="absolute left-3 top-2.5 w-4 h-4 text-slate-400" />
<input
type="text"
required
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
placeholder="e.g. Kumar"
className="w-full pl-9 pr-3 py-2 bg-slate-50/70 border border-slate-200 focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 rounded-xl text-slate-900 text-xs font-semibold outline-none transition-all"
/>
</div>
</div>
</div>
{/* Age & Gender Row */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3">
{/* Age Input (5 cols) */}
<div className="sm:col-span-5">
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">
Age <span className="text-rose-500">*</span>
</label>
<div className="relative">
<Calendar className="absolute left-3 top-2.5 w-4 h-4 text-slate-400" />
<input
type="number"
required
min="1"
max="120"
value={formData.age}
onChange={(e) => handleChange('age', e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-slate-50/70 border border-slate-200 focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 rounded-xl text-slate-900 text-xs font-semibold outline-none transition-all"
/>
</div>
</div>
{/* Gender Segmented Pill Group (7 cols) */}
<div className="sm:col-span-7">
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">
Gender <span className="text-rose-500">*</span>
</label>
<div className="grid grid-cols-3 gap-1 p-1 bg-slate-100/80 rounded-xl border border-slate-200/80">
<button
type="button"
onClick={() => handleChange('gender', 'male')}
className={`flex items-center justify-center space-x-1 py-1.5 px-1 rounded-lg text-xs font-bold transition-all ${
formData.gender === 'male'
? 'bg-white text-indigo-700 border border-indigo-200 shadow-xs'
: 'text-slate-600 hover:text-slate-900'
}`}
>
<User className="w-3.5 h-3.5" />
<span>Male</span>
</button>
<button
type="button"
onClick={() => handleChange('gender', 'female')}
className={`flex items-center justify-center space-x-1 py-1.5 px-1 rounded-lg text-xs font-bold transition-all ${
formData.gender === 'female'
? 'bg-white text-purple-700 border border-purple-200 shadow-xs'
: 'text-slate-600 hover:text-slate-900'
}`}
>
<User className="w-3.5 h-3.5" />
<span>Female</span>
</button>
<button
type="button"
onClick={() => handleChange('gender', 'other')}
className={`flex items-center justify-center space-x-1 py-1.5 px-1 rounded-lg text-xs font-bold transition-all ${
formData.gender === 'other'
? 'bg-white text-violet-700 border border-violet-200 shadow-xs'
: 'text-slate-600 hover:text-slate-900'
}`}
>
<User className="w-3.5 h-3.5" />
<span>Other</span>
</button>
</div>
</div>
</div>
{/* Preferred Spoken Language Selection UI */}
<div className="space-y-1.5">
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider">
Preferred Spoken Language <span className="text-rose-500">*</span>
</label>
{/* Quick-Select Popular Language Cards Grid (2x3) */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5 sm:gap-2">
{[
{ code: 'ta-IN', label: 'தமிழ்', sub: 'Tamil' },
{ code: 'hi-IN', label: 'हिंदी', sub: 'Hindi' },
{ code: 'te-IN', label: 'తెలుగు', sub: 'Telugu' },
{ code: 'kn-IN', label: 'ಕನ್ನಡ', sub: 'Kannada' },
{ code: 'ml-IN', label: 'മലയാളം', sub: 'Malayalam' },
{ code: 'en-IN', label: 'English', sub: 'Indian Accent' },
].map((lang) => {
const isSelected = formData.language === lang.code;
return (
<button
key={lang.code}
type="button"
onClick={() => handleChange('language', lang.code)}
className={`flex items-center justify-between p-2.5 rounded-xl border text-left transition-all duration-200 cursor-pointer ${
isSelected
? 'bg-purple-50/90 border-purple-500 text-purple-950 ring-2 ring-purple-500/20 shadow-xs'
: 'bg-slate-50/50 border-slate-200 text-slate-700 hover:border-purple-300 hover:bg-purple-50/30'
}`}
>
<div className="truncate">
<span className="block text-xs font-black tracking-tight leading-tight">
{lang.label}
</span>
<span className="block text-[10px] text-slate-500 font-medium truncate">
{lang.sub}
</span>
</div>
{isSelected && (
<div className="w-4 h-4 rounded-full bg-purple-600 text-white flex items-center justify-center shrink-0 ml-1 shadow-xs">
<Check className="w-2.5 h-2.5 stroke-[3]" />
</div>
)}
</button>
);
})}
</div>
{/* All Languages Dropdown Selector */}
<div className="relative pt-0.5">
<Globe className="absolute left-3 top-3 w-4 h-4 text-slate-400 pointer-events-none" />
<select
value={formData.language}
onChange={(e) => handleChange('language', e.target.value)}
className="w-full pl-9 pr-9 py-2 bg-white border border-slate-200 focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 rounded-xl text-slate-900 text-xs outline-none font-semibold appearance-none cursor-pointer transition-all shadow-2xs"
>
<option value="" disabled>Other Language (Optional)</option>
{INDIAN_LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-3 w-4 h-4 text-slate-400 pointer-events-none" />
</div>
</div>
{/* Big Action Button */}
<button
type="submit"
disabled={isLoading}
className="w-full group mt-2 p-3 sm:p-3.5 bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-700 hover:from-violet-500 hover:to-indigo-600 text-white rounded-2xl shadow-lg shadow-purple-500/25 hover:shadow-xl hover:shadow-purple-500/35 transition-all duration-300 flex items-center justify-between cursor-pointer disabled:opacity-50 active:scale-[0.99]"
>
{/* Left Mic Pill */}
<div className="w-8 h-8 rounded-full bg-white/20 backdrop-blur-md border border-white/30 flex items-center justify-center shrink-0">
<Mic className="w-4 h-4 text-white" />
</div>
{/* Center Text */}
<div className="text-center px-2">
<span className="block text-sm sm:text-base font-black tracking-wide">
{isLoading ? 'Initializing AI Voice Engine...' : 'Start AI Voice Consultation'}
</span>
<span className="block text-[10px] text-purple-100 font-medium opacity-90">
Tap to begin your secure voice session
</span>
</div>
{/* Right Arrow Pill */}
<div className="w-8 h-8 rounded-full bg-white text-purple-700 flex items-center justify-center shrink-0 shadow-md group-hover:translate-x-0.5 transition-transform">
<ArrowRight className="w-4 h-4" />
</div>
</button>
{/* Terms & Privacy Disclaimer */}
<div className="text-center pt-0.5">
<p className="text-[11px] text-slate-500 font-medium flex items-center justify-center space-x-1">
<Shield className="w-3 h-3 text-slate-400" />
<span>By continuing, you agree to our</span>
<a href="#privacy" className="text-purple-600 hover:underline">Privacy Policy</a>
<span>&</span>
<a href="#terms" className="text-purple-600 hover:underline">Terms of Service</a>
</p>
</div>
</form>
</div>
</div>
{/* COLUMN 3: RIGHT SIDE "YOUR JOURNEY" TIMELINE & QUICK INFO CARDS (3 cols on desktop) */}
<div className="lg:col-span-3 space-y-3.5">
{/* Card 1: Your Journey Step Tracker */}
<div className="bg-white/95 rounded-3xl p-4 sm:p-5 border border-slate-200/80 shadow-md">
<h3 className="text-sm font-black text-slate-900 mb-4 tracking-tight">Your Journey</h3>
<div className="relative pl-3 space-y-5">
{/* Vertical Guide Line */}
<div className="absolute left-[21px] top-3 bottom-3 w-0.5 bg-slate-200" />
{/* Step 1 */}
<div className="relative flex items-start space-x-3 z-10">
<div className="w-7 h-7 rounded-full bg-purple-600 text-white font-bold text-xs flex items-center justify-center shrink-0 shadow-md shadow-purple-500/20">
1
</div>
<div>
<h4 className="text-xs font-bold text-purple-700">Introduction</h4>
<p className="text-[11px] text-slate-500 font-medium">Start your session</p>
</div>
</div>
{/* Step 2 */}
<div className="relative flex items-start space-x-3 z-10">
<div className="w-7 h-7 rounded-full bg-slate-100 text-slate-500 border border-slate-200 font-bold text-xs flex items-center justify-center shrink-0">
2
</div>
<div>
<h4 className="text-xs font-bold text-slate-800">AI Conversation</h4>
<p className="text-[11px] text-slate-500 font-medium">Answer a few questions</p>
</div>
</div>
{/* Step 3 */}
<div className="relative flex items-start space-x-3 z-10">
<div className="w-7 h-7 rounded-full bg-slate-100 text-slate-500 border border-slate-200 font-bold text-xs flex items-center justify-center shrink-0">
3
</div>
<div>
<h4 className="text-xs font-bold text-slate-800">Triage & Analysis</h4>
<p className="text-[11px] text-slate-500 font-medium">AI analyzes your responses</p>
</div>
</div>
{/* Step 4 */}
<div className="relative flex items-start space-x-3 z-10">
<div className="w-7 h-7 rounded-full bg-slate-100 text-slate-500 border border-slate-200 font-bold text-xs flex items-center justify-center shrink-0">
4
</div>
<div>
<h4 className="text-xs font-bold text-slate-800">Results</h4>
<p className="text-[11px] text-slate-500 font-medium">Get summary & next steps</p>
</div>
</div>
</div>
</div>
{/* Card 2: Session is Secure */}
<div className="bg-white/90 backdrop-blur-md rounded-2xl p-3.5 border border-slate-200/80 shadow-xs flex items-center space-x-3 hover:shadow-md transition-shadow">
<div className="w-9 h-9 rounded-xl bg-purple-100/80 text-purple-600 flex items-center justify-center shrink-0">
<ShieldCheck className="w-4.5 h-4.5" />
</div>
<div>
<h4 className="text-xs font-bold text-slate-900">Session is Secure</h4>
<p className="text-[11px] text-slate-500 font-medium leading-snug">
256-bit encryption<br />End-to-end protection
</p>
</div>
</div>
{/* Card 3: Average Time */}
<div className="bg-white/90 backdrop-blur-md rounded-2xl p-3.5 border border-slate-200/80 shadow-xs flex items-center space-x-3 hover:shadow-md transition-shadow">
<div className="w-9 h-9 rounded-xl bg-purple-100/80 text-purple-600 flex items-center justify-center shrink-0">
<Clock className="w-4.5 h-4.5" />
</div>
<div>
<p className="text-[11px] font-bold text-slate-500">Average Time</p>
<p className="text-xs font-extrabold text-purple-700">5–7 Minutes</p>
</div>
</div>
</div>
</div>
</div>
);
}
-382
View File
@@ -1,382 +0,0 @@
import React, { useState } from "react";
import {
Heart,
Brain,
Stethoscope,
Sparkles,
Baby,
Bone,
Eye,
ArrowLeft,
ArrowRight,
User,
Activity,
ShieldCheck,
} from "lucide-react";
import { CategoryInfo, PatientInfo } from "../types/voice";
interface SpecialtyOption {
code: string;
name: string;
type: "general" | "speciality";
icon: React.ReactNode;
color: string;
focus: string;
examples: string;
}
const SPECIALTIES: SpecialtyOption[] = [
{
code: "cardiology",
name: "Cardiology (Heart & BP)",
type: "speciality",
icon: <Heart size={24} color="#f43f5e" />,
color: "#f43f5e",
focus: "Chest pain (நெஞ்சு வலி), BP, palpitations, breathlessness, Telma / Amlodipine",
examples: "Heart checkup, ECG, Stent history",
},
{
code: "diabetology",
name: "Diabetology (Sugar / Diabetes)",
type: "speciality",
icon: <Activity size={24} color="#06b6d4" />,
color: "#06b6d4",
focus: "Sugar readings, Fasting/PPBS, HbA1c, Glycomet, Insulin, tingling in feet",
examples: "Sugar check, diabetic diet, foot exam",
},
{
code: "general",
name: "General Physician & OPD",
type: "general",
icon: <Stethoscope size={24} color="#10b981" />,
color: "#10b981",
focus: "Fever (Dengue, Viral, Typhoid), cold, body ache, acidity / Pan-D, Dolo 650",
examples: "General OPD consultation, health check",
},
{
code: "mental_health",
name: "Mental Health & Wellness",
type: "speciality",
icon: <Brain size={24} color="#8b5cf6" />,
color: "#8b5cf6",
focus: "Stress, anxiety, sleep issues (தூக்கமின்மை), low mood, panic",
examples: "Psychiatry, counselling intake",
},
{
code: "dermatology",
name: "Dermatology (Skin & Hair)",
type: "speciality",
icon: <Sparkles size={24} color="#ec4899" />,
color: "#ec4899",
focus: "Skin rashes, itching (அரிப்பு), fungal infections, hair fall, acne",
examples: "Skin OPD, cosmetic consultation",
},
{
code: "orthopedics",
name: "Orthopedics (Bone & Joint)",
type: "speciality",
icon: <Bone size={24} color="#f59e0b" />,
color: "#f59e0b",
focus: "Knee pain (மூட்டு வலி), back pain, arthritis, fractures, swelling",
examples: "Bone & joint specialist, X-ray",
},
{
code: "pediatrics",
name: "Pediatrics (Child Health)",
type: "speciality",
icon: <Baby size={24} color="#3b82f6" />,
color: "#3b82f6",
focus: "Child fever, growth milestones, vaccinations (தடுப்பூசி), appetite",
examples: "Child OPD, immunization",
},
{
code: "ent",
name: "ENT (Ear, Nose & Throat)",
type: "speciality",
icon: <Eye size={24} color="#14b8a6" />,
color: "#14b8a6",
focus: "Ear discharge, sinusitis, sore throat (தொண்டை வலி), tonsils",
examples: "ENT clinic, hearing check",
},
];
interface SpecialtyStepProps {
selectedSpecialty: CategoryInfo;
patient: PatientInfo;
onSelectSpecialty: (cat: CategoryInfo) => void;
onUpdatePatient: (patient: PatientInfo) => void;
onBack: () => void;
onNext: (cat?: CategoryInfo) => void;
}
export const SpecialtyStep: React.FC<SpecialtyStepProps> = ({
selectedSpecialty,
patient,
onSelectSpecialty,
onUpdatePatient,
onBack,
onNext,
}) => {
const [showEditPatient, setShowEditPatient] = useState(false);
const handleCardClick = (spec: SpecialtyOption) => {
const cat: CategoryInfo = {
type: spec.type,
code: spec.code,
name: spec.name,
};
onSelectSpecialty(cat);
// Instant launch of voice onboarding on tap
onNext(cat);
};
return (
<div className="glass-panel" style={{
maxWidth: "880px",
width: "100%",
margin: "0 auto",
padding: "36px 32px",
}}>
{/* Top Navigation Row */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px" }}>
<button
onClick={onBack}
className="btn-secondary"
style={{ padding: "8px 16px", fontSize: "13px" }}
>
<ArrowLeft size={15} />
<span>Change Language</span>
</button>
<div style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
background: "rgba(59, 130, 246, 0.12)",
color: "#60a5fa",
padding: "4px 14px",
borderRadius: "999px",
fontSize: "12px",
fontWeight: "600",
}}>
<Sparkles size={14} /> Step 2 of 3 • Tap a Department to Begin Voice Intake
</div>
</div>
<div style={{ textAlign: "center", marginBottom: "20px" }}>
<h2 style={{ fontSize: "28px", fontWeight: "800", marginBottom: "6px", letterSpacing: "-0.02em" }}>
Select Medical Specialty
</h2>
<p style={{ fontSize: "14px", color: "var(--text-secondary)", maxWidth: "560px", margin: "0 auto" }}>
Tap any department to immediately start your voice conversation with the AI Doctor Assistant.
</p>
</div>
{/* Indian Patient Profile & ABHA Card */}
<div style={{
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "16px",
padding: "14px 20px",
marginBottom: "24px",
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "10px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div style={{
width: "38px",
height: "38px",
borderRadius: "50%",
background: "linear-gradient(135deg, #06b6d4, #3b82f6)",
color: "#ffffff",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: "700",
}}>
<User size={19} />
</div>
<div>
<div style={{ fontSize: "14px", fontWeight: "700", display: "flex", alignItems: "center", gap: "8px" }}>
<span>{patient.name} ({patient.age}y, {patient.gender})</span>
<span style={{
fontSize: "11px",
padding: "2px 8px",
borderRadius: "999px",
background: "rgba(16, 185, 129, 0.15)",
color: "#34d399",
border: "1px solid rgba(16, 185, 129, 0.3)",
display: "flex",
alignItems: "center",
gap: "4px",
}}>
<ShieldCheck size={11} /> ABHA: {patient.abha_id || "91-5642-1289-4091"}
</span>
</div>
<div style={{ fontSize: "12px", color: "var(--text-muted)", marginTop: "2px" }}>
OPD Slip: {patient.id} | Scheme: {patient.insurance_scheme || "PM-JAY / Ayushman Bharat"}
</div>
</div>
</div>
<button
onClick={() => setShowEditPatient(!showEditPatient)}
className="btn-secondary"
style={{ padding: "6px 12px", fontSize: "12px" }}
>
{showEditPatient ? "Save Details" : "Edit ABHA & Info"}
</button>
</div>
{/* Expandable Patient Edit Form */}
{showEditPatient && (
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: "12px",
marginTop: "14px",
paddingTop: "14px",
borderTop: "1px solid rgba(255, 255, 255, 0.08)",
}}>
<div>
<label style={{ fontSize: "11px", color: "var(--text-muted)", display: "block", marginBottom: "4px" }}>
Patient Name
</label>
<input
type="text"
value={patient.name}
onChange={(e) => onUpdatePatient({ ...patient, name: e.target.value })}
style={{
width: "100%",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.15)",
borderRadius: "8px",
padding: "6px 10px",
color: "#fff",
fontSize: "13px",
}}
/>
</div>
<div>
<label style={{ fontSize: "11px", color: "var(--text-muted)", display: "block", marginBottom: "4px" }}>
ABHA ID
</label>
<input
type="text"
value={patient.abha_id || "91-5642-1289-4091"}
onChange={(e) => onUpdatePatient({ ...patient, abha_id: e.target.value })}
style={{
width: "100%",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.15)",
borderRadius: "8px",
padding: "6px 10px",
color: "#38bdf8",
fontSize: "13px",
}}
/>
</div>
<div>
<label style={{ fontSize: "11px", color: "var(--text-muted)", display: "block", marginBottom: "4px" }}>
Insurance / Scheme
</label>
<select
value={patient.insurance_scheme || "PM-JAY / Ayushman Bharat"}
onChange={(e) => onUpdatePatient({ ...patient, insurance_scheme: e.target.value })}
style={{
width: "100%",
background: "rgba(18, 24, 38, 0.9)",
border: "1px solid rgba(255, 255, 255, 0.15)",
borderRadius: "8px",
padding: "6px 10px",
color: "#fff",
fontSize: "13px",
}}
>
<option value="PM-JAY / Ayushman Bharat">PM-JAY / Ayushman Bharat</option>
<option value="Chief Minister Health Scheme (CMCHIS)">Chief Minister Health Scheme (CMCHIS)</option>
<option value="CGHS / ECHS">CGHS / ECHS Govt Scheme</option>
<option value="Private TPA Insurance">Private Insurance (Star / Care / HDFC)</option>
<option value="Cash / Self-Pay">Cash / Self-Pay</option>
</select>
</div>
</div>
)}
</div>
{/* Specialties Grid (Instant Launch on Click) */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
gap: "14px",
}}>
{SPECIALTIES.map((spec) => {
const isSelected = selectedSpecialty.code === spec.code;
return (
<button
key={spec.code}
onClick={() => handleCardClick(spec)}
style={{
background: isSelected
? "rgba(6, 182, 212, 0.14)"
: "rgba(255, 255, 255, 0.03)",
border: isSelected
? "2px solid #06b6d4"
: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "16px",
padding: "18px 16px",
cursor: "pointer",
textAlign: "left",
transition: "all 0.18s ease",
display: "flex",
flexDirection: "column",
gap: "8px",
boxShadow: isSelected ? "0 0 20px rgba(6, 182, 212, 0.25)" : "none",
outline: "none",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{
width: "40px",
height: "40px",
borderRadius: "10px",
background: "rgba(255, 255, 255, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
{spec.icon}
</div>
<div style={{
display: "flex",
alignItems: "center",
gap: "4px",
fontSize: "12px",
color: "#38bdf8",
fontWeight: "600",
}}>
<span>Start</span>
<ArrowRight size={14} />
</div>
</div>
<div>
<div style={{ fontSize: "16px", fontWeight: "700", color: "#ffffff" }}>
{spec.name}
</div>
<p style={{
fontSize: "12px",
color: "var(--text-secondary)",
margin: "4px 0 0 0",
lineHeight: "1.4",
}}>
{spec.focus}
</p>
</div>
</button>
);
})}
</div>
</div>
);
};
-110
View File
@@ -1,110 +0,0 @@
import React from "react";
import { Globe, Stethoscope, Mic } from "lucide-react";
interface StepProgressBarProps {
currentStep: number;
onStepClick?: (step: number) => void;
}
export const StepProgressBar: React.FC<StepProgressBarProps> = ({
currentStep,
onStepClick,
}) => {
const steps = [
{ num: 1, label: "Select Language", icon: <Globe size={16} /> },
{ num: 2, label: "Choose Specialty", icon: <Stethoscope size={16} /> },
{ num: 3, label: "AI Voice Intake", icon: <Mic size={16} /> },
];
return (
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 24px auto",
maxWidth: "580px",
width: "100%",
}}>
{steps.map((s, index) => {
const isActive = currentStep === s.num;
const isPassed = currentStep > s.num;
return (
<React.Fragment key={s.num}>
{/* Step Node */}
<div
onClick={() => {
if (isPassed && onStepClick) onStepClick(s.num);
}}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: isPassed ? "pointer" : "default",
opacity: isActive || isPassed ? 1 : 0.45,
transition: "all 0.3s ease",
}}
>
<div style={{
width: "36px",
height: "36px",
borderRadius: "50%",
background: isActive
? "linear-gradient(135deg, #06b6d4, #3b82f6)"
: isPassed
? "rgba(16, 185, 129, 0.2)"
: "rgba(255, 255, 255, 0.06)",
border: isActive
? "2px solid #38bdf8"
: isPassed
? "2px solid #10b981"
: "1px solid rgba(255, 255, 255, 0.12)",
color: isActive ? "#ffffff" : isPassed ? "#34d399" : "var(--text-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: isActive ? "0 0 16px rgba(6, 182, 212, 0.5)" : "none",
fontWeight: "700",
fontSize: "14px",
}}>
{s.icon}
</div>
<div style={{ textAlign: "left" }}>
<div style={{
fontSize: "11px",
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: "0.05em",
color: isActive ? "#38bdf8" : isPassed ? "#34d399" : "var(--text-muted)",
}}>
Step {s.num}
</div>
<div style={{
fontSize: "13px",
fontWeight: "600",
color: isActive ? "#f8fafc" : isPassed ? "#e2e8f0" : "var(--text-secondary)",
}}>
{s.label}
</div>
</div>
</div>
{/* Connector Line */}
{index < steps.length - 1 && (
<div style={{
flex: 1,
height: "2px",
margin: "0 12px",
background: currentStep > index + 1
? "linear-gradient(90deg, #10b981, #06b6d4)"
: "rgba(255, 255, 255, 0.1)",
transition: "all 0.3s ease",
}} />
)}
</React.Fragment>
);
})}
</div>
);
};
-274
View File
@@ -1,274 +0,0 @@
import React, { useState, useEffect } from 'react';
import { X, CheckCircle2, ShieldCheck, AlertCircle, FileSpreadsheet, Loader2, UserCheck, HeartPulse, Pill, Activity } from 'lucide-react';
import { completeSession } from '../services/api';
export default function SummaryModal({ sessionId, isVoiceMode, onClose, onSubmittedSuccessfully }) {
const [loading, setLoading] = useState(true);
const [summaryData, setSummaryData] = useState(null);
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [submittedSuccess, setSubmittedSuccess] = useState(false);
// Step A: Load Review Preview (confirmed: false)
useEffect(() => {
async function loadPreview() {
try {
setLoading(true);
const res = await completeSession(sessionId, false, isVoiceMode);
if (res && res.result) {
setSummaryData(res.result);
} else {
setSummaryData(res);
}
} catch (err) {
console.error('Failed to load review preview:', err);
setSummaryData({
patient_id: 'PAT-9842',
abha_id: '91-1234-5678-9012',
phone: '+91 98765 43210',
category: { type: 'speciality', code: 'cardio', name: 'Cardiology' },
chief_complaint: 'Acute onset retrosternal chest pain and dyspnea',
symptoms: [
{
name: 'Chest tightness',
duration: '1 day',
severity: 'Moderate to Severe',
description: 'Retrosternal pressure aggravated by exertion',
},
],
medical_history: ['Hypertension (5 years)'],
medications: ['Amlodipine 5mg OD'],
allergies: ['Penicillin'],
dietary_habits: 'Vegetarian',
additional_information: 'Non-smoker',
language: 'en-IN',
onboarding_completed: true,
});
} finally {
setLoading(false);
}
}
if (sessionId) {
loadPreview();
}
}, [sessionId, isVoiceMode]);
// Step B: Permanent Submission to EHR (confirmed: true)
const handleConfirmSubmit = async () => {
try {
setSubmitting(true);
setError(null);
const res = await completeSession(sessionId, true, isVoiceMode);
setSubmittedSuccess(true);
if (onSubmittedSuccessfully) {
onSubmittedSuccessfully(res);
}
} catch (err) {
console.error('EHR submission error:', err);
setError(err.message || 'Failed to submit clinical record to hospital EHR');
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md overflow-y-auto">
<div className="glass-card rounded-3xl max-w-2xl w-full p-6 sm:p-8 border border-slate-200/90 bg-white/95 shadow-2xl relative my-8 animate-in fade-in zoom-in-95 duration-200">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-slate-400 hover:text-slate-700 p-1.5 rounded-xl hover:bg-slate-100 transition-all"
>
<X className="h-5 w-5" />
</button>
{/* Modal Header */}
<div className="mb-6">
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-emerald-100 text-emerald-800 text-xs font-bold mb-2 border border-emerald-200">
<ShieldCheck className="h-3.5 w-3.5" />
<span>Structured EHR Intake Summary</span>
</div>
<h2 className="text-2xl font-extrabold text-slate-900">Review Clinical Onboarding Report</h2>
<p className="text-xs text-slate-600 mt-1 font-medium">
Please verify the AI-extracted clinical symptoms, active medications, and medical history before saving to hospital EHR.
</p>
</div>
{loading ? (
<div className="py-12 flex flex-col items-center justify-center space-y-3 text-slate-500">
<Loader2 className="h-8 w-8 animate-spin text-cyan-600" />
<span className="text-xs font-semibold">Extracting structured clinical entities...</span>
</div>
) : submittedSuccess ? (
/* Submission Success State */
<div className="py-8 text-center space-y-4">
<div className="h-16 w-16 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center mx-auto border border-emerald-200 glow-emerald">
<CheckCircle2 className="h-10 w-10" />
</div>
<div>
<h3 className="text-xl font-bold text-slate-900">Saved to Hospital EMR / EHR</h3>
<p className="text-xs text-slate-600 mt-1">
Patient record has been cleaned, finalized, and transmitted to central hospital servers.
</p>
</div>
<div className="pt-4">
<button
onClick={onClose}
className="px-6 py-2.5 rounded-xl bg-cyan-600 hover:bg-cyan-500 text-white font-bold text-xs shadow-lg shadow-cyan-600/20"
>
Close & Return to Dashboard
</button>
</div>
</div>
) : (
/* Structured Clinical Record Content */
<div className="space-y-5 text-xs">
{/* Patient Header Summary (Insurance Removed) */}
<div className="bg-sky-50/80 p-4 rounded-2xl border border-sky-100 grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<div className="text-slate-500 font-semibold">Patient ID</div>
<div className="font-mono font-bold text-cyan-900">{summaryData?.patient_id || 'PAT-9842'}</div>
</div>
<div>
<div className="text-slate-500 font-semibold">ABHA ID</div>
<div className="font-mono text-slate-800 font-medium">{summaryData?.abha_id || 'N/A'}</div>
</div>
<div>
<div className="text-slate-500 font-semibold">Clinical Specialty</div>
<div className="font-bold text-cyan-700">{summaryData?.category?.name || 'Cardiology'}</div>
</div>
</div>
{/* Chief Complaint */}
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-200/80">
<div className="font-bold text-slate-900 mb-1.5 flex items-center space-x-1.5 text-sm">
<HeartPulse className="h-4 w-4 text-rose-500" />
<span>Chief Complaint</span>
</div>
<p className="text-slate-800 leading-relaxed bg-white p-3 rounded-xl border border-slate-200 font-medium">
{summaryData?.chief_complaint || 'Patient reported retrosternal chest pain and breathlessness.'}
</p>
</div>
{/* Extracted Symptoms List */}
{summaryData?.symptoms && summaryData.symptoms.length > 0 && (
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-200/80">
<div className="font-bold text-slate-900 mb-2 flex items-center space-x-1.5 text-sm">
<Activity className="h-4 w-4 text-cyan-600" />
<span>Reported Symptoms ({summaryData.symptoms.length})</span>
</div>
<div className="space-y-2">
{summaryData.symptoms.map((sym, idx) => (
<div key={idx} className="p-3 rounded-xl bg-white border border-slate-200 flex items-start justify-between shadow-sm">
<div>
<div className="font-bold text-slate-900 text-xs">{sym.name}</div>
<div className="text-[11px] text-slate-500">{sym.description}</div>
</div>
<div className="text-right">
<span className="px-2 py-0.5 rounded bg-cyan-100 text-cyan-800 border border-cyan-200 text-[10px] font-mono font-semibold">
Severity: {sym.severity}
</span>
<div className="text-[10px] text-slate-500 font-medium mt-0.5">Duration: {sym.duration}</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Grid for History, Medications & Allergies */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Active Medications */}
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-200/80">
<div className="font-bold text-slate-900 mb-2 flex items-center space-x-1.5">
<Pill className="h-4 w-4 text-cyan-600" />
<span>Current Medications</span>
</div>
{summaryData?.medications?.length > 0 ? (
<ul className="space-y-1">
{summaryData.medications.map((med, idx) => (
<li key={idx} className="px-2.5 py-1.5 rounded-lg bg-sky-100 text-sky-900 font-mono text-[11px] border border-sky-200 font-medium">
{med}
</li>
))}
</ul>
) : (
<span className="text-slate-500 italic">None reported</span>
)}
</div>
{/* Allergies & Medical History */}
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-200/80 space-y-3">
<div>
<div className="font-bold text-slate-900 mb-1">Known Allergies</div>
{summaryData?.allergies?.length > 0 ? (
<div className="flex flex-wrap gap-1">
{summaryData.allergies.map((alg, idx) => (
<span key={idx} className="px-2 py-0.5 rounded bg-rose-100 text-rose-800 border border-rose-200 text-[10px] font-mono font-semibold">
{alg}
</span>
))}
</div>
) : (
<span className="text-slate-500 italic">No known drug allergies</span>
)}
</div>
<div>
<div className="font-bold text-slate-900 mb-1">Past Medical History</div>
{summaryData?.medical_history?.length > 0 ? (
<div className="text-slate-800 font-medium">
{summaryData.medical_history.join(', ')}
</div>
) : (
<span className="text-slate-500 italic">None reported</span>
)}
</div>
</div>
</div>
{error && (
<div className="p-3 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 text-xs flex items-center space-x-2">
<AlertCircle className="h-4 w-4 shrink-0 text-rose-600" />
<span>{error}</span>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-4 py-2.5 rounded-xl font-semibold text-slate-600 hover:text-slate-900 hover:bg-slate-100 transition-all text-xs"
>
Back to Session
</button>
<button
type="button"
onClick={handleConfirmSubmit}
disabled={submitting}
className="px-6 py-2.5 rounded-xl font-extrabold text-white bg-gradient-to-r from-emerald-600 via-teal-600 to-cyan-600 hover:from-emerald-500 hover:to-cyan-500 shadow-lg shadow-emerald-600/25 flex items-center space-x-2 text-xs transition-all disabled:opacity-50"
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Transmitting to Hospital EMR...</span>
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" />
<span>Confirm & Permanent Submit to EHR</span>
</>
)}
</button>
</div>
</div>
)}
</div>
</div>
);
}
-787
View File
@@ -1,787 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { Mic, MicOff, Send, Volume2, VolumeX, FileText, CheckCircle2, Bot, User, Sparkles, Loader2, Radio, Zap, Volume, MessageSquare } from 'lucide-react';
import { resolveAudioUrl } from '../services/api';
export default function VoiceChat({
session,
isVoiceMode,
onSendTurn,
onOpenDocumentUploader,
onCompleteSession,
isProcessingTurn,
documentsCount,
}) {
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [textInput, setTextInput] = useState('');
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
const [autoTalkMode, setAutoTalkMode] = useState(true); // Hands-free continuous call mode
const [viewMode, setViewMode] = useState('audio_only'); // 'audio_only' or 'transcript'
const [turnHistory, setTurnHistory] = useState([]);
const [statusMessage, setStatusMessage] = useState('Idle');
const [isCompletedSession, setIsCompletedSession] = useState(false);
// Session completion check
const isCompleted = Boolean(isCompletedSession || session?.completed || session?.stage === 'completed' || session?.stage === 'finished');
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const timerRef = useRef(null);
const audioPlayerRef = useRef(null);
const chatBottomRef = useRef(null);
// VAD & Silence Detection Refs
const silenceAnimFrameRef = useRef(null);
const audioContextRef = useRef(null);
const autoTalkRef = useRef(autoTalkMode);
const isRecordingRef = useRef(isRecording);
const isCompletedRef = useRef(isCompleted);
const autoListenTimerRef = useRef(null);
const lastPlayedUrlRef = useRef({ url: null, timestamp: 0 });
useEffect(() => {
if (typeof window !== 'undefined') {
audioPlayerRef.current = new Audio();
}
}, []);
// Keep refs in sync for callbacks
useEffect(() => {
autoTalkRef.current = autoTalkMode;
}, [autoTalkMode]);
useEffect(() => {
isRecordingRef.current = isRecording;
}, [isRecording]);
useEffect(() => {
isCompletedRef.current = isCompleted;
if (isCompleted) {
setAutoTalkMode(false);
if (isRecordingRef.current) {
stopRecording();
}
}
}, [isCompleted]);
// Clean up AudioContext & timers on unmount
useEffect(() => {
return () => {
if (silenceAnimFrameRef.current) cancelAnimationFrame(silenceAnimFrameRef.current);
if (timerRef.current) clearInterval(timerRef.current);
if (audioContextRef.current) audioContextRef.current.close().catch(() => {});
};
}, []);
// Audio Playback Listener setup
useEffect(() => {
const audioEl = audioPlayerRef.current;
if (!audioEl) return;
const handleEnded = () => {
setIsPlayingAudio(false);
setStatusMessage('Finished speaking');
// Auto-start listening in Auto-Talk mode if session is in progress and not completed
if (autoTalkRef.current && isVoiceMode && !isRecordingRef.current && !isCompletedRef.current) {
setStatusMessage('Auto-listening...');
if (autoListenTimerRef.current) clearTimeout(autoListenTimerRef.current);
autoListenTimerRef.current = setTimeout(() => {
startRecording();
}, 600);
}
};
audioEl.addEventListener('ended', handleEnded);
return () => {
audioEl.removeEventListener('ended', handleEnded);
};
}, [isVoiceMode]);
// Initialize turn history ONLY when session_id changes (new session initialized)
useEffect(() => {
if (session?.session_id) {
const initialGreeting = isVoiceMode ? session.greeting_text : session.greeting_message;
setTurnHistory([
{
id: `turn-0-${session.session_id}`,
sender: 'ai',
text: initialGreeting || 'Welcome to AI Patient Onboarding. How can I assist you today?',
audioUrl: session.audio_url || null,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
},
]);
// Auto-play initial greeting audio if available
if (session.audio_url) {
playAudio(session.audio_url);
}
}
}, [session?.session_id]);
// Scroll chat to bottom on updates
useEffect(() => {
chatBottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [turnHistory, isProcessingTurn, isPlayingAudio, isRecording]);
// Handle Audio Recording Start with Web Audio API Voice Activity Detection (VAD)
const startRecording = async () => {
if (isRecordingRef.current || isCompletedRef.current) return;
isRecordingRef.current = true;
try {
audioChunksRef.current = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
// Web Audio API VAD (Volume & Silence meter)
const AudioCtx = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioCtx();
audioContextRef.current = audioCtx;
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
let hasSpoken = false;
let silenceStart = null;
const detectVoiceActivity = () => {
if (!mediaRecorderRef.current || mediaRecorderRef.current.state !== 'recording') return;
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i];
}
const avgVolume = sum / bufferLength;
// Speech detected (volume above threshold)
if (avgVolume > 10) {
hasSpoken = true;
silenceStart = null;
} else if (hasSpoken) {
// Patient has spoken and volume dropped to silence
if (!silenceStart) {
silenceStart = Date.now();
} else if (Date.now() - silenceStart > 1600) { // 1.6s continuous silence after speech
console.log('Silence detected after speech -> Auto-stopping and submitting recording');
stopRecording();
return;
}
}
silenceAnimFrameRef.current = requestAnimationFrame(detectVoiceActivity);
};
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
if (silenceAnimFrameRef.current) cancelAnimationFrame(silenceAnimFrameRef.current);
if (audioContextRef.current) {
audioContextRef.current.close().catch(() => {});
}
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
// Stop all audio tracks
stream.getTracks().forEach((track) => track.stop());
await processTurn(audioBlob, null);
};
mediaRecorder.start(200);
setIsRecording(true);
setRecordingTime(0);
setStatusMessage('Listening for your response...');
detectVoiceActivity();
timerRef.current = setInterval(() => {
setRecordingTime((prev) => {
// Safeguard: auto-stop after 12 seconds if continuous speech
if (prev >= 12) {
stopRecording();
return prev;
}
return prev + 1;
});
}, 1000);
} catch (err) {
isRecordingRef.current = false;
setIsRecording(false);
console.warn('Microphone access unavailable or denied:', err);
setStatusMessage('Microphone access unavailable');
}
};
// Handle Audio Recording Stop
const stopRecording = () => {
isRecordingRef.current = false;
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (timerRef.current) clearInterval(timerRef.current);
if (silenceAnimFrameRef.current) cancelAnimationFrame(silenceAnimFrameRef.current);
setStatusMessage('Processing speech turn...');
}
};
// Submit Turn (Voice Blob or Text Fallback)
const processTurn = async (audioBlob = null, textFallback = null) => {
if (isCompletedRef.current) return;
const displayText = textFallback || (audioBlob ? '🎙️ Voice Recording' : '');
if (!displayText && !audioBlob) return;
// Add user turn to state
const userTurn = {
id: `user-${Date.now()}`,
sender: 'user',
text: displayText,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
};
setTurnHistory((prev) => [...prev, userTurn]);
setTextInput('');
try {
const actualTextFallback = (textFallback && typeof textFallback === 'string' && textFallback.trim()) ? textFallback.trim() : null;
const response = await onSendTurn({ audioBlob, textFallback: actualTextFallback });
if (response && response.success) {
const aiResponseText = response.ai_text || response.message || 'I have recorded your response.';
const aiTurn = {
id: `ai-${Date.now()}`,
sender: 'ai',
text: aiResponseText,
patientTranscript: response.patient_transcript,
audioUrl: response.audio_url || null,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
};
setTurnHistory((prev) => [...prev, aiTurn]);
if (response.completed || response.stage === 'completed' || response.stage === 'finished') {
setIsCompletedSession(true);
setAutoTalkMode(false);
}
if (response.audio_url) {
playAudio(response.audio_url);
} else if (autoTalkRef.current && !response.completed) {
// If no audio returned, auto-start mic for next turn
setTimeout(() => startRecording(), 800);
}
}
} catch (err) {
console.error('Error submitting turn:', err);
}
};
const handleTextSubmit = (e) => {
e.preventDefault();
if (!textInput.trim() || isProcessingTurn || isCompleted) return;
processTurn(null, textInput.trim());
};
// Audio Playback Handler with deduplication to prevent double audio playback
const playAudio = async (audioUrlOrBase64, retryCount = 0) => {
let fullUrl = audioUrlOrBase64;
if (!fullUrl) return;
if (!fullUrl.startsWith('data:audio')) {
fullUrl = resolveAudioUrl(fullUrl);
}
if (!fullUrl) return;
// Deduplicate rapid play calls within 2 seconds for the exact same audio URL
const now = Date.now();
if (
lastPlayedUrlRef.current.url === fullUrl &&
now - lastPlayedUrlRef.current.timestamp < 2000 &&
retryCount === 0
) {
console.log(`[VoiceChat Audio] Prevented duplicate audio playback for: ${fullUrl}`);
return;
}
lastPlayedUrlRef.current = { url: fullUrl, timestamp: now };
console.log(`[VoiceChat Audio] Playing URL: ${fullUrl}`);
try {
setStatusMessage('AI Speaking...');
if (!audioPlayerRef.current && typeof window !== 'undefined') {
audioPlayerRef.current = new Audio();
}
const player = audioPlayerRef.current;
player.pause();
player.currentTime = 0;
player.src = fullUrl;
player.onended = () => {
setIsPlayingAudio(false);
setStatusMessage('Finished speaking');
if (autoTalkRef.current && isVoiceMode && !isRecordingRef.current && !isCompletedRef.current) {
setStatusMessage('Auto-listening...');
if (autoListenTimerRef.current) clearTimeout(autoListenTimerRef.current);
autoListenTimerRef.current = setTimeout(() => startRecording(), 600);
}
};
const playPromise = player.play();
if (playPromise !== undefined) {
await playPromise;
setIsPlayingAudio(true);
console.log(`[VoiceChat Audio SUCCESS] Audio playing: ${fullUrl}`);
}
} catch (e) {
if (e.name === 'AbortError') {
return;
}
setIsPlayingAudio(false);
if (e.name === 'NotAllowedError') {
console.warn('[VoiceChat Audio] Autoplay blocked by browser policy:', e);
setStatusMessage('Click to play audio');
} else if (retryCount < 3) {
console.log(`[VoiceChat Audio Retry] Retrying playback in 500ms... (attempt ${retryCount + 1})`);
setTimeout(() => playAudio(audioUrlOrBase64, retryCount + 1), 500);
} else {
console.error('[VoiceChat Audio ERROR] Playback failed after retries:', e);
}
}
};
const stopAudio = () => {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
}
setIsPlayingAudio(false);
setStatusMessage('Audio paused');
};
// Latest AI response for Voice-Only Call View
const lastAiTurn = turnHistory.filter((t) => t.sender === 'ai').slice(-1)[0];
const lastUserTurn = turnHistory.filter((t) => t.sender === 'user').slice(-1)[0];
return (
<div className="max-w-5xl mx-auto my-6 px-4">
<div className="glass-card rounded-3xl border border-slate-200/90 shadow-2xl bg-white/95 flex flex-col h-[720px] relative overflow-hidden">
{/* Chat Header Bar */}
<div className="p-4 border-b border-slate-200/80 bg-white/90 backdrop-blur-md flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="h-10 w-10 rounded-2xl bg-gradient-to-tr from-cyan-500 via-sky-500 to-indigo-600 flex items-center justify-center text-white font-bold shadow-md shadow-cyan-500/20">
<Bot className="h-5 w-5" />
</div>
<div>
<div className="flex items-center space-x-2">
<span className="font-extrabold text-sm text-slate-900">
{session?.patient?.name || 'Patient'} Onboarding Assistant
</span>
<span className="px-2 py-0.5 text-[10px] font-mono font-bold rounded-md bg-cyan-100 text-cyan-900 border border-cyan-200">
{session?.language || 'en-IN'}
</span>
{isCompleted && (
<span className="px-2.5 py-0.5 text-[10px] font-extrabold rounded-full bg-emerald-100 text-emerald-800 border border-emerald-300">
Session Completed
</span>
)}
</div>
<p className="text-xs text-slate-500 font-medium">
Specialty: <span className="text-cyan-700 font-bold">{typeof session?.category === 'string' ? session.category : session?.category?.name || session?.category?.code || 'Cardiology'}</span>
</p>
</div>
</div>
<div className="flex items-center space-x-2">
{/* View Mode Toggle: Audio Only vs Full Transcript */}
{!isCompleted && (
<button
onClick={() => setViewMode(viewMode === 'audio_only' ? 'transcript' : 'audio_only')}
className="px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 border border-slate-200 text-xs font-bold text-slate-700 flex items-center space-x-1.5 transition-all shadow-sm"
title="Toggle between Voice Call mode and Full Text Transcript"
>
{viewMode === 'audio_only' ? (
<>
<MessageSquare className="h-3.5 w-3.5 text-cyan-600" />
<span>Show Transcript</span>
</>
) : (
<>
<Volume className="h-3.5 w-3.5 text-cyan-600" />
<span>Audio Only View</span>
</>
)}
</button>
)}
{/* Auto-Talk Continuous Mode Toggle */}
{isVoiceMode && !isCompleted && (
<button
onClick={() => setAutoTalkMode(!autoTalkMode)}
className={`px-3 py-1.5 rounded-xl border text-xs font-extrabold flex items-center space-x-1.5 transition-all shadow-sm ${
autoTalkMode
? 'bg-emerald-50 border-emerald-300 text-emerald-900'
: 'bg-slate-100 border-slate-200 text-slate-500 hover:text-slate-800'
}`}
title="Continuous hands-free conversation (Auto-listen & auto-send on silence)"
>
<Zap className={`h-3.5 w-3.5 ${autoTalkMode ? 'text-emerald-600 animate-pulse' : ''}`} />
<span>Hands-Free Auto-Talk: {autoTalkMode ? 'ON' : 'OFF'}</span>
</button>
)}
{/* Document Uploader Trigger */}
<button
onClick={onOpenDocumentUploader}
className="px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 border border-slate-200 text-xs font-bold text-slate-800 flex items-center space-x-1.5 transition-all shadow-sm"
>
<FileText className="h-3.5 w-3.5 text-cyan-600" />
<span>Prescription OCR</span>
{documentsCount > 0 && (
<span className="ml-1 px-1.5 py-0.2 rounded-full bg-cyan-600 text-white font-mono text-[10px]">
{documentsCount}
</span>
)}
</button>
{/* Complete & Confirm Session Button */}
<button
onClick={() => onCompleteSession(false)}
className="px-4 py-1.5 rounded-xl bg-gradient-to-r from-emerald-500 via-teal-500 to-cyan-600 hover:from-emerald-600 hover:to-cyan-700 text-white text-xs font-extrabold flex items-center space-x-1.5 shadow-md shadow-emerald-500/20 transition-all"
>
<CheckCircle2 className="h-4 w-4" />
<span>Review & Submit EHR</span>
</button>
</div>
</div>
{/* Dynamic Voice Activity Bar */}
<div className="px-4 py-2 bg-sky-50/80 border-b border-sky-100 flex items-center justify-between text-xs">
<div className="flex items-center space-x-2">
{isCompleted ? (
<div className="flex items-center space-x-2 text-emerald-800 font-extrabold">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span>Onboarding Completed — Ready for EHR Submission</span>
</div>
) : isPlayingAudio ? (
<div className="flex items-center space-x-2 text-cyan-800 font-bold">
<Volume2 className="h-4 w-4 text-cyan-600 animate-bounce" />
<span>AI Assistant Speaking...</span>
</div>
) : isRecording ? (
<div className="flex items-center space-x-2 text-rose-700 font-bold">
<Radio className="h-4 w-4 text-rose-600 animate-pulse" />
<span>Microphone Active — Speak Now ({recordingTime}s)</span>
</div>
) : isProcessingTurn ? (
<div className="flex items-center space-x-2 text-indigo-800 font-bold">
<Loader2 className="h-4 w-4 text-indigo-600 animate-spin" />
<span>Analyzing Symptoms & Synthesizing Reply...</span>
</div>
) : (
<div className="flex items-center space-x-2 text-slate-600 font-medium">
<Sparkles className="h-3.5 w-3.5 text-cyan-600" />
<span>Ready for turn</span>
</div>
)}
</div>
<div className="text-[11px] font-mono text-slate-500 font-semibold">
Session: <span className="text-cyan-900 font-bold">{session?.session_id}</span>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 flex flex-col justify-center items-center bg-gradient-to-b from-slate-50/50 via-white to-sky-50/30">
{/* VIEW A: Completed Session Screen (Shows Send EHR to Hospital Button) */}
{isCompleted ? (
<div className="my-auto text-center max-w-lg p-8 rounded-3xl bg-white border border-emerald-200 shadow-2xl space-y-6">
<div className="h-20 w-20 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center mx-auto border border-emerald-200 shadow-xl shadow-emerald-500/10">
<CheckCircle2 className="h-10 w-10 text-emerald-600" />
</div>
<div>
<span className="px-3 py-1 rounded-full bg-emerald-100 text-emerald-800 text-xs font-extrabold border border-emerald-200">
Patient Intake Completed
</span>
<h3 className="text-2xl font-extrabold text-slate-900 mt-3">Ready to Submit to Hospital</h3>
<p className="text-xs text-slate-600 mt-2 leading-relaxed font-medium">
All clinical symptoms, medical history, and prescription findings have been gathered for <span className="text-cyan-800 font-bold">{session?.patient?.name || 'Patient'}</span>.
</p>
</div>
{/* Primary Action Buttons */}
<div className="pt-4 space-y-3">
<button
onClick={() => onCompleteSession(true)}
className="w-full py-4 px-6 rounded-2xl text-sm font-extrabold text-white bg-gradient-to-r from-emerald-500 via-teal-500 to-cyan-600 hover:from-emerald-600 hover:to-cyan-700 shadow-xl shadow-emerald-500/30 flex items-center justify-center space-x-2 transition-all transform active:scale-95 cursor-pointer"
>
<Send className="h-5 w-5" />
<span>Send EHR to Hospital</span>
</button>
<button
onClick={() => onCompleteSession(false)}
className="w-full py-3 px-5 rounded-xl text-xs font-bold text-slate-700 bg-slate-100 hover:bg-slate-200 border border-slate-200 flex items-center justify-center space-x-1.5 transition-all cursor-pointer"
>
<FileText className="h-4 w-4 text-cyan-600" />
<span>Review Clinical Intake Report</span>
</button>
</div>
</div>
) : viewMode === 'audio_only' ? (
/* VIEW B: Audio Only Call View (Pure Voice Interactive Orb) */
<div className="my-auto flex flex-col items-center justify-center text-center max-w-xl space-y-8 py-4">
{/* Autoplay Blocked Tap Banner */}
{statusMessage === 'Click to play audio' && lastAiTurn?.audioUrl && (
<button
onClick={() => playAudio(lastAiTurn.audioUrl)}
className="w-full max-w-md py-3 px-4 rounded-2xl bg-amber-500 hover:bg-amber-600 text-white font-extrabold text-sm flex items-center justify-center space-x-2 shadow-lg animate-bounce transition-all cursor-pointer"
>
<Volume2 className="h-5 w-5" />
<span>🔊 Tap to Enable & Play AI Voice Response</span>
</button>
)}
{/* Interactive 3D Glowing Voice Orb Visualizer */}
<div className="relative flex items-center justify-center">
<div
className={`w-40 h-40 rounded-full flex items-center justify-center transition-all duration-500 ${
isPlayingAudio
? 'bg-gradient-to-tr from-cyan-400 via-sky-400 to-indigo-600 animate-pulse shadow-2xl shadow-cyan-500/40 scale-105'
: isRecording
? 'bg-gradient-to-tr from-rose-500 via-pink-500 to-red-600 animate-pulse shadow-2xl shadow-rose-500/40 scale-105'
: isProcessingTurn
? 'bg-gradient-to-tr from-indigo-500 via-purple-500 to-sky-500 animate-spin shadow-2xl shadow-indigo-500/40'
: 'bg-white border-4 border-slate-200 shadow-xl'
}`}
>
<div className="w-32 h-32 rounded-full bg-white flex items-center justify-center shadow-inner">
{isPlayingAudio ? (
<Volume2 className="h-12 w-12 text-cyan-600 animate-bounce" />
) : isRecording ? (
<Mic className="h-12 w-12 text-rose-500 animate-pulse" />
) : isProcessingTurn ? (
<Loader2 className="h-12 w-12 text-indigo-600 animate-spin" />
) : (
<Bot className="h-12 w-12 text-cyan-600" />
)}
</div>
</div>
{/* Animated Ripple Waves */}
{(isPlayingAudio || isRecording) && (
<>
<div className="absolute inset-0 rounded-full border-2 border-cyan-400/50 animate-ping" />
<div className="absolute -inset-4 rounded-full border border-cyan-400/30 animate-pulse" />
</>
)}
</div>
{/* Status Header */}
<div>
<div className="text-xl font-extrabold text-slate-900">
{isPlayingAudio
? 'AI Doctor Speaking...'
: isRecording
? 'Listening to Patient...'
: isProcessingTurn
? 'Thinking & Processing...'
: 'Voice Onboarding Call'}
</div>
<p className="text-xs text-slate-600 mt-1 max-w-md font-medium">
{isPlayingAudio
? 'Listen to spoken instructions via audio output'
: isRecording
? 'Speak your symptoms into microphone'
: 'Hands-free automated clinical intake session'}
</p>
</div>
{/* Spoken AI Audio Controls */}
{lastAiTurn && (
<div className="w-full bg-white p-5 rounded-3xl border border-slate-200/90 shadow-lg space-y-2 text-left">
<div className="flex items-center justify-between text-xs">
<span className="font-extrabold text-cyan-800 flex items-center space-x-1.5">
<Bot className="h-4 w-4 text-cyan-600" />
<span>Current AI Question:</span>
</span>
{lastAiTurn.audioUrl && (
<button
onClick={() => (isPlayingAudio ? stopAudio() : playAudio(lastAiTurn.audioUrl))}
className="px-3 py-1 rounded-xl bg-cyan-50 hover:bg-cyan-100 border border-cyan-200 text-cyan-900 font-bold text-[11px] flex items-center space-x-1 shadow-sm transition-all"
>
{isPlayingAudio ? <VolumeX className="h-3.5 w-3.5 text-rose-600" /> : <Volume2 className="h-3.5 w-3.5 text-cyan-600" />}
<span>{isPlayingAudio ? 'Pause Voice' : 'Replay Voice'}</span>
</button>
)}
</div>
<p className="text-sm text-slate-900 leading-relaxed font-semibold">"{lastAiTurn.text}"</p>
</div>
)}
</div>
) : (
/* VIEW C: Full Detailed Text Transcript */
<div className="w-full flex-1 overflow-y-auto space-y-4 pr-1">
{turnHistory.map((turn) => {
const isAI = turn.sender === 'ai';
return (
<div
key={turn.id}
className={`flex items-start space-x-3 ${isAI ? 'justify-start' : 'justify-end'}`}
>
{isAI && (
<div className="h-9 w-9 rounded-2xl bg-cyan-100 border border-cyan-200 flex items-center justify-center text-cyan-700 shrink-0 mt-1 shadow-sm">
<Bot className="h-5 w-5" />
</div>
)}
<div
className={`max-w-xl rounded-3xl p-4 text-sm leading-relaxed shadow-md ${
isAI
? 'bg-white border border-slate-200/90 text-slate-900 rounded-tl-none'
: 'bg-gradient-to-r from-cyan-600 to-indigo-600 text-white font-medium rounded-tr-none shadow-cyan-500/20'
}`}
>
{turn.patientTranscript && (
<div className="mb-2 text-xs italic text-cyan-900 bg-cyan-50 p-2.5 rounded-xl border border-cyan-200 font-medium">
<span className="font-bold text-cyan-950">Recognized Patient Speech:</span> "{turn.patientTranscript}"
</div>
)}
<p className="font-medium">{turn.text}</p>
<div className="mt-2 flex items-center justify-between text-[10px] opacity-80 font-medium">
<span>{turn.timestamp}</span>
{/* Audio Playback Button for AI Responses */}
{isAI && turn.audioUrl && (
<button
onClick={() => (isPlayingAudio ? stopAudio() : playAudio(turn.audioUrl))}
className="ml-2 px-2.5 py-1 rounded-lg bg-sky-100 hover:bg-sky-200 border border-sky-200 text-sky-900 flex items-center space-x-1 transition-all font-bold"
>
{isPlayingAudio ? (
<>
<VolumeX className="h-3 w-3 text-rose-600" />
<span>Pause Audio</span>
</>
) : (
<>
<Volume2 className="h-3 w-3 text-cyan-600" />
<span>Replay Audio</span>
</>
)}
</button>
)}
</div>
</div>
{!isAI && (
<div className="h-9 w-9 rounded-2xl bg-gradient-to-tr from-cyan-600 to-indigo-600 flex items-center justify-center text-white font-bold shrink-0 mt-1 shadow-md">
<User className="h-5 w-5" />
</div>
)}
</div>
);
})}
{isProcessingTurn && (
<div className="flex items-center space-x-3 text-slate-600 text-xs py-2.5 px-4 bg-white rounded-2xl w-max border border-slate-200 shadow-sm font-semibold">
<Loader2 className="h-4 w-4 animate-spin text-cyan-600" />
<span>AI is generating response & synthesizing speech audio...</span>
</div>
)}
<div ref={chatBottomRef} />
</div>
)}
</div>
{/* Input Bar / Controls (HIDDEN when Session is Completed) */}
{!isCompleted && (
<div className="p-4 border-t border-slate-200/90 bg-white space-y-3">
{/* Voice Microphone Recorder Controls */}
{isVoiceMode && (
<div className="flex items-center justify-between bg-slate-50 p-3 rounded-2xl border border-slate-200">
<div className="flex items-center space-x-3">
<button
type="button"
onClick={isRecording ? stopRecording : startRecording}
disabled={isProcessingTurn || isPlayingAudio}
className={`h-12 w-12 rounded-full flex items-center justify-center transition-all transform active:scale-95 shadow-xl cursor-pointer ${
isRecording
? 'bg-rose-500 hover:bg-rose-600 text-white animate-pulse shadow-rose-500/40 ring-4 ring-rose-500/20'
: 'bg-gradient-to-tr from-cyan-500 to-indigo-600 hover:from-cyan-400 hover:to-indigo-500 text-white shadow-cyan-500/30'
}`}
>
{isRecording ? <MicOff className="h-6 w-6" /> : <Mic className="h-6 w-6" />}
</button>
<div>
<div className="text-xs font-extrabold text-slate-900">
{isRecording
? `Recording Speech... (${recordingTime}s)`
: autoTalkMode
? 'Auto-Silence VAD Active (Mic sends audio automatically when you pause)'
: 'Tap Microphone to Speak'}
</div>
<div className="text-[11px] text-slate-500 font-medium">
{isRecording
? 'Speak your symptoms — audio will auto-send after 1.6s of silence'
: 'Sarvam AI multi-model Indic speech recognition'}
</div>
</div>
</div>
{/* Animated Waveform Bars when Recording */}
{isRecording && (
<div className="flex items-center space-x-1 px-4">
<div className="wave-bar" />
<div className="wave-bar" />
<div className="wave-bar" />
<div className="wave-bar" />
<div className="wave-bar" />
</div>
)}
</div>
)}
{/* Text Input Fallback Form */}
<form onSubmit={handleTextSubmit} className="flex items-center space-x-2">
<input
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
disabled={isProcessingTurn || isRecording}
placeholder={
isVoiceMode
? 'Or type your symptoms here (Text Fallback)...'
: 'Type your message or answer here...'
}
className="flex-1 glass-input px-4 py-3 rounded-2xl text-sm font-medium"
/>
<button
type="submit"
disabled={!textInput.trim() || isProcessingTurn || isRecording}
className="px-5 py-3 rounded-2xl bg-gradient-to-r from-cyan-600 to-indigo-600 hover:from-cyan-500 hover:to-indigo-500 disabled:opacity-40 text-white font-bold text-sm flex items-center space-x-1.5 transition-all shadow-md shadow-cyan-600/20 cursor-pointer"
>
<span>Send</span>
<Send className="h-4 w-4" />
</button>
</form>
</div>
)}
</div>
</div>
);
}
-196
View File
@@ -1,196 +0,0 @@
import React from "react";
import { Mic, Volume2, Sparkles, CheckCircle2, AlertCircle } from "lucide-react";
import { VoiceState } from "../types/voice";
interface VoiceOrbProps {
state: VoiceState;
audioLevel: number;
isSpeechDetected?: boolean;
onClick: () => void;
}
export const VoiceOrb: React.FC<VoiceOrbProps> = ({
state,
audioLevel,
onClick,
}) => {
const getOrbVisuals = () => {
switch (state) {
case "listening":
return {
gradient: "radial-gradient(circle, #f43f5e 0%, #be123c 100%)",
glow: "rgba(244, 63, 94, 0.6)",
icon: <Mic size={42} color="#ffffff" className="animate-pulse" />,
statusText: "Listening to you...",
subText: "Speak naturally, AI will respond automatically",
color: "#f43f5e",
};
case "ai_speaking":
return {
gradient: "radial-gradient(circle, #06b6d4 0%, #2563eb 100%)",
glow: "rgba(6, 182, 212, 0.7)",
icon: <Volume2 size={42} color="#ffffff" />,
statusText: "Assistant Speaking...",
subText: "Mic activates automatically when speech ends",
color: "#06b6d4",
};
case "processing":
return {
gradient: "radial-gradient(circle, #8b5cf6 0%, #6d28d9 100%)",
glow: "rgba(139, 92, 246, 0.7)",
icon: <Sparkles size={42} color="#ffffff" className="animate-spin-slow" />,
statusText: "Thinking...",
subText: "Analyzing symptoms & clinical details",
color: "#8b5cf6",
};
case "confirmation":
return {
gradient: "radial-gradient(circle, #10b981 0%, #047857 100%)",
glow: "rgba(16, 185, 129, 0.6)",
icon: <CheckCircle2 size={42} color="#ffffff" />,
statusText: "Intake Complete",
subText: "Review your summary below to confirm",
color: "#10b981",
};
case "completed":
return {
gradient: "radial-gradient(circle, #10b981 0%, #065f46 100%)",
glow: "rgba(16, 185, 129, 0.5)",
icon: <CheckCircle2 size={44} color="#ffffff" />,
statusText: "Saved to Hospital Record",
subText: "Transferred to OPD Consultation Queue",
color: "#10b981",
};
case "error":
return {
gradient: "radial-gradient(circle, #ef4444 0%, #991b1b 100%)",
glow: "rgba(239, 68, 68, 0.6)",
icon: <AlertCircle size={42} color="#ffffff" />,
statusText: "Tap to Reconnect",
subText: "Reconnecting voice intake channel",
color: "#ef4444",
};
default:
return {
gradient: "radial-gradient(circle, #06b6d4 0%, #3b82f6 100%)",
glow: "rgba(6, 182, 212, 0.4)",
icon: <Mic size={42} color="#ffffff" />,
statusText: "Hands-Free Voice Active",
subText: "Speak naturally to start conversation",
color: "#06b6d4",
};
}
};
const visuals = getOrbVisuals();
const scaleByAudio = state === "listening"
? 1 + Math.min(0.22, (audioLevel / 220))
: state === "ai_speaking"
? 1.05
: 1;
return (
<div style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "24px 0 16px 0",
position: "relative",
}}>
{/* Orb Outer Container */}
<div style={{ position: "relative", width: "170px", height: "170px", margin: "20px 0" }}>
{/* Multi-layered dynamic aura glow */}
<div
style={{
position: "absolute",
inset: "-24px",
borderRadius: "50%",
background: visuals.glow,
filter: "blur(36px)",
opacity: state === "listening" || state === "ai_speaking" ? 0.95 : 0.4,
transition: "all 0.3s ease",
transform: `scale(${scaleByAudio})`,
}}
/>
{/* Dynamic Sound Ripple Aura */}
{(state === "listening" || state === "ai_speaking") && (
<>
<div
style={{
position: "absolute",
inset: "-16px",
borderRadius: "50%",
border: `2px solid ${visuals.color}`,
opacity: 0.6,
animation: "pulse-slow 2s infinite ease-out",
transform: `scale(${scaleByAudio})`,
}}
/>
<div
style={{
position: "absolute",
inset: "-32px",
borderRadius: "50%",
border: `1px solid ${visuals.color}`,
opacity: 0.3,
animation: "pulse-slow 2s 0.6s infinite ease-out",
}}
/>
</>
)}
{/* Clean, Modern Floating Voice Sphere */}
<button
onClick={onClick}
aria-label={visuals.statusText}
style={{
position: "relative",
width: "170px",
height: "170px",
borderRadius: "50%",
background: visuals.gradient,
border: "2px solid rgba(255, 255, 255, 0.35)",
boxShadow: `0 0 50px ${visuals.glow}, inset 0 0 30px rgba(255, 255, 255, 0.4)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
outline: "none",
transition: "transform 0.15s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease",
transform: `scale(${scaleByAudio})`,
}}
>
{visuals.icon}
</button>
</div>
{/* State Text & Feedback */}
<div style={{ textAlign: "center", marginTop: "12px" }}>
<h2 style={{
fontSize: "22px",
fontWeight: "700",
color: visuals.color,
marginBottom: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
letterSpacing: "-0.01em",
}}>
{visuals.statusText}
</h2>
<p style={{
fontSize: "13px",
color: "var(--text-secondary)",
maxWidth: "380px",
lineHeight: "1.4",
margin: "0 auto",
}}>
{visuals.subText}
</p>
</div>
</div>
);
};
-388
View File
@@ -1,388 +0,0 @@
import React, { useState, useEffect } from "react";
import { Volume2, Sliders, X, Sparkles, Check, Play, Square } from "lucide-react";
import {
getAvailableIndianVoices,
setSelectedVoiceURI,
getSelectedVoiceURI,
getVoiceRate,
setVoiceRate,
getVoicePitch,
setVoicePitch,
testIndianAccentSample,
stopNeuralSpeech,
} from "../services/voiceSynthesis";
interface VoiceSettingsModalProps {
isOpen: boolean;
onClose: () => void;
language: string;
}
export const VoiceSettingsModal: React.FC<VoiceSettingsModalProps> = ({
isOpen,
onClose,
language,
}) => {
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const [selectedURI, setSelectedURI] = useState<string | null>(getSelectedVoiceURI());
const [rate, setRate] = useState<number>(getVoiceRate());
const [pitch, setPitch] = useState<number>(getVoicePitch());
const [isPlayingTest, setIsPlayingTest] = useState<boolean>(false);
useEffect(() => {
if (!isOpen) return;
const loadVoices = () => {
const available = getAvailableIndianVoices(language);
setVoices(available);
setSelectedURI(getSelectedVoiceURI());
setRate(getVoiceRate());
setPitch(getVoicePitch());
};
loadVoices();
if (typeof window !== "undefined" && window.speechSynthesis) {
window.speechSynthesis.onvoiceschanged = loadVoices;
}
}, [isOpen, language]);
if (!isOpen) return null;
const handleVoiceSelect = (uri: string) => {
const newURI = uri === "auto" ? null : uri;
setSelectedURI(newURI);
setSelectedVoiceURI(newURI);
};
const handleRateChange = (newRate: number) => {
setRate(newRate);
setVoiceRate(newRate);
};
const handlePitchChange = (newPitch: number) => {
setPitch(newPitch);
setVoicePitch(newPitch);
};
const handleTestVoice = () => {
if (isPlayingTest) {
stopNeuralSpeech();
setIsPlayingTest(false);
return;
}
setIsPlayingTest(true);
testIndianAccentSample(language, () => {
setIsPlayingTest(false);
});
};
const langNames: Record<string, string> = {
"ta-IN": "Tamil (தமிழ்)",
"en-IN": "English (India)",
"hi-IN": "Hindi (हिन्दी)",
"te-IN": "Telugu (తెలుగు)",
"kn-IN": "Kannada (ಕನ್ನಡ)",
};
return (
<div
style={{
position: "fixed",
inset: 0,
backgroundColor: "rgba(0, 0, 0, 0.75)",
backdropFilter: "blur(8px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 100,
padding: "16px",
}}
>
<div
className="glass-panel"
style={{
width: "100%",
maxWidth: "540px",
background: "linear-gradient(180deg, rgba(15, 23, 42, 0.95), rgba(10, 15, 29, 0.98))",
border: "1px solid rgba(56, 189, 248, 0.25)",
borderRadius: "20px",
padding: "24px",
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.6), 0 0 30px rgba(6, 182, 212, 0.15)",
}}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "20px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<div
style={{
width: "36px",
height: "36px",
borderRadius: "10px",
background: "linear-gradient(135deg, #06b6d4, #3b82f6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Volume2 size={18} color="#ffffff" />
</div>
<div>
<h3 style={{ fontSize: "17px", fontWeight: "700", margin: 0, color: "#ffffff" }}>
Indian Voice & Accent Calibration
</h3>
<p style={{ fontSize: "12px", color: "var(--text-muted)", margin: 0 }}>
Active Language: <strong style={{ color: "#38bdf8" }}>{langNames[language] || language}</strong>
</p>
</div>
</div>
<button
onClick={() => {
stopNeuralSpeech();
setIsPlayingTest(false);
onClose();
}}
style={{
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
padding: "6px",
cursor: "pointer",
color: "var(--text-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X size={18} />
</button>
</div>
{/* Voice Selection List */}
<div style={{ marginBottom: "20px" }}>
<label style={{ fontSize: "12px", fontWeight: "600", color: "#94a3b8", display: "block", marginBottom: "8px" }}>
Select Neural / Natural Indian Voice Engine
</label>
<div
style={{
maxHeight: "180px",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: "8px",
paddingRight: "4px",
}}
>
{/* Auto-Optimized Option */}
<div
onClick={() => handleVoiceSelect("auto")}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 14px",
borderRadius: "10px",
background: selectedURI === null ? "rgba(6, 182, 212, 0.15)" : "rgba(255, 255, 255, 0.03)",
border: selectedURI === null ? "1px solid #06b6d4" : "1px solid rgba(255, 255, 255, 0.06)",
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
<div>
<div style={{ fontSize: "13px", fontWeight: "600", color: "#ffffff", display: "flex", alignItems: "center", gap: "6px" }}>
<Sparkles size={13} color="#06b6d4" /> Auto-Select Highest Quality Indian Voice
</div>
<div style={{ fontSize: "11px", color: "var(--text-muted)" }}>
Recommended • Prioritizes Microsoft Neerja / Swara / Valluvar & Google Neural voices
</div>
</div>
{selectedURI === null && <Check size={16} color="#06b6d4" />}
</div>
{/* Installed Voices */}
{voices.map((v) => {
const isSelected = selectedURI === v.voiceURI;
const isNatural = v.name.toLowerCase().includes("natural") || v.name.toLowerCase().includes("online");
const isIndianNamed = ["neerja", "prabhat", "heera", "ravi", "swara", "madhur", "valluvar", "mohan", "gagan", "india"].some(
(k) => v.name.toLowerCase().includes(k)
);
return (
<div
key={v.voiceURI}
onClick={() => handleVoiceSelect(v.voiceURI)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 14px",
borderRadius: "10px",
background: isSelected ? "rgba(6, 182, 212, 0.15)" : "rgba(255, 255, 255, 0.03)",
border: isSelected ? "1px solid #06b6d4" : "1px solid rgba(255, 255, 255, 0.06)",
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
<div>
<div style={{ fontSize: "13px", fontWeight: "600", color: "#ffffff", display: "flex", alignItems: "center", gap: "6px" }}>
{v.name}
{isIndianNamed && (
<span
style={{
fontSize: "10px",
background: "rgba(16, 185, 129, 0.2)",
color: "#34d399",
padding: "1px 6px",
borderRadius: "4px",
fontWeight: "700",
}}
>
Indian Accent
</span>
)}
{isNatural && (
<span
style={{
fontSize: "10px",
background: "rgba(59, 130, 246, 0.2)",
color: "#60a5fa",
padding: "1px 6px",
borderRadius: "4px",
fontWeight: "600",
}}
>
Neural
</span>
)}
</div>
<div style={{ fontSize: "11px", color: "var(--text-muted)" }}>
Language: {v.lang}
</div>
</div>
{isSelected && <Check size={16} color="#06b6d4" />}
</div>
);
})}
</div>
</div>
{/* Sliders: Rate & Pitch */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px", marginBottom: "16px" }}>
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "6px" }}>
<span style={{ fontSize: "12px", color: "#94a3b8" }}>Pacing / Speed</span>
<span style={{ fontSize: "12px", color: "#38bdf8", fontWeight: "600" }}>{rate.toFixed(2)}x</span>
</div>
<input
type="range"
min="0.75"
max="1.2"
step="0.02"
value={rate}
onChange={(e) => handleRateChange(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "#06b6d4", cursor: "pointer" }}
/>
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "6px" }}>
<span style={{ fontSize: "12px", color: "#94a3b8" }}>Pitch / Tone</span>
<span style={{ fontSize: "12px", color: "#38bdf8", fontWeight: "600" }}>{pitch.toFixed(2)}x</span>
</div>
<input
type="range"
min="0.8"
max="1.2"
step="0.02"
value={pitch}
onChange={(e) => handlePitchChange(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "#06b6d4", cursor: "pointer" }}
/>
</div>
</div>
{/* High Precision STT & Medical Speech Normalization Badge */}
<div
style={{
background: "rgba(6, 182, 212, 0.08)",
border: "1px solid rgba(56, 189, 248, 0.2)",
borderRadius: "10px",
padding: "10px 14px",
marginBottom: "20px",
display: "flex",
flexDirection: "column",
gap: "4px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<Sparkles size={14} color="#38bdf8" />
<span style={{ fontSize: "12px", fontWeight: "600", color: "#e0f2fe" }}>
High-Precision Clinical STT Active
</span>
</div>
<p style={{ fontSize: "11px", color: "var(--text-muted)", margin: 0 }}>
Multi-engine cascade (Sarvam Saaras v2 + Gemini Multimodal + Whisper-1) with Indian clinical phonetic normalization & code-switching (Tanglish/Hinglish).
</p>
</div>
{/* Test Speech Sample Button & Done Button */}
<div style={{ display: "flex", gap: "12px" }}>
<button
onClick={handleTestVoice}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
padding: "12px 16px",
borderRadius: "12px",
background: isPlayingTest ? "rgba(239, 68, 68, 0.2)" : "rgba(6, 182, 212, 0.15)",
border: isPlayingTest ? "1px solid #ef4444" : "1px solid #06b6d4",
color: isPlayingTest ? "#f87171" : "#38bdf8",
fontSize: "13px",
fontWeight: "600",
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
{isPlayingTest ? (
<>
<Square size={16} /> Stop Sample
</>
) : (
<>
<Play size={16} /> Test Accent Speech
</>
)}
</button>
<button
onClick={() => {
stopNeuralSpeech();
setIsPlayingTest(false);
onClose();
}}
style={{
flex: 1,
padding: "12px 16px",
borderRadius: "12px",
background: "linear-gradient(135deg, #06b6d4, #3b82f6)",
border: "none",
color: "#ffffff",
fontSize: "13px",
fontWeight: "600",
cursor: "pointer",
boxShadow: "0 0 15px rgba(6, 182, 212, 0.3)",
}}
>
Apply & Save
</button>
</div>
</div>
</div>
);
};
-281
View File
@@ -1,281 +0,0 @@
import { useState, useRef, useCallback, useEffect } from "react";
interface UseAudioRecorderProps {
onAudioCaptured: (audioBlob: Blob, textTranscript?: string) => void;
language?: string;
maxDurationSeconds?: number;
silenceThresholdSeconds?: number;
}
export function useAudioRecorder({
onAudioCaptured,
language = "ta-IN",
maxDurationSeconds = 5.0,
silenceThresholdSeconds = 0.4,
}: UseAudioRecorderProps) {
const [isRecording, setIsRecording] = useState(false);
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [audioLevel, setAudioLevel] = useState<number>(0);
const [isSpeechDetected, setIsSpeechDetected] = useState(false);
const onAudioCapturedRef = useRef(onAudioCaptured);
useEffect(() => {
onAudioCapturedRef.current = onAudioCaptured;
}, [onAudioCaptured]);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const animationFrameRef = useRef<number | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const maxTimeoutRef = useRef<number | null>(null);
const speechRecognitionRef = useRef<any>(null);
const liveTranscriptRef = useRef<string>("");
// Dynamic VAD refs
const hasSpokenRef = useRef<boolean>(false);
const silenceStartRef = useRef<number | null>(null);
const isStoppingRef = useRef<boolean>(false);
const noiseFloorRef = useRef<number>(10);
const sampleCountRef = useRef<number>(0);
const sampleSumRef = useRef<number>(0);
const stopRecording = useCallback(() => {
if (isStoppingRef.current) return;
isStoppingRef.current = true;
if (maxTimeoutRef.current) {
clearTimeout(maxTimeoutRef.current);
maxTimeoutRef.current = null;
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (speechRecognitionRef.current) {
try {
speechRecognitionRef.current.stop();
} catch (_) {}
speechRecognitionRef.current = null;
}
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
audioContextRef.current.close().catch(() => {});
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
setIsRecording(false);
setIsSpeechDetected(false);
setAudioLevel(0);
}, []);
const startRecording = useCallback(async () => {
try {
isStoppingRef.current = false;
hasSpokenRef.current = false;
silenceStartRef.current = null;
audioChunksRef.current = [];
sampleCountRef.current = 0;
sampleSumRef.current = 0;
noiseFloorRef.current = 10;
liveTranscriptRef.current = "";
// High-definition audio constraints tailored for Indian speech recognition
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 16000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
streamRef.current = stream;
setHasPermission(true);
// Web Speech API for high-accuracy local Indian Language Recognition
try {
const SpeechRec = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition;
if (SpeechRec) {
const rec = new SpeechRec();
rec.continuous = true;
rec.interimResults = true;
rec.lang = language || "ta-IN";
rec.onresult = (event: any) => {
let finalStr = "";
let isFinal = false;
for (let i = 0; i < event.results.length; i++) {
finalStr += event.results[i][0].transcript;
if (event.results[i].isFinal) {
isFinal = true;
}
}
if (finalStr.trim()) {
liveTranscriptRef.current = finalStr.trim();
hasSpokenRef.current = true;
setIsSpeechDetected(true);
silenceStartRef.current = null;
if (isFinal) {
setTimeout(() => {
stopRecording();
}, 300);
}
}
};
rec.onspeechend = () => {
if (hasSpokenRef.current) {
setTimeout(() => {
stopRecording();
}, 300);
}
};
rec.onerror = () => {};
rec.onend = () => {};
try {
rec.start();
speechRecognitionRef.current = rec;
} catch (_) {}
}
} catch (_) {}
// Adaptive Dynamic VAD Analyzer with Indian Room Acoustics Tolerance
try {
const AudioCtx =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext })
.webkitAudioContext;
const audioCtx = new AudioCtx();
audioContextRef.current = audioCtx;
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.25;
const source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const checkAudioActivity = () => {
if (isStoppingRef.current || !analyser) return;
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
sum += dataArray[i];
}
const avg = sum / dataArray.length;
const currentLevel = Math.min(100, Math.round((avg / 128) * 100));
setAudioLevel(currentLevel);
// Calibrate background noise floor over the first 15 frames
if (sampleCountRef.current < 15) {
sampleCountRef.current += 1;
sampleSumRef.current += currentLevel;
noiseFloorRef.current = Math.round(sampleSumRef.current / sampleCountRef.current);
animationFrameRef.current = requestAnimationFrame(checkAudioActivity);
return;
}
const now = Date.now();
const baseNoise = noiseFloorRef.current;
const SPEECH_TRIGGER = Math.max(10, baseNoise + 5);
if (currentLevel > SPEECH_TRIGGER) {
hasSpokenRef.current = true;
setIsSpeechDetected(true);
silenceStartRef.current = null;
} else {
// Once speech has been detected, level dropping below trigger immediately starts silence countdown
if (hasSpokenRef.current) {
if (silenceStartRef.current === null) {
silenceStartRef.current = now;
} else {
const silenceDurationMs = now - silenceStartRef.current;
if (silenceDurationMs >= silenceThresholdSeconds * 1000) {
stopRecording();
return;
}
}
}
}
animationFrameRef.current = requestAnimationFrame(checkAudioActivity);
};
checkAudioActivity();
} catch (err) {
console.warn("AudioContext VAD warning:", err);
}
// MediaRecorder initialization with standard codecs
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: MediaRecorder.isTypeSupported("audio/webm")
? "audio/webm"
: MediaRecorder.isTypeSupported("audio/mp4")
? "audio/mp4"
: undefined;
const mediaRecorder = new MediaRecorder(
stream,
mimeType ? { mimeType } : undefined
);
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunksRef.current, {
type: mediaRecorder.mimeType || "audio/webm",
});
const recognizedText = liveTranscriptRef.current;
if (onAudioCapturedRef.current) {
onAudioCapturedRef.current(audioBlob, recognizedText);
}
setIsRecording(false);
setAudioLevel(0);
};
mediaRecorder.start(100);
setIsRecording(true);
maxTimeoutRef.current = window.setTimeout(() => {
stopRecording();
}, maxDurationSeconds * 1000);
} catch (err) {
console.error("Microphone access failed:", err);
setHasPermission(false);
setIsRecording(false);
}
}, [language, maxDurationSeconds, silenceThresholdSeconds, stopRecording]);
return {
isRecording,
hasPermission,
audioLevel,
isSpeechDetected,
startRecording,
stopRecording,
};
}
-84
View File
@@ -1,84 +0,0 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
@layer base {
body {
font-family: 'Sora', 'Outfit', 'Inter', system-ui, -apple-system, sans-serif;
background: radial-gradient(at 0% 0%, rgba(139, 92, 246, 0.07) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(99, 102, 241, 0.07) 0px, transparent 50%),
radial-gradient(at 50% 100%, rgba(168, 85, 247, 0.05) 0px, transparent 50%),
#f8fafc;
background-attachment: fixed;
color: #0f172a;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
}
/* Premium SaaS Card Styling */
.premium-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(226, 232, 240, 0.8);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 12px 32px -6px rgba(124, 58, 237, 0.06);
}
.premium-card-hover {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.premium-card-hover:hover {
border-color: rgba(168, 85, 247, 0.35);
box-shadow: 0 20px 40px -12px rgba(124, 58, 237, 0.12);
transform: translateY(-2px);
}
/* Form Input Focus States */
.premium-input {
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #0f172a;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.premium-input:focus {
background: #ffffff;
border-color: #8b5cf6;
outline: none;
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.12);
}
/* Floating Animations */
@keyframes floatSlow {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-6px); }
}
@keyframes floatReverse {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(6px); }
}
.animate-float-slow {
animation: floatSlow 4s ease-in-out infinite;
}
.animate-float-reverse {
animation: floatReverse 4.5s ease-in-out infinite;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(241, 245, 249, 0.8);
}
::-webkit-scrollbar-thumb {
background: rgba(196, 181, 253, 0.6);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(168, 85, 247, 0.8);
}
-10
View File
@@ -1,10 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
-10
View File
@@ -1,10 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
-254
View File
@@ -1,254 +0,0 @@
// 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 isLocalhost = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
let envUrl = import.meta.env.VITE_API_BASE_URL;
if (isLocalhost && (!envUrl || envUrl.includes('services-api.a2zhealth.in'))) {
envUrl = 'http://localhost:8000';
}
const DEFAULT_API_URL = isNetlify
? ''
: isLocalhost
? 'http://localhost:8000'
: 'https://services-api.a2zhealth.in/onboarding';
const API_BASE_URL = (envUrl || DEFAULT_API_URL).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);
}
-131
View File
@@ -1,131 +0,0 @@
import { CategoryInfo, PatientInfo, VoiceTurnResult } from "../types/voice";
import { DocumentExtractionResult, PatientOnboardingResult } from "../types/onboarding";
let envUrl = import.meta.env.VITE_API_BASE_URL;
const isNetlify = typeof window !== "undefined" && window.location.hostname.includes("netlify.app");
const isLocalhost = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
if (isLocalhost && (!envUrl || envUrl.includes("services-api.a2zhealth.in"))) {
envUrl = "http://localhost:8000";
}
const DEFAULT_API_URL = isNetlify ? "" : isLocalhost ? "http://localhost:8000" : "https://services-api.a2zhealth.in/onboarding";
const API_BASE_URL = (envUrl !== undefined && envUrl !== "" ? envUrl : DEFAULT_API_URL).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
@@ -1,258 +0,0 @@
// 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);
}
-45
View File
@@ -1,45 +0,0 @@
export interface Symptom {
name: string;
duration?: string | null;
severity?: string | null;
description?: string | null;
}
export interface PatientOnboardingResult {
patient_id: string;
abha_id?: string;
phone?: string;
insurance_scheme?: string;
category: {
type: string;
code: string;
name: string;
};
chief_complaint?: string | null;
symptoms: Symptom[];
medical_history: string[];
medications: string[];
allergies: string[];
dietary_habits?: string | null;
additional_information?: string | null;
language: string;
onboarding_completed: boolean;
}
export interface DocumentFinding {
test: string;
value: string;
unit?: string | null;
interpretation?: string | null;
}
export interface DocumentExtractionResult {
success: boolean;
session_id: string;
document_type: string;
summary: string;
findings: DocumentFinding[];
medications_found: string[];
allergies_found: string[];
message: string;
}
-47
View File
@@ -1,47 +0,0 @@
export type VoiceState =
| "idle"
| "starting"
| "ai_speaking"
| "listening"
| "processing"
| "confirmation"
| "completed"
| "error";
export interface PatientInfo {
id: string;
name: string;
age: number;
gender: string;
abha_id?: string;
phone?: string;
insurance_scheme?: string;
dietary_habits?: string;
}
export interface CategoryInfo {
type: "general" | "speciality";
code: string;
name: string;
}
export interface VoiceTurnResult {
success: boolean;
session_id: string;
patient_transcript: string;
ai_text: string;
stage: string;
completed: boolean;
audio_url: string;
latencyMs?: number;
}
export interface MessageItem {
id: string;
role: "assistant" | "patient" | "system";
text: string;
timestamp: string;
audioUrl?: string;
latencyMs?: number;
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-38
View File
@@ -1,38 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
medical: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0284c7',
600: '#0284c7',
700: '#0369a1',
900: '#0c4a6e',
},
health: {
emerald: '#10b981',
teal: '#14b8a6',
violet: '#8b5cf6',
accent: '#0ea5e9'
}
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'wave': 'wave 1.5s ease-in-out infinite',
},
keyframes: {
wave: {
'0%, 100%': { transform: 'scaleY(0.4)' },
'50%': { transform: 'scaleY(1)' },
}
}
},
},
plugins: [],
}
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
-30
View File
@@ -1,30 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true,
proxy: {
'/v1': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
},
'/health': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
},
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
-30
View File
@@ -1,30 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: true,
proxy: {
'/v1': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
},
'/health': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
},
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})