commit 991a5d845c577c932c500a979490a1eeb95dc407 Author: Gokulvarthan Ramesh Date: Sat Aug 29 15:33:57 2026 +0530 Initial commit: CureSelect AI Voice Onboarding Web Frontend diff --git a/.env b/.env new file mode 100644 index 0000000..9c14ad0 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://services-api.a2zhealth.in/onboarding diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..14ea4ad --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=/api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ca6ff6 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..6663ac0 --- /dev/null +++ b/API_DOCUMENTATION.md @@ -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}' +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc54537 Binary files /dev/null and b/README.md differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..0b8af7b --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + AI Patient Onboarding Portal | CureSelect Healthcare + + + + + + + +
+ + + diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..84e1527 --- /dev/null +++ b/netlify.toml @@ -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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..db584cd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2680 @@ +{ + "name": "ai-patient-onboarding-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-patient-onboarding-frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.415", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", + "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.2.tgz", + "integrity": "sha512-UpGiiODyCGprM8EPP6JodP6jC9Rws6TCuiDOD+nn0CJhR8guI3g/ozo4ugL0vJ+Yz1UtJuuRPqvQuybVOF1VQA==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.344.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz", + "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5a0d71b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 0000000..49fc85f --- /dev/null +++ b/public/_redirects @@ -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 diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..07631a5 --- /dev/null +++ b/src/App.jsx @@ -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 ( +
+ + {/* Header Bar */} +
+ + {/* Main Content Area */} + + +
+ {!activeSession ? ( + + ) : ( + setShowDocumentUploader(true)} + onCompleteSession={() => setShowSummaryModal(true)} + isProcessingTurn={isProcessingTurn} + documentsCount={uploadedDocuments.length} + /> + )} +
+ + {/* Prescription & Medical Document Uploader Modal */} + {showDocumentUploader && activeSession && ( + setShowDocumentUploader(false)} + onDocumentUploaded={handleDocumentUploaded} + /> + )} + + {/* Clinical Summary & EHR 2-Step Confirmation Modal */} + {showSummaryModal && activeSession && ( + setShowSummaryModal(false)} + onSubmittedSuccessfully={() => { + // Keep modal open on success view + }} + /> + )} + + {/* Footer */} +
+ CureSelect Healthcare AI Engine +
+ + +
+ ); +} + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..fd62b32 --- /dev/null +++ b/src/App.tsx @@ -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(1); + + // Intake State + const [patient, setPatient] = useState(DEFAULT_PATIENT); + const [category, setCategory] = useState(DEFAULT_CATEGORY); + const [language, setLanguage] = useState("ta-IN"); + const [sessionId, setSessionId] = useState(null); + const [voiceState, setVoiceState] = useState("idle"); + const [currentStage, setCurrentStage] = useState("greeting"); + const [messages, setMessages] = useState([]); + const [isDocModalOpen, setIsDocModalOpen] = useState(false); + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); + const [isVoiceSettingsOpen, setIsVoiceSettingsOpen] = useState(false); + const [onboardingResult, setOnboardingResult] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isMuted, setIsMuted] = useState(false); + const [showKeyboardFallback, setShowKeyboardFallback] = useState(false); + const [textInput, setTextInput] = useState(""); + + const currentAudioRef = useRef(null); + const sessionIdRef = useRef(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 ( +
+ {/* Navigation Header */} +
setIsVoiceSettingsOpen(true)} + /> + + {/* Main Wizard Container */} +
+ {/* Step Progress Bar */} + { + if (voiceState === "idle" || voiceState === "completed" || voiceState === "error") { + setCurrentStep(step); + } + }} + /> + + {/* STEP 1: Language Selection */} + {currentStep === 1 && ( + setCurrentStep(2)} + /> + )} + + {/* STEP 2: Specialty Selection */} + {currentStep === 2 && ( + setCurrentStep(1)} + onNext={(cat) => { + setCurrentStep(3); + handleStartSession(cat); + }} + /> + )} + + {/* STEP 3: Voice Onboarding Intake */} + {currentStep === 3 && ( +
+ {/* Left Column: Voice Orb & Interactive Controls */} +
+ {/* Top Specialty & Language Status Badge */} +
+ + {category.name} • {language === "ta-IN" ? "தமிழ்" : language === "hi-IN" ? "हिन्दी" : language === "te-IN" ? "తెలుగు" : language === "kn-IN" ? "ಕನ್ನಡ" : "English"} + + +
+ + + + + {sessionId && ( + + )} +
+
+ + {/* Central Glowing Voice Sphere (Clean Hands-Free) */} +
+ + + {/* Dynamic Soundwave Visualizer */} + +
+ + {/* Bottom Action Tray: Document Upload + Keyboard Fallback + Review */} +
+
+ {/* Document Upload Button */} + + + {/* Keyboard Mode Toggle */} + + + {/* Review & Complete Trigger */} + {sessionId && ( + + )} +
+ + {/* Keyboard Input Fallback Box */} + {showKeyboardFallback && ( +
+ 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", + }} + /> + +
+ )} +
+
+ + {/* Right Column: Live Clinical Dialogue Transcript */} +
+ playAudioResponse(url, false)} + /> +
+
+ )} +
+ + {/* Gemini Vision Document Upload Modal */} + {sessionId && ( + 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 */} + setIsConfirmModalOpen(false)} + /> + + {/* Indian Voice & Accent Settings Modal */} + setIsVoiceSettingsOpen(false)} + language={language} + /> +
+ ); +} + +export default App; + diff --git a/src/components/AudioWaveform.tsx b/src/components/AudioWaveform.tsx new file mode 100644 index 0000000..014975d --- /dev/null +++ b/src/components/AudioWaveform.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { VoiceState } from "../types/voice"; + +interface AudioWaveformProps { + state: VoiceState; + audioLevel: number; +} + +export const AudioWaveform: React.FC = ({ state, audioLevel }) => { + if (state !== "listening" && state !== "ai_speaking") { + return null; + } + + const barCount = 18; + const isListening = state === "listening"; + + return ( +
+ {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 ( +
+ ); + })} +
+ ); +}; diff --git a/src/components/ConfirmationModal.tsx b/src/components/ConfirmationModal.tsx new file mode 100644 index 0000000..a1cbad0 --- /dev/null +++ b/src/components/ConfirmationModal.tsx @@ -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 = ({ + isOpen, + result, + isSubmitting, + onConfirm, + onCancel, +}) => { + if (!isOpen || !result) return null; + + return ( +
+
+ {/* Header */} +
+
+ +
+
+

Review & Confirm OPD Intake

+

+ Specialty: {result.category.name} | OPD ID: {result.patient_id} {result.abha_id ? `| ABHA: ${result.abha_id}` : ""} +

+
+
+ + {/* Structured Summary Cards */} +
+ {/* Chief Complaint */} +
+
+ Chief Complaint / Reason for Visit +
+
+ {result.chief_complaint || "Not specified"} +
+
+ + {/* Reported Symptoms */} +
+
+ Reported Symptoms +
+ {result.symptoms && result.symptoms.length > 0 ? ( +
+ {result.symptoms.map((s, i) => ( +
+ • {s.name} + + {s.duration ? `Duration: ${s.duration}` : ""} {s.severity ? `(${s.severity})` : ""} + +
+ ))} +
+ ) : ( + None reported + )} +
+ + {/* Current Medications & Allergies Grid */} +
+ {/* Medications */} +
+
+ Medications +
+ {result.medications && result.medications.length > 0 ? ( +
    + {result.medications.map((m, i) => ( +
  • {m}
  • + ))} +
+ ) : ( + No regular medications + )} +
+ + {/* Allergies */} +
+
+ Allergies +
+ {result.allergies && result.allergies.length > 0 ? ( +
    + {result.allergies.map((a, i) => ( +
  • {a}
  • + ))} +
+ ) : ( + No known drug allergies + )} +
+
+
+ + {/* Action Controls */} +
+ + +
+
+
+ ); +}; diff --git a/src/components/DocumentUploadModal.tsx b/src/components/DocumentUploadModal.tsx new file mode 100644 index 0000000..fb16aa3 --- /dev/null +++ b/src/components/DocumentUploadModal.tsx @@ -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 = ({ + sessionId, + isOpen, + onClose, + onExtractionComplete, +}) => { + const [selectedFile, setSelectedFile] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + if (!isOpen) return null; + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( +
+
+ {/* Close Button */} + + + {/* Modal Title */} +
+
+ +
+
+

Upload Medical Document

+

+ Gemini Vision analyzes prescriptions, previous lab tests & reports +

+
+
+ + {/* Upload Dropzone */} +
+ +

+ {selectedFile ? selectedFile.name : "Select or drop medical file"} +

+

+ Supports PDF, PNG, JPG, or Scanned Doctor Prescription +

+ +
+ + {/* Error message */} + {error && ( +
+ + {error} +
+ )} + + {/* Extracted Findings Preview */} + {result && ( +
+
+ + Extracted by Gemini Vision: +
+

+ {result.summary} +

+ {result.findings && result.findings.length > 0 && ( +
+ {result.findings.map((f, i) => ( + + {f.test}: {f.value} {f.unit || ""} ({f.interpretation || "Observed"}) + + ))} +
+ )} +
+ )} + + {/* Action Buttons */} +
+ + {!result ? ( + + ) : ( + + )} +
+
+
+ ); +}; diff --git a/src/components/DocumentUploader.jsx b/src/components/DocumentUploader.jsx new file mode 100644 index 0000000..2126bf6 --- /dev/null +++ b/src/components/DocumentUploader.jsx @@ -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 ( +
+
+ + {/* Close Button */} + + +
+
+ + Gemini Vision OCR & Medical Extractor +
+

Upload Medical Document

+

+ Upload doctor's prescription, discharge summary, or lab result image to automatically extract active medications and allergies into this session. +

+
+ + {/* Upload Form */} + {!uploadResult ? ( +
+
+ + +
+ + {error && ( +
+ + {error} +
+ )} + +
+ + +
+
+ ) : ( + /* Parsed Findings Results */ +
+
+ +
+
Document Analyzed Successfully
+
{uploadResult.message}
+
+
+ +
+
+ Document Type: + {uploadResult.document_type || 'Prescription'} +
+
+ Summary: + {uploadResult.summary} +
+ + {/* Medications Found */} + {uploadResult.medications_found?.length > 0 && ( +
+
+ + Medications Extracted: +
+
+ {uploadResult.medications_found.map((med, idx) => ( + + {med} + + ))} +
+
+ )} + + {/* Allergies Found */} + {uploadResult.allergies_found?.length > 0 && ( +
+
+ + Allergies Flagged: +
+
+ {uploadResult.allergies_found.map((alg, idx) => ( + + {alg} + + ))} +
+
+ )} + + {/* Findings */} + {uploadResult.findings?.length > 0 && ( +
+
+ + Clinical Findings: +
+
    + {uploadResult.findings.map((finding, idx) => ( +
  • {finding}
  • + ))} +
+
+ )} +
+ +
+ +
+
+ )} + +
+
+ ); +} diff --git a/src/components/Header.jsx b/src/components/Header.jsx new file mode 100644 index 0000000..f15e9ad --- /dev/null +++ b/src/components/Header.jsx @@ -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 ( +
+
+ + {/* Left: Brand Logo, Title & Tagline */} +
+
+
+ +
+
+
+

+ CareVoice AI +

+

Intelligent. Human. Caring.

+
+
+ + {/* Right side Header Items */} +
+ + {/* Badge 1: HIPAA & DPDP Compliant */} +
+
+ +
+
+ HIPAA & DPDP + Compliant +
+
+ + {/* Badge 2: 15+ Indian Languages */} +
+
+ +
+
+ 15+ Indian + Languages +
+
+ + {/* Theme Toggle Sun Button */} + + + {/* Need Help? Pill Button */} + + +
+ +
+
+ ); +} + + + diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..db12d43 --- /dev/null +++ b/src/components/Header.tsx @@ -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 = ({ + patient, + category, + language, + onLanguageChange, + onCategoryChange, + voiceState, + onOpenVoiceSettings, +}) => { + const isSessionLocked = voiceState !== "idle" && voiceState !== "completed" && voiceState !== "error"; + + return ( +
+ {/* Brand & Logo */} +
+
+ +
+
+
+ + CureSelect + + + Voice AI + +
+

+ Hospital Patient Intake Assistant +

+
+
+ + {/* Patient & Specialty Context */} +
+
+
+ {patient.name.charAt(0)} +
+
+
+ {patient.name} ({patient.age}y, {patient.gender}) +
+
+ ID: {patient.id} +
+
+
+ + {/* Specialty Selector */} + + + {/* Language Selector */} +
+ + +
+ + {/* Indian Voice & Accent Settings Button */} + {onOpenVoiceSettings && ( + + )} + + {/* Medical Safety Seal */} +
+ + Intake Portal +
+
+
+ ); +}; + diff --git a/src/components/LanguageStep.tsx b/src/components/LanguageStep.tsx new file mode 100644 index 0000000..a7e8130 --- /dev/null +++ b/src/components/LanguageStep.tsx @@ -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 = ({ + selectedLanguage, + onSelectLanguage, + onNext, +}) => { + const handleCardClick = (code: string) => { + onSelectLanguage(code); + // Instant single-tap transition to Step 2 + onNext(); + }; + + return ( +
+ {/* Title */} +
+ Step 1 of 3 • Tap a Language to Proceed +
+ +

+ Choose Your Language +

+

+ Tap your native language to immediately choose your medical department. +

+ + {/* Language Cards Grid (Instant Tap) */} +
+ {LANGUAGES.map((lang) => { + const isSelected = selectedLanguage === lang.code; + + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/components/LiveTranscript.tsx b/src/components/LiveTranscript.tsx new file mode 100644 index 0000000..e11cc19 --- /dev/null +++ b/src/components/LiveTranscript.tsx @@ -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 = ({ + messages, + currentStage, + isProcessing, +}) => { + const scrollRef = useRef(null); + const [elapsedSeconds, setElapsedSeconds] = useState(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 ( +
+ {/* Header */} +
+
+ + + Live Conversation + +
+ +
+ {/* Latest Response Time Badge */} + {latestLatency && !isProcessing && ( +
+ + {latestLatency.text} +
+ )} + + {/* Current Stage Badge */} +
+ {currentStage.replace(/_/g, " ")} +
+
+
+ + {/* Messages Scroll Area */} +
+ {messages.length === 0 ? ( +
+ Voice session started. The assistant will speak and listen automatically. +
+ ) : ( + messages.map((m) => { + const isAI = m.role === "assistant"; + const isSystem = m.role === "system"; + const latency = isAI ? formatLatency(m.latencyMs) : null; + + if (isSystem) { + return ( +
+ {m.text} +
+ ); + } + + return ( +
+ {isAI && ( +
+ +
+ )} + +
+

{m.text}

+ + {/* Timestamp & Latency Metric Footer */} +
+ {m.timestamp} + + {/* Response Latency Pill for AI */} + {latency && ( + + + Response: {latency.text} + + )} +
+
+ + {!isAI && ( +
+ +
+ )} +
+ ); + }) + )} + + {/* AI Live Thinking Bubble with Real-Time Stopwatch */} + {isProcessing && ( +
+
+ +
+
+
+
+
+ + AI Processing... {elapsedSeconds > 0 && `${elapsedSeconds}s`} + +
+
+ )} +
+
+ ); +}; + diff --git a/src/components/PatientSetupForm.jsx b/src/components/PatientSetupForm.jsx new file mode 100644 index 0000000..9a29161 --- /dev/null +++ b/src/components/PatientSetupForm.jsx @@ -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 ( +
+ + {/* Main 3-Column Layout: Left Hero, Middle Form, Right Timeline */} +
+ + {/* COLUMN 1: LEFT HERO & SMART SPEAKER DEVICE (4 cols on desktop) */} +
+ + {/* Main Title & Subtitle */} +
+

+ Smarter Intake.
+ + Better Care. + +

+

+ AI voice-first intake that understands, triages and extracts clinical information seamlessly. +

+
+ + {/* Horizontal 3 Feature Badges Row */} +
+
+
+ +
+
+ AI Voice + Natural conversation +
+
+ +
+
+ +
+
+ Secure + Your data is safe +
+
+ +
+
+ +
+
+ Real-time + Instant insights +
+
+
+ + {/* 3D Smart Speaker Image Frame with Animated Floating Speech Bubbles */} +
+ AI Smart Speaker Consultation + + {/* Subtle Vignette Overlay for Contrast */} +
+ + {/* Floating Glass Speech Bubble 1 (Top Left, Purple Accent) */} +
+
+ + Hello! 👋 I'm your AI assistant. +
+
+ + {/* Floating Glass Speech Bubble 2 (Top Right, Indigo Accent) */} +
+ Let's understand
how you're feeling. +
+ + {/* Floating Glass Speech Bubble 3 (Bottom Right, Emerald Accent) */} +
+ I'll ask a few
simple questions. +
+
+ + {/* Privacy Card at Bottom of Left Column */} +
+
+ +
+
+

Your Privacy Matters

+

+ We follow industry-leading standards to keep your health information private and secure. +

+
+
+ +
+ + {/* COLUMN 2: MIDDLE PATIENT INTAKE & TRIAGE FORM CARD (5 cols on desktop) */} +
+
+ + {/* Header of Form Card */} +
+
+
+ +
+
+

+ Patient Intake & Triage +

+

+ Start your AI voice consultation +

+
+
+ + {/* AI Voice Ready Pill */} +
+
+ + + +
+ AI Voice Ready + +
+
+ +
+ + {/* AI Auto-Triage Active Banner */} +
+
+ +
+
+ + + AI Dynamic Specialty Auto-Triage Active + + Our AI will analyze your responses in real-time and route you to the right specialist. No manual department selection needed. +
+
+ + {/* First Name & Last Name */} +
+
+ +
+ + 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" + /> +
+
+ +
+ +
+ + 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" + /> +
+
+
+ + {/* Age & Gender Row */} +
+ + {/* Age Input (5 cols) */} +
+ +
+ + 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" + /> +
+
+ + {/* Gender Segmented Pill Group (7 cols) */} +
+ +
+ + + + + +
+
+ +
+ + {/* Preferred Spoken Language Selection UI */} +
+ + + {/* Quick-Select Popular Language Cards Grid (2x3) */} +
+ {[ + { 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 ( + + ); + })} +
+ + {/* All Languages Dropdown Selector */} +
+ + + +
+
+ + {/* Big Action Button */} + + + {/* Terms & Privacy Disclaimer */} +
+

+ + By continuing, you agree to our + Privacy Policy + & + Terms of Service +

+
+ +
+
+
+ + {/* COLUMN 3: RIGHT SIDE "YOUR JOURNEY" TIMELINE & QUICK INFO CARDS (3 cols on desktop) */} +
+ + {/* Card 1: Your Journey Step Tracker */} +
+

Your Journey

+ +
+ + {/* Vertical Guide Line */} +
+ + {/* Step 1 */} +
+
+ 1 +
+
+

Introduction

+

Start your session

+
+
+ + {/* Step 2 */} +
+
+ 2 +
+
+

AI Conversation

+

Answer a few questions

+
+
+ + {/* Step 3 */} +
+
+ 3 +
+
+

Triage & Analysis

+

AI analyzes your responses

+
+
+ + {/* Step 4 */} +
+
+ 4 +
+
+

Results

+

Get summary & next steps

+
+
+ +
+
+ + {/* Card 2: Session is Secure */} +
+
+ +
+
+

Session is Secure

+

+ 256-bit encryption
End-to-end protection +

+
+
+ + {/* Card 3: Average Time */} +
+
+ +
+
+

Average Time

+

5–7 Minutes

+
+
+ +
+ +
+ +
+ ); +} diff --git a/src/components/SpecialtyStep.tsx b/src/components/SpecialtyStep.tsx new file mode 100644 index 0000000..eb25d4c --- /dev/null +++ b/src/components/SpecialtyStep.tsx @@ -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: , + 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: , + 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: , + 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: , + color: "#8b5cf6", + focus: "Stress, anxiety, sleep issues (தூக்கமின்மை), low mood, panic", + examples: "Psychiatry, counselling intake", + }, + { + code: "dermatology", + name: "Dermatology (Skin & Hair)", + type: "speciality", + icon: , + 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: , + 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: , + color: "#3b82f6", + focus: "Child fever, growth milestones, vaccinations (தடுப்பூசி), appetite", + examples: "Child OPD, immunization", + }, + { + code: "ent", + name: "ENT (Ear, Nose & Throat)", + type: "speciality", + icon: , + 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 = ({ + 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 ( +
+ {/* Top Navigation Row */} +
+ + +
+ Step 2 of 3 • Tap a Department to Begin Voice Intake +
+
+ +
+

+ Select Medical Specialty +

+

+ Tap any department to immediately start your voice conversation with the AI Doctor Assistant. +

+
+ + {/* Indian Patient Profile & ABHA Card */} +
+
+
+
+ +
+
+
+ {patient.name} ({patient.age}y, {patient.gender}) + + ABHA: {patient.abha_id || "91-5642-1289-4091"} + +
+
+ OPD Slip: {patient.id} | Scheme: {patient.insurance_scheme || "PM-JAY / Ayushman Bharat"} +
+
+
+ + +
+ + {/* Expandable Patient Edit Form */} + {showEditPatient && ( +
+
+ + 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", + }} + /> +
+
+ + 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", + }} + /> +
+
+ + +
+
+ )} +
+ + {/* Specialties Grid (Instant Launch on Click) */} +
+ {SPECIALTIES.map((spec) => { + const isSelected = selectedSpecialty.code === spec.code; + + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/components/StepProgressBar.tsx b/src/components/StepProgressBar.tsx new file mode 100644 index 0000000..420f198 --- /dev/null +++ b/src/components/StepProgressBar.tsx @@ -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 = ({ + currentStep, + onStepClick, +}) => { + const steps = [ + { num: 1, label: "Select Language", icon: }, + { num: 2, label: "Choose Specialty", icon: }, + { num: 3, label: "AI Voice Intake", icon: }, + ]; + + return ( +
+ {steps.map((s, index) => { + const isActive = currentStep === s.num; + const isPassed = currentStep > s.num; + + return ( + + {/* Step Node */} +
{ + 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", + }} + > +
+ {s.icon} +
+ +
+
+ Step {s.num} +
+
+ {s.label} +
+
+
+ + {/* Connector Line */} + {index < steps.length - 1 && ( +
index + 1 + ? "linear-gradient(90deg, #10b981, #06b6d4)" + : "rgba(255, 255, 255, 0.1)", + transition: "all 0.3s ease", + }} /> + )} + + ); + })} +
+ ); +}; diff --git a/src/components/SummaryModal.jsx b/src/components/SummaryModal.jsx new file mode 100644 index 0000000..538a65d --- /dev/null +++ b/src/components/SummaryModal.jsx @@ -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 ( +
+
+ + {/* Close Button */} + + + {/* Modal Header */} +
+
+ + Structured EHR Intake Summary +
+

Review Clinical Onboarding Report

+

+ Please verify the AI-extracted clinical symptoms, active medications, and medical history before saving to hospital EHR. +

+
+ + {loading ? ( +
+ + Extracting structured clinical entities... +
+ ) : submittedSuccess ? ( + /* Submission Success State */ +
+
+ +
+
+

Saved to Hospital EMR / EHR

+

+ Patient record has been cleaned, finalized, and transmitted to central hospital servers. +

+
+
+ +
+
+ ) : ( + /* Structured Clinical Record Content */ +
+ {/* Patient Header Summary (Insurance Removed) */} +
+
+
Patient ID
+
{summaryData?.patient_id || 'PAT-9842'}
+
+
+
ABHA ID
+
{summaryData?.abha_id || 'N/A'}
+
+
+
Clinical Specialty
+
{summaryData?.category?.name || 'Cardiology'}
+
+
+ + {/* Chief Complaint */} +
+
+ + Chief Complaint +
+

+ {summaryData?.chief_complaint || 'Patient reported retrosternal chest pain and breathlessness.'} +

+
+ + {/* Extracted Symptoms List */} + {summaryData?.symptoms && summaryData.symptoms.length > 0 && ( +
+
+ + Reported Symptoms ({summaryData.symptoms.length}) +
+
+ {summaryData.symptoms.map((sym, idx) => ( +
+
+
{sym.name}
+
{sym.description}
+
+
+ + Severity: {sym.severity} + +
Duration: {sym.duration}
+
+
+ ))} +
+
+ )} + + {/* Grid for History, Medications & Allergies */} +
+ {/* Active Medications */} +
+
+ + Current Medications +
+ {summaryData?.medications?.length > 0 ? ( +
    + {summaryData.medications.map((med, idx) => ( +
  • + {med} +
  • + ))} +
+ ) : ( + None reported + )} +
+ + {/* Allergies & Medical History */} +
+
+
Known Allergies
+ {summaryData?.allergies?.length > 0 ? ( +
+ {summaryData.allergies.map((alg, idx) => ( + + {alg} + + ))} +
+ ) : ( + No known drug allergies + )} +
+ +
+
Past Medical History
+ {summaryData?.medical_history?.length > 0 ? ( +
+ {summaryData.medical_history.join(', ')} +
+ ) : ( + None reported + )} +
+
+
+ + {error && ( +
+ + {error} +
+ )} + + {/* Action Buttons */} +
+ + +
+
+ )} + +
+
+ ); +} diff --git a/src/components/VoiceChat.jsx b/src/components/VoiceChat.jsx new file mode 100644 index 0000000..f228720 --- /dev/null +++ b/src/components/VoiceChat.jsx @@ -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 ( +
+
+ + {/* Chat Header Bar */} +
+
+
+ +
+
+
+ + {session?.patient?.name || 'Patient'} Onboarding Assistant + + + {session?.language || 'en-IN'} + + {isCompleted && ( + + Session Completed + + )} +
+

+ Specialty: {typeof session?.category === 'string' ? session.category : session?.category?.name || session?.category?.code || 'Cardiology'} +

+
+
+ +
+ {/* View Mode Toggle: Audio Only vs Full Transcript */} + {!isCompleted && ( + + )} + + {/* Auto-Talk Continuous Mode Toggle */} + {isVoiceMode && !isCompleted && ( + + )} + + {/* Document Uploader Trigger */} + + + {/* Complete & Confirm Session Button */} + +
+
+ + {/* Dynamic Voice Activity Bar */} +
+
+ {isCompleted ? ( +
+ + Onboarding Completed — Ready for EHR Submission +
+ ) : isPlayingAudio ? ( +
+ + AI Assistant Speaking... +
+ ) : isRecording ? ( +
+ + Microphone Active — Speak Now ({recordingTime}s) +
+ ) : isProcessingTurn ? ( +
+ + Analyzing Symptoms & Synthesizing Reply... +
+ ) : ( +
+ + Ready for turn +
+ )} +
+ +
+ Session: {session?.session_id} +
+
+ + {/* Main Content Area */} +
+ + {/* VIEW A: Completed Session Screen (Shows Send EHR to Hospital Button) */} + {isCompleted ? ( +
+
+ +
+
+ + Patient Intake Completed + +

Ready to Submit to Hospital

+

+ All clinical symptoms, medical history, and prescription findings have been gathered for {session?.patient?.name || 'Patient'}. +

+
+ + {/* Primary Action Buttons */} +
+ + +
+
+ ) : viewMode === 'audio_only' ? ( + /* VIEW B: Audio Only Call View (Pure Voice Interactive Orb) */ +
+ + {/* Autoplay Blocked Tap Banner */} + {statusMessage === 'Click to play audio' && lastAiTurn?.audioUrl && ( + + )} + + {/* Interactive 3D Glowing Voice Orb Visualizer */} +
+
+
+ {isPlayingAudio ? ( + + ) : isRecording ? ( + + ) : isProcessingTurn ? ( + + ) : ( + + )} +
+
+ + {/* Animated Ripple Waves */} + {(isPlayingAudio || isRecording) && ( + <> +
+
+ + )} +
+ + {/* Status Header */} +
+
+ {isPlayingAudio + ? 'AI Doctor Speaking...' + : isRecording + ? 'Listening to Patient...' + : isProcessingTurn + ? 'Thinking & Processing...' + : 'Voice Onboarding Call'} +
+

+ {isPlayingAudio + ? 'Listen to spoken instructions via audio output' + : isRecording + ? 'Speak your symptoms into microphone' + : 'Hands-free automated clinical intake session'} +

+
+ + {/* Spoken AI Audio Controls */} + {lastAiTurn && ( +
+
+ + + Current AI Question: + + {lastAiTurn.audioUrl && ( + + )} +
+

"{lastAiTurn.text}"

+
+ )} +
+ ) : ( + /* VIEW C: Full Detailed Text Transcript */ +
+ {turnHistory.map((turn) => { + const isAI = turn.sender === 'ai'; + return ( +
+ {isAI && ( +
+ +
+ )} + +
+ {turn.patientTranscript && ( +
+ Recognized Patient Speech: "{turn.patientTranscript}" +
+ )} + +

{turn.text}

+ +
+ {turn.timestamp} + + {/* Audio Playback Button for AI Responses */} + {isAI && turn.audioUrl && ( + + )} +
+
+ + {!isAI && ( +
+ +
+ )} +
+ ); + })} + + {isProcessingTurn && ( +
+ + AI is generating response & synthesizing speech audio... +
+ )} + +
+
+ )} + +
+ + {/* Input Bar / Controls (HIDDEN when Session is Completed) */} + {!isCompleted && ( +
+ + {/* Voice Microphone Recorder Controls */} + {isVoiceMode && ( +
+
+ + +
+
+ {isRecording + ? `Recording Speech... (${recordingTime}s)` + : autoTalkMode + ? 'Auto-Silence VAD Active (Mic sends audio automatically when you pause)' + : 'Tap Microphone to Speak'} +
+
+ {isRecording + ? 'Speak your symptoms — audio will auto-send after 1.6s of silence' + : 'Sarvam AI multi-model Indic speech recognition'} +
+
+
+ + {/* Animated Waveform Bars when Recording */} + {isRecording && ( +
+
+
+
+
+
+
+ )} +
+ )} + + {/* Text Input Fallback Form */} +
+ 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" + /> + +
+
+ )} + +
+
+ ); +} diff --git a/src/components/VoiceOrb.tsx b/src/components/VoiceOrb.tsx new file mode 100644 index 0000000..9eb253d --- /dev/null +++ b/src/components/VoiceOrb.tsx @@ -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 = ({ + 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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 ( +
+ {/* Orb Outer Container */} +
+ {/* Multi-layered dynamic aura glow */} +
+ + {/* Dynamic Sound Ripple Aura */} + {(state === "listening" || state === "ai_speaking") && ( + <> +
+
+ + )} + + {/* Clean, Modern Floating Voice Sphere */} + +
+ + {/* State Text & Feedback */} +
+

+ {visuals.statusText} +

+

+ {visuals.subText} +

+
+
+ ); +}; diff --git a/src/components/VoiceSettingsModal.tsx b/src/components/VoiceSettingsModal.tsx new file mode 100644 index 0000000..6731d2e --- /dev/null +++ b/src/components/VoiceSettingsModal.tsx @@ -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 = ({ + isOpen, + onClose, + language, +}) => { + const [voices, setVoices] = useState([]); + const [selectedURI, setSelectedURI] = useState(getSelectedVoiceURI()); + const [rate, setRate] = useState(getVoiceRate()); + const [pitch, setPitch] = useState(getVoicePitch()); + const [isPlayingTest, setIsPlayingTest] = useState(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 = { + "ta-IN": "Tamil (தமிழ்)", + "en-IN": "English (India)", + "hi-IN": "Hindi (हिन्दी)", + "te-IN": "Telugu (తెలుగు)", + "kn-IN": "Kannada (ಕನ್ನಡ)", + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Indian Voice & Accent Calibration +

+

+ Active Language: {langNames[language] || language} +

+
+
+ + +
+ + {/* Voice Selection List */} +
+ + +
+ {/* Auto-Optimized Option */} +
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", + }} + > +
+
+ Auto-Select Highest Quality Indian Voice +
+
+ Recommended • Prioritizes Microsoft Neerja / Swara / Valluvar & Google Neural voices +
+
+ {selectedURI === null && } +
+ + {/* 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 ( +
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", + }} + > +
+
+ {v.name} + {isIndianNamed && ( + + Indian Accent + + )} + {isNatural && ( + + Neural + + )} +
+
+ Language: {v.lang} +
+
+ {isSelected && } +
+ ); + })} +
+
+ + {/* Sliders: Rate & Pitch */} +
+
+
+ Pacing / Speed + {rate.toFixed(2)}x +
+ handleRateChange(parseFloat(e.target.value))} + style={{ width: "100%", accentColor: "#06b6d4", cursor: "pointer" }} + /> +
+ +
+
+ Pitch / Tone + {pitch.toFixed(2)}x +
+ handlePitchChange(parseFloat(e.target.value))} + style={{ width: "100%", accentColor: "#06b6d4", cursor: "pointer" }} + /> +
+
+ + {/* High Precision STT & Medical Speech Normalization Badge */} +
+
+ + + High-Precision Clinical STT Active + +
+

+ Multi-engine cascade (Sarvam Saaras v2 + Gemini Multimodal + Whisper-1) with Indian clinical phonetic normalization & code-switching (Tanglish/Hinglish). +

+
+ + {/* Test Speech Sample Button & Done Button */} +
+ + + +
+
+
+ ); +}; diff --git a/src/hooks/useAudioRecorder.ts b/src/hooks/useAudioRecorder.ts new file mode 100644 index 0000000..c562458 --- /dev/null +++ b/src/hooks/useAudioRecorder.ts @@ -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(null); + const [audioLevel, setAudioLevel] = useState(0); + const [isSpeechDetected, setIsSpeechDetected] = useState(false); + + const onAudioCapturedRef = useRef(onAudioCaptured); + useEffect(() => { + onAudioCapturedRef.current = onAudioCaptured; + }, [onAudioCaptured]); + + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const streamRef = useRef(null); + const animationFrameRef = useRef(null); + const audioContextRef = useRef(null); + const maxTimeoutRef = useRef(null); + const speechRecognitionRef = useRef(null); + const liveTranscriptRef = useRef(""); + + // Dynamic VAD refs + const hasSpokenRef = useRef(false); + const silenceStartRef = useRef(null); + const isStoppingRef = useRef(false); + const noiseFloorRef = useRef(10); + const sampleCountRef = useRef(0); + const sampleSumRef = useRef(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, + }; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..fb397ec --- /dev/null +++ b/src/index.css @@ -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); +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..54b39dd --- /dev/null +++ b/src/main.jsx @@ -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( + + + , +) diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/src/main.tsx @@ -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( + + + , +) diff --git a/src/services/api.js b/src/services/api.js new file mode 100644 index 0000000..14b586d --- /dev/null +++ b/src/services/api.js @@ -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); +} diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..e5e9429 --- /dev/null +++ b/src/services/api.ts @@ -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 { + 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 { + 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; + diff --git a/src/services/voiceSynthesis.ts b/src/services/voiceSynthesis.ts new file mode 100644 index 0000000..ca52c6b --- /dev/null +++ b/src/services/voiceSynthesis.ts @@ -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 = { + "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); +} + + diff --git a/src/types/onboarding.ts b/src/types/onboarding.ts new file mode 100644 index 0000000..74c3438 --- /dev/null +++ b/src/types/onboarding.ts @@ -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; +} diff --git a/src/types/voice.ts b/src/types/voice.ts new file mode 100644 index 0000000..4bbcf23 --- /dev/null +++ b/src/types/voice.ts @@ -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; +} + diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..05b4de8 --- /dev/null +++ b/tailwind.config.js @@ -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: [], +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6bfa73a --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..b741ed7 --- /dev/null +++ b/vite.config.js @@ -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/, ''), + }, + }, + }, +}); + diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..27e20f8 --- /dev/null +++ b/vite.config.ts @@ -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/, ''), + }, + }, + }, +}) +