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