Amogh1221 commited on
Commit
9e7d4f7
·
verified ·
1 Parent(s): 4fc7b49

Upload 19 files

Browse files
Requirements.txt ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Web & API
2
+ fastapi
3
+ uvicorn
4
+ python-multipart
5
+
6
+ # Configuration & Security
7
+ pydantic
8
+ pydantic-settings
9
+ python-dotenv
10
+ passlib[bcrypt]
11
+ python-jose[cryptography]
12
+
13
+ # LLMs & Vector Store
14
+ groq
15
+ openai
16
+ chromadb
17
+ langchain
18
+ langchain-chroma
19
+ langchain-openai
20
+
21
+ # Audio Processing & Transcription
22
+ librosa
23
+ soundfile
24
+ torchaudio
25
+ transformers
26
+ funasr
27
+
28
+ # Testing
29
+ pytest
30
+ pytest-asyncio
31
+ pytest-mock
32
+ httpx
api/__pycache__/dependencies.cpython-311.pyc ADDED
Binary file (8.68 kB). View file
 
api/__pycache__/dependencies.cpython-313.pyc ADDED
Binary file (5.18 kB). View file
 
api/__pycache__/routes.cpython-311.pyc ADDED
Binary file (12.5 kB). View file
 
api/__pycache__/routes.cpython-313.pyc ADDED
Binary file (9.12 kB). View file
 
api/dependencies.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dependency injection module for FastAPI.
3
+ Instantiates all infrastructure components (LLMs, Databases, STT) as singletons
4
+ and wires them into the core domain services (Orchestrator, PatientService).
5
+ Provides getter functions to inject these into FastAPI route handlers.
6
+ """
7
+ import os
8
+ from dotenv import load_dotenv
9
+
10
+ from providers.llms.groq_llm_provider import GroqLLMProvider
11
+ from providers.llms.ollama_llm_provider import OllamaLLMProvider
12
+ from providers.llms.fallback_llm_provider import FallbackLLMProvider
13
+ from providers.speech_to_text_provider import SenseVoiceSTTProvider
14
+ from providers.chroma_provider import ChromaVectorStore
15
+ from providers.pinecone_provider import PineconeVectorStore
16
+ from persistence.sqlite_memory_store import SQLiteProfileStore, SQLiteSessionStore
17
+ from persistence.user_store import SQLiteUserStore
18
+
19
+ # Safety/Risk Signals
20
+ from providers.safety.llm import LLMRiskSignal
21
+ from providers.safety.clinical import ClinicalRiskSignal
22
+
23
+ # Uncomment if using Cascade
24
+ # from core.ports import SafetySignal
25
+ from providers.safety.bert import BertRiskSignal
26
+ from providers.safety.keyword import KeywordRiskSignal
27
+ from providers.safety.cascade import CascadeRiskSignal
28
+ from fastapi.security import OAuth2PasswordBearer
29
+ from fastapi import Depends, HTTPException, status
30
+ from services.auth_service import decode_access_token
31
+ from services.risk_assessment_service import RiskAssessmentService
32
+ from services.conversation_orchestrator import ConversationOrchestrator
33
+ from services.patient_service import PatientService
34
+ from core.ports import ProfileStore, SessionStore, STTProvider, LLMProvider, VectorStore
35
+
36
+ load_dotenv()
37
+
38
+ OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
39
+ OLLAMA_LLM1_MODEL = os.getenv("OLLAMA_LLM1_MODEL", "phi4-mini")
40
+ OLLAMA_LLM2_MODEL = os.getenv("OLLAMA_LLM2_MODEL", "qwen2.5:7b-instruct")
41
+ GROQ_LLM1_MODEL = os.getenv("GROQ_LLM1_MODEL", "llama-3.1-8b-instant")
42
+ GROQ_LLM2_MODEL = os.getenv("GROQ_LLM2_MODEL", "llama-3.3-70b-versatile")
43
+ EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "nomic-embed-text")
44
+ CHROMA_PERSIST_DIR = os.getenv("CHROMA_PERSIST_DIR", "./chroma_db")
45
+ CHROMA_COLLECTION = os.getenv("CHROMA_COLLECTION_NAME", "mhcva-knowledge")
46
+ VECTOR_STORE_TYPE = os.getenv("VECTOR_STORE_TYPE", "chroma").lower()
47
+ PINECONE_API_KEY = os.getenv("PINECONE_API_KEY", "")
48
+ PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "serinity-knowledge")
49
+ MEMORY_DB_PATH = os.getenv("MEMORY_DB_PATH", "./data/serinity.db")
50
+ WORKING_MEMORY_TURNS = int(os.getenv("WORKING_MEMORY_TURNS", "20"))
51
+ CLOUD_MODE = os.getenv("CLOUD_MODE", "false").lower() == "true"
52
+
53
+ _hf_home_raw = os.getenv("HF_HOME", "./models")
54
+ if not os.path.isabs(_hf_home_raw):
55
+ _hf_home_raw = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), _hf_home_raw)
56
+ os.environ["HF_HOME"] = _hf_home_raw
57
+ os.makedirs(_hf_home_raw, exist_ok=True)
58
+
59
+ # Instantiate singleton infrastructure
60
+ groq_provider = GroqLLMProvider(model1=GROQ_LLM1_MODEL, model2=GROQ_LLM2_MODEL)
61
+ ollama_provider = OllamaLLMProvider(host=OLLAMA_HOST, model1=OLLAMA_LLM1_MODEL, model2=OLLAMA_LLM2_MODEL)
62
+
63
+ if CLOUD_MODE:
64
+ llm_provider = FallbackLLMProvider(providers=[groq_provider])
65
+ vector_store = PineconeVectorStore(
66
+ host=OLLAMA_HOST,
67
+ embedding_model=EMBEDDING_MODEL,
68
+ index_name=PINECONE_INDEX_NAME,
69
+ api_key=PINECONE_API_KEY
70
+ )
71
+ else:
72
+ llm_provider = FallbackLLMProvider(providers=[ollama_provider])
73
+ vector_store = ChromaVectorStore(
74
+ host=OLLAMA_HOST,
75
+ embedding_model=EMBEDDING_MODEL,
76
+ persist_dir=CHROMA_PERSIST_DIR,
77
+ collection_name=CHROMA_COLLECTION
78
+ )
79
+ profile_store = SQLiteProfileStore(db_path=MEMORY_DB_PATH)
80
+ session_store = SQLiteSessionStore(db_path=MEMORY_DB_PATH, working_memory_turns=WORKING_MEMORY_TURNS)
81
+ user_store = SQLiteUserStore(db_path=MEMORY_DB_PATH)
82
+
83
+ stt_provider = SenseVoiceSTTProvider()
84
+
85
+ # Setup DI dependencies for FastAPI routes
86
+ def get_llm_provider() -> FallbackLLMProvider:
87
+ return llm_provider
88
+
89
+ def get_vector_store():
90
+ return vector_store
91
+
92
+ def get_profile_store() -> SQLiteProfileStore:
93
+ return profile_store
94
+
95
+ def get_session_store() -> SQLiteSessionStore:
96
+ return session_store
97
+
98
+ def get_user_store() -> SQLiteUserStore:
99
+ return user_store
100
+
101
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
102
+
103
+ def get_current_user(token: str = Depends(oauth2_scheme), store: SQLiteUserStore = Depends(get_user_store)):
104
+ credentials_exception = HTTPException(
105
+ status_code=status.HTTP_401_UNAUTHORIZED,
106
+ detail="Could not validate credentials",
107
+ headers={"WWW-Authenticate": "Bearer"},
108
+ )
109
+ payload = decode_access_token(token)
110
+ if payload is None:
111
+ raise credentials_exception
112
+
113
+ user_id: str = payload.get("sub")
114
+ if user_id is None:
115
+ raise credentials_exception
116
+
117
+ user = store.get_user_by_id(user_id)
118
+ if user is None:
119
+ raise credentials_exception
120
+ return user
121
+
122
+ # Instantiate Risk Service
123
+ risk_service = RiskAssessmentService([
124
+ CascadeRiskSignal(
125
+ filter_signal=KeywordRiskSignal(),
126
+ verify_signal=BertRiskSignal()
127
+ ),
128
+ LLMRiskSignal(),
129
+ ClinicalRiskSignal()
130
+ ])
131
+
132
+ # Instantiate Domain Services
133
+ conversation_orchestrator = ConversationOrchestrator(
134
+ llm_provider=llm_provider,
135
+ vector_store=vector_store,
136
+ session_store=session_store,
137
+ profile_store=profile_store,
138
+ risk_service=risk_service
139
+ )
140
+
141
+ patient_service = PatientService(
142
+ profile_store=profile_store,
143
+ session_store=session_store,
144
+ llm_provider=llm_provider
145
+ )
146
+
147
+ # FastAPI dependency injection functions
148
+ def get_orchestrator(
149
+ llm_provider: LLMProvider = Depends(get_llm_provider),
150
+ vector_store: VectorStore = Depends(get_vector_store),
151
+ session_store: SessionStore = Depends(get_session_store),
152
+ profile_store: ProfileStore = Depends(get_profile_store)
153
+ ) -> ConversationOrchestrator:
154
+ return ConversationOrchestrator(
155
+ llm_provider=llm_provider,
156
+ vector_store=vector_store,
157
+ session_store=session_store,
158
+ profile_store=profile_store,
159
+ risk_service=risk_service
160
+ )
161
+
162
+ def get_patient_service(
163
+ profile_store: ProfileStore = Depends(get_profile_store),
164
+ session_store: SessionStore = Depends(get_session_store),
165
+ llm_provider: LLMProvider = Depends(get_llm_provider)
166
+ ) -> PatientService:
167
+ return PatientService(
168
+ profile_store=profile_store,
169
+ session_store=session_store,
170
+ llm_provider=llm_provider
171
+ )
172
+
173
+ def get_stt_provider() -> STTProvider:
174
+ return stt_provider
175
+
176
+ def get_profile_store() -> ProfileStore:
177
+ return profile_store
178
+
179
+ def get_session_store() -> SessionStore:
180
+ return session_store
api/routers/__pycache__/auth.cpython-311.pyc ADDED
Binary file (5.7 kB). View file
 
api/routers/auth.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication Router
3
+ Handles JWT-based authentication, user registration, and OTP flows (signup, password reset, account deletion).
4
+ """
5
+
6
+ from fastapi import APIRouter, HTTPException, status, Depends
7
+ from schemas.auth_schemas import SignupRequest, LoginRequest, TokenResponse, ResetPasswordRequest
8
+ from services.auth_service import get_password_hash, verify_password, create_access_token
9
+ from datetime import datetime, timedelta, timezone
10
+ from api.dependencies import profile_store, get_user_store, get_current_user
11
+ from persistence.user_store import SQLiteUserStore
12
+
13
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
14
+
15
+ @router.post("/signup", response_model=TokenResponse)
16
+ def signup(request: SignupRequest, user_store: SQLiteUserStore = Depends(get_user_store)):
17
+ """
18
+ Register a new user and create an initial patient profile.
19
+ Returns a JWT access token upon successful registration.
20
+ """
21
+ # Check if user exists
22
+ if user_store.get_user_by_email(request.email):
23
+ raise HTTPException(status_code=400, detail="Email already registered")
24
+ if user_store.get_user_by_username(request.username):
25
+ raise HTTPException(status_code=400, detail="Username already taken")
26
+
27
+ # Hash password
28
+ hashed_password = get_password_hash(request.password)
29
+
30
+ # Create user
31
+ user_id = user_store.create_user(
32
+ username=request.username,
33
+ email=request.email,
34
+ password_hash=hashed_password,
35
+ nationality=request.nationality,
36
+ emergency_contact_name=request.emergency_contact_name,
37
+ emergency_contact_phone=request.emergency_contact_phone
38
+ )
39
+
40
+ # Create patient profile linked to user_id
41
+ patient_id = profile_store.create_patient(
42
+ name=request.name,
43
+ age=request.age,
44
+ gender=request.gender,
45
+ primary_concern=request.primary_concern,
46
+ user_id=user_id
47
+ )
48
+
49
+ # Generate token
50
+ access_token = create_access_token(data={"sub": user_id, "username": request.username})
51
+
52
+ return TokenResponse(access_token=access_token, user_id=user_id)
53
+
54
+
55
+ @router.post("/login", response_model=TokenResponse)
56
+ def login(request: LoginRequest, user_store: SQLiteUserStore = Depends(get_user_store)):
57
+ """
58
+ Authenticate a user using username or email and password.
59
+ Returns a JWT access token.
60
+ """
61
+ if "@" in request.username:
62
+ user = user_store.get_user_by_email(request.username)
63
+ else:
64
+ user = user_store.get_user_by_username(request.username)
65
+
66
+ if not user:
67
+ raise HTTPException(
68
+ status_code=status.HTTP_401_UNAUTHORIZED,
69
+ detail="Incorrect username or password",
70
+ )
71
+
72
+ if not verify_password(request.password, user["password_hash"]):
73
+ raise HTTPException(
74
+ status_code=status.HTTP_401_UNAUTHORIZED,
75
+ detail="Incorrect username or password",
76
+ )
77
+
78
+ access_token = create_access_token(data={"sub": user["id"], "username": user["username"]})
79
+ return TokenResponse(access_token=access_token, user_id=user["id"])
80
+
81
+ @router.post("/reset-password")
82
+ def reset_password(request: ResetPasswordRequest, user_store: SQLiteUserStore = Depends(get_user_store)):
83
+ user = user_store.get_user_by_email(request.email)
84
+ if not user:
85
+ raise HTTPException(status_code=404, detail="No account found with that email address.")
86
+
87
+ # Hash new password
88
+ hashed_password = get_password_hash(request.new_password)
89
+ user_store.update_password(request.email, hashed_password)
90
+
91
+ return {"message": "Password successfully updated."}
92
+
93
+
94
+ @router.post("/delete-account")
95
+ def delete_account(
96
+ user_store: SQLiteUserStore = Depends(get_user_store),
97
+ current_user: dict = Depends(get_current_user)
98
+ ):
99
+ user = user_store.get_user_by_id(current_user["id"])
100
+ if not user:
101
+ raise HTTPException(status_code=404, detail="User not found")
102
+
103
+ # Delete patient associated with user first
104
+ patients = profile_store.list_patients_for_user(user["id"])
105
+ for p in patients:
106
+ profile_store.delete_patient(p["patient_id"])
107
+
108
+ # Delete user
109
+ user_store.delete_user(user["id"])
110
+
111
+ return {"message": "Account has been successfully deleted."}
api/routes.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main API Routes
3
+ Handles patient management, dashboard, and conversation endpoints.
4
+ """
5
+
6
+ from fastapi import APIRouter, Depends, UploadFile, File, Request, Response, BackgroundTasks, HTTPException
7
+ from fastapi.concurrency import run_in_threadpool
8
+ from fastapi.responses import HTMLResponse
9
+ from fastapi.templating import Jinja2Templates
10
+ from pydantic import BaseModel
11
+
12
+ from core.schemas import (
13
+ ResetRequest, ChatRequest, StartRequest,
14
+ PatientCreateRequest, EndSessionRequest
15
+ )
16
+ from api.dependencies import (
17
+ get_orchestrator, get_patient_service,
18
+ get_stt_provider, get_profile_store, get_session_store,
19
+ get_current_user,
20
+ ConversationOrchestrator, PatientService
21
+ )
22
+ from core.ports import ProfileStore, SessionStore, STTProvider
23
+
24
+ router = APIRouter()
25
+ templates = Jinja2Templates(directory="templates")
26
+
27
+ @router.get("/health")
28
+ def health():
29
+ """Simple health check endpoint to verify API is running."""
30
+ return {"status": "ok"}
31
+
32
+ @router.get("/", response_class=HTMLResponse)
33
+ def home(request: Request):
34
+ """Serves the main frontend Single Page Application (SPA)."""
35
+ return templates.TemplateResponse(request, "index.html")
36
+
37
+
38
+
39
+ @router.get("/patients")
40
+ def get_patients(profile_store: ProfileStore = Depends(get_profile_store), user: dict = Depends(get_current_user)):
41
+ """Retrieve a list of all registered patients for the logged-in user."""
42
+ patients = profile_store.list_patients_for_user(user["id"])
43
+ if not patients:
44
+ # Auto-create a default profile for older accounts that lack one
45
+ patient_id = profile_store.create_patient(
46
+ name=user.get("username", "User"),
47
+ age=None,
48
+ gender=None,
49
+ primary_concern=None,
50
+ user_id=user["id"]
51
+ )
52
+ return [{"patient_id": patient_id, "name": user.get("username", "User")}]
53
+ return patients
54
+
55
+ @router.post("/patients/create")
56
+ def create_new_patient(
57
+ request: PatientCreateRequest,
58
+ profile_store: ProfileStore = Depends(get_profile_store),
59
+ user: dict = Depends(get_current_user)
60
+ ):
61
+ """Create a new patient record in the database."""
62
+ p_id = profile_store.create_patient(
63
+ name=request.name,
64
+ age=request.age,
65
+ gender=request.gender,
66
+ occupation=request.occupation,
67
+ primary_concern=request.primary_concern,
68
+ user_id=user["id"]
69
+ )
70
+ return {"patient_id": p_id, "name": request.name}
71
+
72
+ @router.get("/patients/{patient_id}/dashboard")
73
+ def patient_dashboard(
74
+ patient_id: str,
75
+ profile_store: ProfileStore = Depends(get_profile_store),
76
+ user: dict = Depends(get_current_user)
77
+ ):
78
+ """Retrieve full dashboard data for a patient (info, profile, past sessions)."""
79
+ patient_info = profile_store.get_patient(patient_id)
80
+ if not patient_info:
81
+ return Response(status_code=404)
82
+
83
+ profile = profile_store.get_patient_profile(patient_id)
84
+ sessions = profile_store.get_patient_sessions(patient_id)
85
+
86
+ return {
87
+ "patient": patient_info,
88
+ "profile": profile,
89
+ "sessions": sessions
90
+ }
91
+
92
+ @router.post("/patients/{patient_id}/reset")
93
+ def reset_patient(
94
+ patient_id: str,
95
+ patient_service: PatientService = Depends(get_patient_service),
96
+ user: dict = Depends(get_current_user)
97
+ ):
98
+ """Reset the patient's data, including sessions, messages, and profile."""
99
+ patient_service.reset_patient_data(patient_id)
100
+ return {"status": "success", "message": "Patient data reset successfully."}
101
+
102
+ @router.delete("/patients/{patient_id}")
103
+ def delete_patient(
104
+ patient_id: str,
105
+ patient_service: PatientService = Depends(get_patient_service),
106
+ user: dict = Depends(get_current_user)
107
+ ):
108
+ """Delete a patient and all their associated data completely."""
109
+ patient_service.delete_patient(patient_id)
110
+ return {"status": "success", "message": "Patient deleted successfully."}
111
+
112
+ @router.post("/end_session")
113
+ def end_session_endpoint(
114
+ request: EndSessionRequest,
115
+ background_tasks: BackgroundTasks,
116
+ patient_service: PatientService = Depends(get_patient_service),
117
+ user: dict = Depends(get_current_user)
118
+ ):
119
+ """Gracefully terminate an active session and generate a clinical summary in the background."""
120
+ patient_service.end_session(request.session_id, request.patient_id)
121
+ background_tasks.add_task(patient_service.generate_session_summary, request.session_id, request.patient_id)
122
+ return {"status": "ended"}
123
+
124
+ @router.post("/start")
125
+ def start_session(
126
+ request: StartRequest = StartRequest(),
127
+ patient_service: PatientService = Depends(get_patient_service),
128
+ user: dict = Depends(get_current_user)
129
+ ):
130
+ """Initialize a new conversation session and generate the psychiatrist's opening remark."""
131
+ try:
132
+ session_id, opening_message, patient_id = patient_service.create_new_session(request.patient_id)
133
+ return {
134
+ "assistant_message": opening_message,
135
+ "session_id": session_id,
136
+ "patient_id": patient_id,
137
+ }
138
+ except Exception as e:
139
+ error_str = str(e).lower()
140
+ if "connection error" in error_str or "all providers failed" in error_str or "unavailable" in error_str:
141
+ raise HTTPException(status_code=503, detail="LLMs are currently unavailable. Cannot start session.")
142
+ raise e
143
+
144
+
145
+ @router.post("/chat_text")
146
+ def chat_text(
147
+ request: ChatRequest,
148
+ orchestrator: ConversationOrchestrator = Depends(get_orchestrator),
149
+ patient_service: PatientService = Depends(get_patient_service),
150
+ session_store: SessionStore = Depends(get_session_store),
151
+ profile_store: ProfileStore = Depends(get_profile_store),
152
+ user: dict = Depends(get_current_user)
153
+ ):
154
+ """
155
+ Process a user's chat message through the core orchestrator.
156
+ LLM2 analysis is run synchronously within the orchestrator if triggered.
157
+ """
158
+ if not session_store.session_exists(request.session_id):
159
+ # Session expired, create a new one
160
+ session_id, opening_message, patient_id = patient_service.create_new_session(request.patient_id)
161
+ # Instead of dropping the user's message and returning the greeting, pipe the user's message into the new session
162
+ request.session_id = session_id
163
+ request.patient_id = patient_id
164
+
165
+ try:
166
+ chat_result = orchestrator.handle_message(
167
+ session_id=request.session_id,
168
+ message=request.message,
169
+ emotion=request.emotion,
170
+ default_patient_id=request.patient_id
171
+ )
172
+ except Exception as e:
173
+ error_str = str(e).lower()
174
+ error_type = type(e).__name__.lower()
175
+ if "rate limit" in error_str or "ratelimit" in error_str or "429" in error_str or "ratelimit" in error_type:
176
+ raise HTTPException(status_code=429, detail="Tokens Exhausted")
177
+ if "connection error" in error_str or "all providers failed" in error_str or "unavailable" in error_str:
178
+ raise HTTPException(status_code=503, detail="LLMs are currently unavailable. Please try again later.")
179
+ raise e
180
+
181
+
182
+ return {
183
+ "assistant_message": chat_result.assistant_message,
184
+ "intent": chat_result.intent,
185
+ "risk_flagged": chat_result.risk_flagged,
186
+ "session_id": request.session_id,
187
+ }
188
+
189
+ @router.post("/transcribe")
190
+ async def transcribe(
191
+ audio: UploadFile = File(...),
192
+ stt_provider: STTProvider = Depends(get_stt_provider)
193
+ ):
194
+ """
195
+ Accepts raw audio bytes from the frontend and passes them to the STTProvider
196
+ for speech-to-text and emotional tone extraction.
197
+ """
198
+ audio_bytes = await audio.read()
199
+ # Run the CPU-bound transcription in a threadpool to prevent blocking the async event loop
200
+ result = await run_in_threadpool(stt_provider.transcribe, audio_bytes)
201
+ return {
202
+ "text": result.get("text", ""),
203
+ "emotion": result.get("emotion", "unknown"),
204
+ "event": result.get("event", None),
205
+ }
206
+
207
+ @router.get("/patients/{patient_id}/active_session")
208
+ def get_active_session(
209
+ patient_id: str,
210
+ patient_service: PatientService = Depends(get_patient_service),
211
+ user: dict = Depends(get_current_user)
212
+ ):
213
+ """Check if the patient currently has an active session."""
214
+ session_id = patient_service.get_active_session(patient_id)
215
+ return {"session_id": session_id}
216
+
217
+ @router.get("/sessions/{session_id}/messages")
218
+ def get_session_messages(
219
+ session_id: str,
220
+ patient_service: PatientService = Depends(get_patient_service),
221
+ user: dict = Depends(get_current_user)
222
+ ):
223
+ """Retrieve all raw messages for a given session to continue a chat."""
224
+ messages = patient_service.get_session_messages(session_id)
225
+ return {"messages": messages}
226
+
227
+ @router.get("/{full_path:path}", response_class=HTMLResponse)
228
+ def catch_all(request: Request, full_path: str):
229
+ """
230
+ Catch-all route to support History API (clean URLs) in the frontend SPA.
231
+ If the user navigates directly to /profiles or /dashboard, serve index.html
232
+ so the frontend JS can handle the routing.
233
+ """
234
+ return templates.TemplateResponse(request, "index.html")
static/css/style.css ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Public+Sans:ital,wght@0,100..900;1,100..900&display=swap');
2
+
3
+ :root {
4
+ /* Colors */
5
+ --color-paper: #F6F3EE;
6
+ --color-ink: #262521;
7
+ --color-moss: #4B6358;
8
+ --color-clay: #9C7A5B;
9
+ --color-signal: #B4483C;
10
+
11
+ /* Typography */
12
+ --font-display: 'Fraunces', serif;
13
+ --font-body: 'Public Sans', sans-serif;
14
+ --font-utility: 'IBM Plex Mono', monospace;
15
+ }
16
+
17
+ body {
18
+ background-color: var(--color-paper);
19
+ color: var(--color-ink);
20
+ font-family: var(--font-body);
21
+ }
22
+
23
+ /* Base resets to clear generic Tailwind */
24
+ h1,
25
+ h2,
26
+ h3,
27
+ h4,
28
+ h5,
29
+ h6 {
30
+ font-family: var(--font-display);
31
+ }
32
+
33
+ .font-display {
34
+ font-family: var(--font-display);
35
+ }
36
+
37
+ .font-utility {
38
+ font-family: var(--font-utility);
39
+ }
40
+
41
+ .font-body {
42
+ font-family: var(--font-body);
43
+ }
44
+
45
+ /* Remove previous animations and float effects */
46
+ .fade-in {
47
+ animation: fadeIn 0.15s ease-out;
48
+ }
49
+
50
+ @keyframes fadeIn {
51
+ from {
52
+ opacity: 0;
53
+ transform: translateY(4px);
54
+ }
55
+
56
+ to {
57
+ opacity: 1;
58
+ transform: translateY(0);
59
+ }
60
+ }
61
+
62
+ @media (prefers-reduced-motion: reduce) {
63
+ .fade-in {
64
+ animation: none;
65
+ }
66
+ }
67
+
68
+ .custom-scrollbar::-webkit-scrollbar {
69
+ width: 6px;
70
+ }
71
+
72
+ .custom-scrollbar::-webkit-scrollbar-track {
73
+ background: var(--color-paper);
74
+ }
75
+
76
+ .custom-scrollbar::-webkit-scrollbar-thumb {
77
+ background: var(--color-clay);
78
+ border-radius: 3px;
79
+ }
80
+
81
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
82
+ background: var(--color-moss);
83
+ }
84
+
85
+ /* Loading Screen */
86
+ #loadingScreen {
87
+ position: fixed;
88
+ top: 0;
89
+ left: 0;
90
+ width: 100%;
91
+ height: 100%;
92
+ background: var(--color-paper);
93
+ display: flex;
94
+ flex-direction: column;
95
+ align-items: center;
96
+ justify-content: center;
97
+ z-index: 9999;
98
+ transition: opacity 0.5s ease-out;
99
+ }
100
+
101
+ #loadingScreen.hidden {
102
+ opacity: 0;
103
+ pointer-events: none;
104
+ }
105
+
106
+ .logo-text {
107
+ font-family: var(--font-display);
108
+ font-size: clamp(3rem, 10vw, 5rem);
109
+ font-weight: 400;
110
+ color: var(--color-ink);
111
+ margin-bottom: 1rem;
112
+ }
113
+
114
+ .loading-dots {
115
+ display: flex;
116
+ gap: 0.5rem;
117
+ margin-top: 1rem;
118
+ }
119
+
120
+ .loading-dots span {
121
+ width: 8px;
122
+ height: 8px;
123
+ border-radius: 50%;
124
+ background: var(--color-ink);
125
+ animation: breathe 2s infinite ease-in-out both;
126
+ }
127
+
128
+ @keyframes breathe {
129
+
130
+ 0%,
131
+ 100% {
132
+ transform: scale(0.8);
133
+ opacity: 0.3;
134
+ }
135
+
136
+ 50% {
137
+ transform: scale(1.2);
138
+ opacity: 0.8;
139
+ }
140
+ }
141
+
142
+ /* Panels */
143
+ .panel {
144
+ background: var(--color-paper);
145
+ border: 1px solid var(--color-ink);
146
+ box-shadow: none;
147
+ }
148
+
149
+ /* Chat Input */
150
+ .chat-input-container {
151
+ display: flex;
152
+ gap: 0.5rem;
153
+ align-items: center;
154
+ background: transparent;
155
+ padding: 0.75rem;
156
+ border: 1px solid var(--color-ink);
157
+ border-radius: 0;
158
+ }
159
+
160
+ .chat-input {
161
+ flex: 1;
162
+ background: transparent;
163
+ border: none;
164
+ outline: none;
165
+ color: var(--color-ink);
166
+ font-family: var(--font-body);
167
+ font-size: 0.95rem;
168
+ padding: 0.5rem 1rem;
169
+ }
170
+
171
+ .chat-input::placeholder {
172
+ color: var(--color-clay);
173
+ }
174
+
175
+ /* Buttons */
176
+ .btn-primary {
177
+ background: var(--color-moss);
178
+ color: var(--color-paper);
179
+ border: 1px solid var(--color-moss);
180
+ padding: 0.5rem 1rem;
181
+ font-family: var(--font-body);
182
+ cursor: pointer;
183
+ transition: background 0.2s ease, color 0.2s ease;
184
+ }
185
+
186
+ .btn-primary:hover {
187
+ background: transparent;
188
+ color: var(--color-moss);
189
+ }
190
+
191
+ .btn-primary:disabled {
192
+ opacity: 0.5;
193
+ cursor: not-allowed;
194
+ }
195
+
196
+ .btn-toggle {
197
+ background: transparent;
198
+ color: var(--color-ink);
199
+ border: none;
200
+ border-bottom: 2px solid transparent;
201
+ padding: 0.5rem;
202
+ font-family: var(--font-body);
203
+ cursor: pointer;
204
+ }
205
+
206
+ .btn-toggle.active {
207
+ border-bottom-color: var(--color-moss);
208
+ font-weight: 600;
209
+ }
210
+
211
+ /* Chat Messages */
212
+ .message-user {
213
+ text-align: left;
214
+ font-family: var(--font-body);
215
+ color: var(--color-ink);
216
+ padding: 1rem 0;
217
+ }
218
+
219
+ .message-assistant {
220
+ text-align: left;
221
+ font-family: var(--font-body);
222
+ color: var(--color-ink);
223
+ padding: 1rem 0;
224
+ }
225
+
226
+ .assistant-label {
227
+ font-family: var(--font-utility);
228
+ color: var(--color-clay);
229
+ font-size: 0.8rem;
230
+ text-transform: uppercase;
231
+ letter-spacing: 0.05em;
232
+ display: block;
233
+ margin-bottom: 0.5rem;
234
+ }
235
+
236
+ .safety-banner {
237
+ background: var(--color-paper);
238
+ border: 1px solid var(--color-signal);
239
+ color: var(--color-ink);
240
+ padding: 1rem;
241
+ margin: 1rem 0;
242
+ }
243
+
244
+ .safety-banner-title {
245
+ color: var(--color-signal);
246
+ font-family: var(--font-display);
247
+ margin-bottom: 0.5rem;
248
+ }
249
+
250
+ .typing-indicator span {
251
+ display: inline-block;
252
+ width: 6px;
253
+ height: 6px;
254
+ border-radius: 50%;
255
+ background: var(--color-ink);
256
+ animation: breathe 1.4s infinite;
257
+ }
258
+
259
+ .typing-indicator span:nth-child(2) {
260
+ animation-delay: 0.2s;
261
+ }
262
+
263
+ .typing-indicator span:nth-child(3) {
264
+ animation-delay: 0.4s;
265
+ }
266
+
267
+ .dashboard-label {
268
+ font-family: var(--font-utility);
269
+ color: var(--color-clay);
270
+ font-size: 0.85rem;
271
+ text-transform: uppercase;
272
+ }
273
+
274
+ .dashboard-value {
275
+ font-family: var(--font-body);
276
+ color: var(--color-ink);
277
+ }
static/icons/logo.svg ADDED
static/js/api.js ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const BACKEND_URL = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
2
+ ? "http://127.0.0.1:8000"
3
+ : "https://amogh1221-serinity.hf.space";
4
+
5
+ // Helper to get token
6
+ export function getToken() {
7
+ return localStorage.getItem('serinity_token');
8
+ }
9
+
10
+ // Global fetch wrapper for auth
11
+ export async function authFetch(url, options = {}) {
12
+ const token = getToken();
13
+ const headers = { ...options.headers };
14
+
15
+ if (token) {
16
+ headers['Authorization'] = `Bearer ${token}`;
17
+ }
18
+
19
+ // Don't override FormData headers
20
+ if (!(options.body instanceof FormData) && !headers['Content-Type']) {
21
+ headers['Content-Type'] = 'application/json';
22
+ }
23
+
24
+ const res = await fetch(url, { ...options, headers });
25
+
26
+ // Handle unauthorized globaly
27
+ if (res.status === 401) {
28
+ localStorage.removeItem('serinity_token');
29
+ window.dispatchEvent(new Event('unauthorized'));
30
+ throw new Error('Unauthorized');
31
+ }
32
+
33
+ return res;
34
+ }
35
+
36
+ /**
37
+ * Fetches the list of patients associated with the logged-in user.
38
+ * @returns {Promise<Array>} List of patient objects.
39
+ */
40
+ export async function fetchPatientsList() {
41
+ const res = await authFetch(`${BACKEND_URL}/patients`, { cache: "no-store" });
42
+ return res.json();
43
+ }
44
+
45
+ /**
46
+ * Loads the dashboard data for a specific patient.
47
+ * @param {string} patientId
48
+ * @returns {Promise<Object>} Dashboard overview data.
49
+ */
50
+ export async function loadDashboard(patientId) {
51
+ const res = await authFetch(`${BACKEND_URL}/patients/${patientId}/dashboard`, { cache: "no-store" });
52
+ if (!res.ok) throw new Error("Dashboard load failed");
53
+ return res.json();
54
+ }
55
+
56
+ /**
57
+ * Resets a patient's historical profile and sessions.
58
+ * @param {string} patientId
59
+ */
60
+ export async function resetPatientProfile(patientId) {
61
+ return authFetch(`${BACKEND_URL}/patients/${patientId}/reset`, { method: "POST" });
62
+ }
63
+
64
+ /**
65
+ * Deletes a patient profile completely.
66
+ * @param {string} patientId
67
+ */
68
+ export async function deletePatientProfile(patientId) {
69
+ return authFetch(`${BACKEND_URL}/patients/${patientId}`, { method: "DELETE" });
70
+ }
71
+
72
+ /**
73
+ * Creates a new patient profile linked to the user.
74
+ * @param {Object} data
75
+ * @returns {Promise<Object>} The created patient data.
76
+ */
77
+ export async function createPatientProfile(data) {
78
+ const res = await authFetch(`${BACKEND_URL}/patients/create`, {
79
+ method: "POST",
80
+ body: JSON.stringify(data)
81
+ });
82
+ return res.json();
83
+ }
84
+
85
+ export async function pingHealth() {
86
+ // health doesn't need auth, but authFetch is safe to use
87
+ return fetch(`${BACKEND_URL}/health`);
88
+ }
89
+
90
+ export async function startSessionReq(patientId) {
91
+ const res = await authFetch(`${BACKEND_URL}/start`, {
92
+ method: "POST",
93
+ body: JSON.stringify({ patient_id: patientId })
94
+ });
95
+ if (!res.ok) {
96
+ let errorDetail = `HTTP Error ${res.status}`;
97
+ try {
98
+ const data = await res.json();
99
+ errorDetail = data.detail || errorDetail;
100
+ } catch(e) {}
101
+ throw new Error(errorDetail);
102
+ }
103
+ return res.json();
104
+ }
105
+
106
+ export async function sendChatText(sessionId, patientId, message, emotion) {
107
+ const res = await authFetch(`${BACKEND_URL}/chat_text`, {
108
+ method: "POST",
109
+ body: JSON.stringify({
110
+ message: message,
111
+ session_id: sessionId,
112
+ patient_id: patientId,
113
+ emotion: emotion
114
+ })
115
+ });
116
+ if (!res.ok) {
117
+ let errorDetail = `HTTP Error ${res.status}`;
118
+ try {
119
+ const data = await res.json();
120
+ errorDetail = data.detail || errorDetail;
121
+ } catch(e) {}
122
+ throw new Error(errorDetail);
123
+ }
124
+ return res.json();
125
+ }
126
+
127
+ export async function transcribeAudio(audioBlob) {
128
+ const formData = new FormData();
129
+ formData.append('audio', audioBlob, 'recording.webm');
130
+
131
+ // Custom headers because authFetch handles FormData correctly if Content-Type is omitted
132
+ const res = await authFetch(`${BACKEND_URL}/transcribe`, {
133
+ method: "POST",
134
+ body: formData
135
+ });
136
+ return res.json();
137
+ }
138
+
139
+ export async function endSessionReq(sessionId, patientId) {
140
+ return authFetch(`${BACKEND_URL}/end_session`, {
141
+ method: "POST",
142
+ body: JSON.stringify({
143
+ session_id: sessionId,
144
+ patient_id: patientId
145
+ })
146
+ });
147
+ }
148
+
149
+ export async function getActiveSession(patientId) {
150
+ const res = await authFetch(`${BACKEND_URL}/patients/${patientId}/active_session`, { cache: "no-store" });
151
+ return res.json();
152
+ }
153
+
154
+ export async function getSessionMessages(sessionId) {
155
+ const res = await authFetch(`${BACKEND_URL}/sessions/${sessionId}/messages`, { cache: "no-store" });
156
+ return res.json();
157
+ }
static/js/app.js ADDED
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from './state.js';
2
+ import * as api from './api.js';
3
+ import * as ui from './ui.js';
4
+ import * as audio from './audio.js';
5
+ import * as profiles from './profiles.js';
6
+ import * as session from './session.js';
7
+ import * as auth from './auth.js';
8
+
9
+ // DOM Elements specific to top-level app logic
10
+ const startButton = document.getElementById("startButton");
11
+ const voiceModeBtn = document.getElementById("voiceModeBtn");
12
+ const textModeBtn = document.getElementById("textModeBtn");
13
+ const voiceInput = document.getElementById("voiceInput");
14
+ const textInput = document.getElementById("textInput");
15
+ const chatInput = document.getElementById("chatInput");
16
+ const sendBtn = document.getElementById("sendBtn");
17
+ const micBtn = document.getElementById("micBtn");
18
+
19
+ /**
20
+ * Binds all global events to the DOM (button clicks, form submits, keypresses).
21
+ */
22
+ function bindEvents() {
23
+ // Handle browser back/forward buttons
24
+ window.addEventListener("popstate", (event) => {
25
+ if (event.state && event.state.screen) {
26
+ ui.switchScreen(event.state.screen, false);
27
+ } else {
28
+ const path = window.location.pathname;
29
+ if (path === "/dashboard" || path === "/session") {
30
+ if (!state.patientId) {
31
+ profiles.fetchPatients();
32
+ } else {
33
+ ui.switchScreen(path === "/dashboard" ? "dashboardScreen" : "mainApp", false);
34
+ }
35
+ } else {
36
+ ui.switchScreen("loadingScreen", false);
37
+ }
38
+ }
39
+ });
40
+
41
+ // Global Unauthorized Handler
42
+ window.addEventListener("unauthorized", () => {
43
+ ui.switchScreen("authScreen", false);
44
+ });
45
+
46
+ // --- Auth Events ---
47
+ const loginFormContainer = document.getElementById("loginFormContainer");
48
+ const signupFormContainer = document.getElementById("signupFormContainer");
49
+ const forgotPwdFormContainer = document.getElementById("forgotPwdFormContainer");
50
+ const otpStepContainer = document.getElementById("otpStepContainer");
51
+ const newPwdStepContainer = document.getElementById("newPwdStepContainer");
52
+ const signupOtpStepContainer = document.getElementById("signupOtpStepContainer");
53
+
54
+ // Track verified OTP state in memory (not persisted — refresh = back to login)
55
+ let _otpVerifiedEmail = null;
56
+
57
+ function showAuthPanel(panelId) {
58
+ // Only hide/show panels that exist
59
+ const panels = [loginFormContainer, signupFormContainer, forgotPwdFormContainer, otpStepContainer, newPwdStepContainer, signupOtpStepContainer];
60
+ panels.forEach(p => {
61
+ if(p) {
62
+ p.classList.add('hidden');
63
+ p.classList.remove('block');
64
+ }
65
+ });
66
+ const target = document.getElementById(panelId);
67
+ if (target) {
68
+ target.classList.remove('hidden');
69
+ target.classList.add('block');
70
+ } else {
71
+ console.error("Auth panel not found:", panelId);
72
+ }
73
+ }
74
+
75
+ document.getElementById("showSignupBtn")?.addEventListener("click", () => showAuthPanel("signupFormContainer"));
76
+ document.getElementById("showForgotPwdBtn")?.addEventListener("click", () => showAuthPanel("forgotPwdFormContainer"));
77
+ document.getElementById("backToLoginBtn1")?.addEventListener("click", () => showAuthPanel("loginFormContainer"));
78
+ document.getElementById("backToLoginBtn2")?.addEventListener("click", () => showAuthPanel("loginFormContainer"));
79
+ document.getElementById("backToForgotBtn")?.addEventListener("click", () => showAuthPanel("forgotPwdFormContainer"));
80
+ document.getElementById("backToSignupBtn")?.addEventListener("click", () => showAuthPanel("signupFormContainer"));
81
+
82
+ // Populate Nationality Dropdown
83
+ const countries = [
84
+ "India", "Afghanistan", "Albania", "Algeria", "Andorra", "Angola", "Antigua and Barbuda",
85
+ "Argentina", "Armenia", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", "Bangladesh",
86
+ "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bhutan", "Bolivia",
87
+ "Bosnia and Herzegovina", "Botswana", "Brazil", "Brunei", "Bulgaria", "Burkina Faso",
88
+ "Burundi", "Côte d'Ivoire", "Cabo Verde", "Cambodia", "Cameroon", "Canada", "Central African Republic",
89
+ "Chad", "Chile", "China", "Colombia", "Comoros", "Congo (Congo-Brazzaville)", "Costa Rica",
90
+ "Croatia", "Cuba", "Cyprus", "Czechia (Czech Republic)", "Democratic Republic of the Congo",
91
+ "Denmark", "Djibouti", "Dominica", "Dominican Republic", "Ecuador", "Egypt", "El Salvador",
92
+ "Equatorial Guinea", "Eritrea", "Estonia", "Eswatini", "Ethiopia", "Fiji", "Finland",
93
+ "France", "Gabon", "Gambia", "Georgia", "Germany", "Ghana", "Greece", "Grenada",
94
+ "Guatemala", "Guinea", "Guinea-Bissau", "Guyana", "Haiti", "Holy See", "Honduras",
95
+ "Hungary", "Iceland", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", "Italy", "Jamaica",
96
+ "Japan", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Kuwait", "Kyrgyzstan", "Laos",
97
+ "Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg",
98
+ "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Mauritania",
99
+ "Mauritius", "Mexico", "Micronesia", "Moldova", "Monaco", "Mongolia", "Montenegro", "Morocco",
100
+ "Mozambique", "Myanmar (formerly Burma)", "Namibia", "Nauru", "Nepal", "Netherlands", "New Zealand",
101
+ "Nicaragua", "Niger", "Nigeria", "North Korea", "North Macedonia", "Norway", "Oman", "Pakistan",
102
+ "Palau", "Palestine State", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Poland",
103
+ "Portugal", "Qatar", "Romania", "Russia", "Rwanda", "Saint Kitts and Nevis", "Saint Lucia",
104
+ "Saint Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia",
105
+ "Senegal", "Serbia", "Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia",
106
+ "Solomon Islands", "Somalia", "South Africa", "South Korea", "South Sudan", "Spain", "Sri Lanka",
107
+ "Sudan", "Suriname", "Sweden", "Switzerland", "Syria", "Tajikistan", "Tanzania", "Thailand",
108
+ "Timor-Leste", "Togo", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan",
109
+ "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States", "Uruguay",
110
+ "Uzbekistan", "Vanuatu", "Venezuela", "Vietnam", "Yemen", "Zambia", "Zimbabwe"
111
+ ];
112
+
113
+ const signupNationality = document.getElementById("signupNationality");
114
+ const nationalityDropdown = document.getElementById("nationalityDropdown");
115
+
116
+ if (signupNationality && nationalityDropdown) {
117
+ function renderCountries(filterText = "") {
118
+ nationalityDropdown.innerHTML = "";
119
+ const filtered = countries.filter(c => c.toLowerCase().includes(filterText.toLowerCase()));
120
+ filtered.forEach(country => {
121
+ const div = document.createElement("div");
122
+ div.className = "p-2 hover:bg-moss/20 cursor-pointer text-sm text-ink";
123
+ div.textContent = country;
124
+ div.onmousedown = (e) => {
125
+ e.preventDefault(); // prevent blur
126
+ signupNationality.value = country;
127
+ nationalityDropdown.classList.add("hidden");
128
+ };
129
+ nationalityDropdown.appendChild(div);
130
+ });
131
+ if (filtered.length === 0) {
132
+ const div = document.createElement("div");
133
+ div.className = "p-2 text-sm text-clay italic";
134
+ div.textContent = "No matches";
135
+ nationalityDropdown.appendChild(div);
136
+ }
137
+ }
138
+
139
+ signupNationality.addEventListener("focus", () => {
140
+ renderCountries(signupNationality.value);
141
+ nationalityDropdown.classList.remove("hidden");
142
+ });
143
+
144
+ signupNationality.addEventListener("input", (e) => {
145
+ renderCountries(e.target.value);
146
+ nationalityDropdown.classList.remove("hidden");
147
+ });
148
+
149
+ signupNationality.addEventListener("blur", () => {
150
+ nationalityDropdown.classList.add("hidden");
151
+ });
152
+ }
153
+
154
+ // Helper function to strip spaces from OTP input
155
+ function setupOtpInputStripper(fieldId) {
156
+ const field = document.getElementById(fieldId);
157
+ if (field) {
158
+ field.addEventListener('input', (e) => {
159
+ // Remove all spaces from the input value
160
+ e.target.value = e.target.value.replace(/\s/g, '');
161
+ });
162
+ }
163
+ }
164
+
165
+ // Setup OTP input strippers for all OTP fields (signup, forgot password, delete account)
166
+ setupOtpInputStripper("signupOtpCode");
167
+ setupOtpInputStripper("resetOtpCode");
168
+ setupOtpInputStripper("deleteAccountOtpCode");
169
+
170
+ document.getElementById("loginBtn")?.addEventListener("click", async () => {
171
+ const user = document.getElementById("loginUsername").value.trim();
172
+ const pass = document.getElementById("loginPassword").value;
173
+ if (!user || !pass) return ui.showAlert("Login Error", "Please enter both username/email and password");
174
+
175
+ try {
176
+ document.getElementById("loginBtn").textContent = "Logging In...";
177
+ await auth.login(user, pass);
178
+ document.getElementById("loginBtn").textContent = "Log In";
179
+ await profiles.fetchPatients();
180
+ } catch (e) {
181
+ ui.showAlert("Login Failed", e.message);
182
+ document.getElementById("loginBtn").textContent = "Log In";
183
+ }
184
+ });
185
+
186
+ document.getElementById("signupCompleteBtn")?.addEventListener("click", async () => {
187
+ const payload = {
188
+ name: document.getElementById("signupName").value.trim(),
189
+ username: document.getElementById("signupUsername").value.trim(),
190
+ email: document.getElementById("signupEmail").value.trim(),
191
+ password: document.getElementById("signupPassword").value,
192
+ gender: document.getElementById("signupGender").value,
193
+ age: parseInt(document.getElementById("signupAge").value) || null,
194
+ nationality: document.getElementById("signupNationality").value,
195
+ primary_concern: document.getElementById("signupConcern").value,
196
+ emergency_contact_name: document.getElementById("signupEmergencyName").value.trim() || null,
197
+ emergency_contact_phone: document.getElementById("signupEmergencyPhone").value.trim() || null
198
+ };
199
+
200
+ if (!payload.name || !payload.username || !payload.email || !payload.password || !payload.gender || !payload.nationality || !payload.primary_concern) {
201
+ return ui.showAlert("Signup Error", "Please fill out all required fields (*).");
202
+ }
203
+
204
+ if (payload.password.length < 8) {
205
+ return ui.showAlert("Signup Error", "Password must be at least 8 characters long.");
206
+ }
207
+
208
+ if (payload.age !== null && (payload.age < 5 || payload.age > 99)) {
209
+ return ui.showAlert("Signup Error", "Age must be between 5 and 99.");
210
+ }
211
+
212
+ try {
213
+ document.getElementById("signupCompleteBtn").textContent = "Creating Account...";
214
+ document.getElementById("signupCompleteBtn").disabled = true;
215
+
216
+ await auth.signup(payload);
217
+
218
+ ui.showAlert("Success", "Account created successfully!");
219
+ showAuthPanel("loginFormContainer");
220
+
221
+ await profiles.fetchPatients();
222
+ } catch (e) {
223
+ ui.showAlert("Signup Failed", e.message);
224
+ } finally {
225
+ document.getElementById("signupCompleteBtn").textContent = "Create Account";
226
+ document.getElementById("signupCompleteBtn").disabled = false;
227
+ }
228
+ });
229
+
230
+ document.getElementById("submitResetBtn")?.addEventListener("click", async () => {
231
+ const email = document.getElementById("forgotPwdEmail").value.trim();
232
+ if (!email) return ui.showAlert("Reset Password", "Please enter your email.");
233
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
234
+ if (!emailRegex.test(email)) return ui.showAlert("Reset Password", "Please enter a valid email address.");
235
+
236
+ const newPass = document.getElementById("resetNewPassword").value;
237
+ if (!newPass || newPass.length < 8) return ui.showAlert("Reset Password", "New password must be at least 8 characters long.");
238
+ try {
239
+ document.getElementById("submitResetBtn").textContent = "Updating...";
240
+ document.getElementById("submitResetBtn").disabled = true;
241
+ await auth.resetPassword(email, newPass);
242
+ ui.showAlert("Success", "Password reset successfully! Please log in.");
243
+ showAuthPanel("loginFormContainer");
244
+ } catch (e) {
245
+ ui.showAlert("Reset Failed", e.message);
246
+ } finally {
247
+ document.getElementById("submitResetBtn").textContent = "Update Password";
248
+ document.getElementById("submitResetBtn").disabled = false;
249
+ }
250
+ });
251
+
252
+ // --- Profiles & Dashboard Events ---
253
+
254
+ const patientDropdown = document.getElementById("patientDropdown");
255
+ if (patientDropdown) patientDropdown.onchange = profiles.toggleSelectButton;
256
+
257
+ const dashboardLogoutBtn = document.getElementById("dashboardLogoutBtn");
258
+ if (dashboardLogoutBtn) {
259
+ dashboardLogoutBtn.onclick = () => {
260
+ auth.logout();
261
+ ui.switchScreen("authScreen");
262
+ };
263
+ }
264
+
265
+ const dashboardResetBtn = document.getElementById("dashboardResetBtn");
266
+ if (dashboardResetBtn) {
267
+ dashboardResetBtn.onclick = () => {
268
+ ui.showConfirm(
269
+ "Reset Profile?",
270
+ "Are you sure you want to reset this profile? All sessions, messages, and analysis will be permanently deleted. The patient record will remain.",
271
+ async () => {
272
+ try {
273
+ await api.resetPatientProfile(state.patientId);
274
+ } catch (e) {
275
+ console.error("Error resetting profile:", e);
276
+ }
277
+ document.getElementById("dashboardSessionsList").innerHTML = `<p class="text-clay text-sm italic">No previous sessions found.</p>`;
278
+ document.getElementById("dashboardDomains").innerHTML = `
279
+ <h3 class="font-utility uppercase text-clay text-sm border-b border-ink/30 pb-2">Clinical Profile</h3>
280
+ <p class="text-clay text-sm italic mt-4">Profile has been reset.</p>
281
+ `;
282
+ }
283
+ );
284
+ };
285
+ }
286
+
287
+ const dashboardDeleteBtn = document.getElementById("dashboardDeleteBtn");
288
+ if (dashboardDeleteBtn) {
289
+ dashboardDeleteBtn.onclick = () => {
290
+ document.getElementById("deleteAccountConfirmModal").style.display = "flex";
291
+ };
292
+ }
293
+
294
+ const deleteAccountCancelBtn = document.getElementById("deleteAccountCancelBtn");
295
+ if (deleteAccountCancelBtn) {
296
+ deleteAccountCancelBtn.onclick = () => {
297
+ document.getElementById("deleteAccountConfirmModal").style.display = "none";
298
+ };
299
+ }
300
+
301
+ const deleteAccountConfirmBtn = document.getElementById("deleteAccountConfirmBtn");
302
+ if (deleteAccountConfirmBtn) {
303
+ deleteAccountConfirmBtn.onclick = async () => {
304
+ try {
305
+ deleteAccountConfirmBtn.textContent = "Deleting...";
306
+ deleteAccountConfirmBtn.disabled = true;
307
+
308
+ await auth.deleteAccount();
309
+
310
+ document.getElementById("deleteAccountConfirmModal").style.display = "none";
311
+ auth.logout();
312
+ ui.switchScreen("authScreen");
313
+
314
+ } catch (e) {
315
+ console.error("Error deleting account:", e);
316
+ ui.showAlert("Error", e.message);
317
+ } finally {
318
+ deleteAccountConfirmBtn.textContent = "DELETE";
319
+ deleteAccountConfirmBtn.disabled = false;
320
+ }
321
+ };
322
+ }
323
+ const dashboardStartBtn = document.getElementById("dashboardStartBtn");
324
+ if (dashboardStartBtn) {
325
+ dashboardStartBtn.onclick = async () => {
326
+ ui.switchScreen("loadingScreen", false);
327
+ startButton.textContent = "Checking Session...";
328
+ startButton.disabled = true;
329
+
330
+ try {
331
+ const active = await api.getActiveSession(state.patientId);
332
+ if (active && active.session_id) {
333
+ ui.switchScreen("dashboardScreen");
334
+ const modal = document.getElementById("continueSessionModal");
335
+ modal.style.display = "flex";
336
+
337
+ document.getElementById("continueSessionBtn").onclick = async () => {
338
+ modal.style.display = "none";
339
+ ui.switchScreen("loadingScreen", false);
340
+ startButton.textContent = "Resuming Session...";
341
+ await session.continueActualSession(active.session_id);
342
+ };
343
+
344
+ document.getElementById("endAndStartNewBtn").onclick = async () => {
345
+ modal.style.display = "none";
346
+ ui.switchScreen("loadingScreen", false);
347
+ startButton.textContent = "Loading Session...";
348
+ state.setSessionId(null);
349
+ await api.endSessionReq(active.session_id, state.patientId);
350
+ await session.startActualSession(state.patientId);
351
+ };
352
+ } else {
353
+ startButton.textContent = "Loading Session...";
354
+ await session.startActualSession(state.patientId);
355
+ }
356
+ } catch (e) {
357
+ console.error(e);
358
+ startButton.textContent = "Loading Session...";
359
+ await session.startActualSession(state.patientId);
360
+ }
361
+ };
362
+ }
363
+
364
+ const selectProfileBtn = document.getElementById("selectProfileBtn");
365
+ if (selectProfileBtn) {
366
+ selectProfileBtn.onclick = async () => {
367
+ state.setPatientId(patientDropdown.value);
368
+ await profiles.showDashboard(state.patientId);
369
+ };
370
+ }
371
+
372
+ const createProfileBtn = document.getElementById("createProfileBtn");
373
+ if (createProfileBtn) {
374
+ createProfileBtn.onclick = async () => {
375
+ const nameInput = document.getElementById("newPatientName");
376
+ const ageInput = document.getElementById("newPatientAge");
377
+ const genderInput = document.getElementById("newPatientGender");
378
+ const occupationInput = document.getElementById("newPatientOccupation");
379
+ const concernInput = document.getElementById("newPatientConcern");
380
+
381
+ const name = nameInput.value.trim();
382
+ const age = ageInput.value;
383
+ const gender = genderInput ? genderInput.value : null;
384
+ const occupation = occupationInput ? occupationInput.value.trim() : null;
385
+
386
+ if (!name || !age || !gender || !occupation) {
387
+ ui.showAlert("Missing Information", "Please fill out all 4 required fields (Name, Age, Gender, and Occupation) to create a new profile.");
388
+ return;
389
+ }
390
+
391
+ const parsedAge = parseInt(age);
392
+ if (isNaN(parsedAge) || parsedAge < 5 || parsedAge > 99) {
393
+ ui.showAlert("Invalid Age", "Age must be between 5 and 99.");
394
+ return;
395
+ }
396
+
397
+ try {
398
+ const data = {
399
+ name: name,
400
+ age: parseInt(age),
401
+ gender: gender,
402
+ occupation: occupation,
403
+ primary_concern: concernInput ? concernInput.value.trim() : null
404
+ };
405
+ const pData = await api.createPatientProfile(data);
406
+ state.setPatientId(pData.patient_id);
407
+ await profiles.showDashboard(state.patientId);
408
+ } catch (e) {
409
+ console.error("Failed to create profile:", e);
410
+ ui.showAlert("Error", "Failed to create patient profile. Please try again.");
411
+ }
412
+ };
413
+ }
414
+
415
+ if (startButton) {
416
+ startButton.onclick = async () => {
417
+ const text = startButton.textContent.trim();
418
+ if (text.includes("Start Consultation")) {
419
+ if (!auth.isAuthenticated()) {
420
+ ui.switchScreen("authScreen", false);
421
+ return;
422
+ }
423
+
424
+ startButton.textContent = "Loading...";
425
+ startButton.disabled = true;
426
+ try {
427
+ await profiles.fetchPatients();
428
+ } catch(e) {
429
+ console.error(e);
430
+ ui.showAlert("Error", "Failed to load dashboard. Check console.");
431
+ } finally {
432
+ startButton.textContent = "Start Consultation";
433
+ startButton.disabled = false;
434
+ }
435
+ } else if (text.includes("Retry")) {
436
+ startButton.textContent = "Initializing...";
437
+ startButton.disabled = true;
438
+ session.initializeSession();
439
+ }
440
+ };
441
+ }
442
+
443
+ // --- Session UI Events (Toggle Text/Voice, etc) ---
444
+
445
+ if (voiceModeBtn) {
446
+ voiceModeBtn.onclick = (e) => {
447
+ state.isVoiceMode = true;
448
+ voiceModeBtn.classList.add("active");
449
+ textModeBtn.classList.remove("active");
450
+ voiceInput.style.display = "flex";
451
+ textInput.style.display = "none";
452
+
453
+ // Auto-enable audio output when switching to voice mode
454
+ if (!state.isAudioOutputEnabled) {
455
+ session.setAudioOutput(true);
456
+ }
457
+ e.currentTarget.blur();
458
+ };
459
+ }
460
+
461
+ if (textModeBtn) {
462
+ textModeBtn.onclick = (e) => {
463
+ state.isVoiceMode = false;
464
+ textModeBtn.classList.add("active");
465
+ voiceModeBtn.classList.remove("active");
466
+ voiceInput.style.display = "none";
467
+ textInput.style.display = "block";
468
+ chatInput.focus();
469
+ };
470
+ }
471
+
472
+ const voiceSelect = document.getElementById("voiceSelect");
473
+ if (voiceSelect) {
474
+ voiceSelect.addEventListener('change', (e) => e.target.blur());
475
+ }
476
+
477
+ const audioOutputToggleMobile = document.getElementById("audioOutputToggleMobile");
478
+ if (audioOutputToggleMobile) {
479
+ audioOutputToggleMobile.addEventListener('change', (e) => {
480
+ session.setAudioOutput(e.target.checked);
481
+ e.target.blur();
482
+ });
483
+ }
484
+
485
+ const audioOutputToggleDesktop = document.getElementById("audioOutputToggleDesktop");
486
+ if (audioOutputToggleDesktop) {
487
+ audioOutputToggleDesktop.addEventListener('click', (e) => {
488
+ session.setAudioOutput(!state.isAudioOutputEnabled);
489
+ e.currentTarget.blur();
490
+ });
491
+ }
492
+
493
+ if (sendBtn) {
494
+ sendBtn.onclick = () => {
495
+ const message = chatInput.value.trim();
496
+ if (message) {
497
+ chatInput.value = "";
498
+ session.primeTTS(); // Renew activation synchronously
499
+ session.sendTextMessage(message);
500
+ }
501
+ };
502
+ }
503
+
504
+ if (chatInput) {
505
+ chatInput.onkeypress = (e) => {
506
+ if (e.key === 'Enter') sendBtn.onclick();
507
+ };
508
+ }
509
+
510
+ // --- Audio Recording Events ---
511
+
512
+ if (micBtn) {
513
+ micBtn.addEventListener('pointerdown', (e) => {
514
+ e.preventDefault();
515
+ if (!state.isVoiceMode || state.isAISpeaking) return;
516
+ if (!state.isListening && !state.isRecordingPending) {
517
+ micBtn.style.transform = 'scale(0.95)';
518
+ audio.startRecording(session.sendTextMessage);
519
+ }
520
+ });
521
+
522
+ const stopMicAction = (e) => {
523
+ e.preventDefault();
524
+ if (state.isVoiceMode && (state.isListening || state.isRecordingPending)) {
525
+ micBtn.style.transform = 'none';
526
+ session.primeTTS(); // Renew activation synchronously
527
+ audio.stopRecording();
528
+ }
529
+ };
530
+
531
+ micBtn.addEventListener('pointerup', stopMicAction);
532
+ micBtn.addEventListener('pointercancel', stopMicAction);
533
+ micBtn.addEventListener('pointerleave', stopMicAction);
534
+ }
535
+
536
+ // Spacebar hold-to-record (capture phase to intercept before browser synthetic clicks)
537
+ window.addEventListener('keydown', (e) => {
538
+ if (e.code !== 'Space' || e.repeat) return;
539
+ if (!state.isVoiceMode) return;
540
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return;
541
+
542
+ e.preventDefault();
543
+ e.stopPropagation();
544
+
545
+ if (state.isAISpeaking) return;
546
+ if (!state.isListening && !state.isRecordingPending) {
547
+ audio.startRecording(session.sendTextMessage);
548
+ }
549
+ }, { capture: true });
550
+
551
+ window.addEventListener('keyup', (e) => {
552
+ if (e.code !== 'Space') return;
553
+ if (!state.isVoiceMode) return;
554
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return;
555
+
556
+ e.preventDefault();
557
+ e.stopPropagation();
558
+
559
+ session.primeTTS(); // Renew activation synchronously
560
+ audio.stopRecording();
561
+ }, { capture: true });
562
+
563
+ const endSessionBtn = document.getElementById("endSessionBtn");
564
+ if (endSessionBtn) {
565
+ endSessionBtn.onclick = async () => {
566
+ if (!state.sessionId) return;
567
+
568
+ if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
569
+ ui.setStatusText("Ending session...");
570
+
571
+ try {
572
+ await api.endSessionReq(state.sessionId, state.patientId);
573
+ state.setSessionId(null);
574
+ window.location.href = "/";
575
+ } catch (err) {
576
+ console.error("Failed to end session:", err);
577
+ state.setSessionId(null);
578
+ window.location.href = "/";
579
+ }
580
+ };
581
+ }
582
+ }
583
+
584
+ // Bootstrap application
585
+ api.pingHealth().catch(() => console.log("Wakeup ping sent."));
586
+ audio.loadVoices();
587
+ bindEvents();
588
+ window.onload = () => session.initializeSession();
static/js/audio.js ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from './state.js';
2
+ import { setStatusText, toggleMicButtonVisuals, addTyping, removeTyping } from './ui.js';
3
+ import { transcribeAudio } from './api.js';
4
+
5
+ let systemVoices = [];
6
+ let mediaRecorder = null;
7
+ let audioChunks = [];
8
+
9
+ // Keep a global reference so Chrome doesn't garbage-collect the utterance mid-speech
10
+ let currentUtterance = null;
11
+
12
+ export function loadVoices() {
13
+ const voiceSelect = document.getElementById("voiceSelect");
14
+ if (!voiceSelect) return;
15
+
16
+ systemVoices = window.speechSynthesis.getVoices();
17
+ voiceSelect.innerHTML = "";
18
+
19
+ // Add a reliable default option that doesn't force a specific voice object
20
+ const defaultOption = document.createElement("option");
21
+ defaultOption.value = "";
22
+ defaultOption.textContent = "Default OS Voice (Reliable)";
23
+ defaultOption.selected = true;
24
+ voiceSelect.appendChild(defaultOption);
25
+
26
+ const englishVoices = systemVoices.filter(v => v.lang.includes('en'));
27
+
28
+ englishVoices.forEach(voice => {
29
+ const option = document.createElement("option");
30
+ option.value = voice.name;
31
+ option.textContent = `${voice.name} (${voice.localService ? 'Local' : 'Network'})`;
32
+ voiceSelect.appendChild(option);
33
+ });
34
+
35
+ const googleUSEnglish = englishVoices.find(v => v.name === "Google US English");
36
+ if (googleUSEnglish) {
37
+ voiceSelect.value = googleUSEnglish.name;
38
+ }
39
+ }
40
+
41
+ if (window.speechSynthesis.onvoiceschanged !== undefined) {
42
+ window.speechSynthesis.onvoiceschanged = loadVoices;
43
+ }
44
+
45
+ /**
46
+ * Speak text using the browser's SpeechSynthesis API.
47
+ * Only speaks if audio output is enabled (state.isAudioOutputEnabled).
48
+ * This is independent of input mode (text vs voice).
49
+ */
50
+ export function speak(text) {
51
+ return new Promise((resolve) => {
52
+ if (!state.isAudioOutputEnabled) {
53
+ resolve();
54
+ return;
55
+ }
56
+
57
+ // Ensure voices are loaded
58
+ if (systemVoices.length === 0) {
59
+ systemVoices = window.speechSynthesis.getVoices();
60
+ }
61
+
62
+ // Strip markdown symbols before speaking
63
+ const cleanText = text.replace(/[*_~`#]/g, '').replace(/\[.*?\]/g, '').replace(/\(.*?\)/g, '').trim();
64
+ if (!cleanText) {
65
+ console.warn("TTS Skipped: Text was empty after stripping markdown");
66
+ resolve();
67
+ return;
68
+ }
69
+
70
+ setStatusText("Assistant is responding...");
71
+
72
+ // No cancel(), no setTimeout. Just create and speak.
73
+ currentUtterance = new SpeechSynthesisUtterance(cleanText);
74
+
75
+ // Select voice
76
+ const voiceSelect = document.getElementById("voiceSelect");
77
+ const selectedVoiceName = voiceSelect ? voiceSelect.value : "";
78
+
79
+ // Only set the voice if the user explicitly selected one of the specific named voices.
80
+ // Otherwise, leave it undefined so the browser uses its ultra-reliable native default.
81
+ if (selectedVoiceName !== "") {
82
+ const preferredVoice = systemVoices.find(v => v.name === selectedVoiceName);
83
+ if (preferredVoice) {
84
+ currentUtterance.voice = preferredVoice;
85
+ }
86
+ }
87
+ currentUtterance.rate = 1.0;
88
+ currentUtterance.pitch = 1.0;
89
+ currentUtterance.volume = 1.0;
90
+
91
+ currentUtterance.onstart = () => {
92
+ state.isAISpeaking = true;
93
+ setStatusText("Assistant is speaking... (Audio Started)");
94
+ };
95
+
96
+ currentUtterance.onend = () => {
97
+ state.isAISpeaking = false;
98
+ setStatusText(state.isVoiceMode ? "Hold Space to speak" : "Type your message");
99
+ resolve();
100
+ };
101
+
102
+ currentUtterance.onerror = (e) => {
103
+ console.error("TTS error:", e.error);
104
+ state.isAISpeaking = false;
105
+ setStatusText(`TTS Error: ${e.error}`);
106
+ setTimeout(() => {
107
+ setStatusText(state.isVoiceMode ? "Hold Space to speak" : "Type your message");
108
+ }, 3000);
109
+ resolve();
110
+ };
111
+
112
+ setStatusText("Attempting to speak...");
113
+
114
+ // Fallback timeout in case onstart/onend never fire
115
+ setTimeout(() => {
116
+ if (state.isAISpeaking) {
117
+ // It started, but maybe stuck?
118
+ } else {
119
+ setStatusText("Audio failed to start (Timeout)");
120
+ setTimeout(() => {
121
+ setStatusText(state.isVoiceMode ? "Hold Space to speak" : "Type your message");
122
+ }, 3000);
123
+ resolve();
124
+ }
125
+ }, 4000);
126
+
127
+ window.speechSynthesis.speak(currentUtterance);
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Starts recording the user's voice via MediaRecorder.
133
+ * Called only via spacebar hold.
134
+ */
135
+ export async function startRecording(onTranscriptionDone) {
136
+ if (state.isRecordingPending || state.isListening) return;
137
+ state.isRecordingPending = true;
138
+
139
+ try {
140
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
141
+
142
+ // If user released space before mic permission was granted
143
+ if (!state.isRecordingPending) {
144
+ stream.getTracks().forEach(track => track.stop());
145
+ return;
146
+ }
147
+
148
+ mediaRecorder = new MediaRecorder(stream);
149
+ audioChunks = [];
150
+
151
+ mediaRecorder.ondataavailable = (event) => {
152
+ if (event.data.size > 0) {
153
+ audioChunks.push(event.data);
154
+ }
155
+ };
156
+
157
+ mediaRecorder.onstop = async () => {
158
+ // Stop all mic tracks immediately
159
+ stream.getTracks().forEach(track => track.stop());
160
+
161
+ const audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
162
+
163
+ if (audioBlob.size < 100) {
164
+ setStatusText("Recording was too short. Hold Space longer.");
165
+ return;
166
+ }
167
+
168
+ setStatusText("Transcribing your voice...");
169
+ addTyping(true, "Transcribing...");
170
+
171
+ try {
172
+ const data = await transcribeAudio(audioBlob);
173
+ removeTyping();
174
+
175
+ if (data.text && data.text.trim() !== "") {
176
+ onTranscriptionDone(data.text, data.emotion);
177
+ } else {
178
+ setStatusText("I didn't catch that. Try again?");
179
+ }
180
+ } catch (e) {
181
+ removeTyping();
182
+ console.error("Transcription failed", e);
183
+ setStatusText("Hold Space to try again");
184
+ }
185
+ };
186
+
187
+ mediaRecorder.start();
188
+ state.isListening = true;
189
+ state.isRecordingPending = false;
190
+ toggleMicButtonVisuals(true);
191
+ setStatusText("Listening... (release Space to stop)");
192
+ } catch (err) {
193
+ console.error("Microphone access denied", err);
194
+ setStatusText("Microphone access denied");
195
+ state.isRecordingPending = false;
196
+ }
197
+ }
198
+
199
+ export function stopRecording() {
200
+ state.isRecordingPending = false;
201
+
202
+ if (mediaRecorder && state.isListening) {
203
+ try {
204
+ if (mediaRecorder.state === 'recording') {
205
+ mediaRecorder.stop();
206
+ }
207
+ } catch(e) {
208
+ console.error("Error stopping media recorder", e);
209
+ }
210
+
211
+ state.isListening = false;
212
+ toggleMicButtonVisuals(false);
213
+ setStatusText("Processing...");
214
+ } else {
215
+ state.isListening = false;
216
+ toggleMicButtonVisuals(false);
217
+ }
218
+ }
static/js/auth.js ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BACKEND_URL } from './api.js';
2
+
3
+ export async function login(username, password) {
4
+ const res = await fetch(`${BACKEND_URL}/api/auth/login`, {
5
+ method: "POST",
6
+ headers: { "Content-Type": "application/json" },
7
+ body: JSON.stringify({ username, password })
8
+ });
9
+
10
+ const data = await res.json();
11
+ if (!res.ok) {
12
+ throw new Error(data.detail || "Login failed");
13
+ }
14
+
15
+ localStorage.setItem('serinity_token', data.access_token);
16
+ return data;
17
+ }
18
+
19
+
20
+
21
+ export async function signup(payload) {
22
+ const res = await fetch(`${BACKEND_URL}/api/auth/signup`, {
23
+ method: "POST",
24
+ headers: { "Content-Type": "application/json" },
25
+ body: JSON.stringify(payload)
26
+ });
27
+
28
+ const data = await res.json();
29
+ if (!res.ok) {
30
+ if (Array.isArray(data.detail)) {
31
+ throw new Error(data.detail[0].msg || "Validation error");
32
+ }
33
+ throw new Error(data.detail || "Signup failed");
34
+ }
35
+
36
+ localStorage.setItem('serinity_token', data.access_token);
37
+ return data;
38
+ }
39
+
40
+ export async function resetPassword(email, new_password) {
41
+ const res = await fetch(`${BACKEND_URL}/api/auth/reset-password`, {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({ email, new_password })
45
+ });
46
+
47
+ const data = await res.json();
48
+ if (!res.ok) {
49
+ if (Array.isArray(data.detail)) {
50
+ throw new Error(data.detail[0].msg || "Validation error");
51
+ }
52
+ throw new Error(data.detail || "Failed to reset password");
53
+ }
54
+ return data;
55
+ }
56
+
57
+ export async function deleteAccount() {
58
+ const token = localStorage.getItem('serinity_token');
59
+ const res = await fetch(`${BACKEND_URL}/api/auth/delete-account`, {
60
+ method: "POST",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ "Authorization": `Bearer ${token}`
64
+ }
65
+ });
66
+ const data = await res.json();
67
+ if (!res.ok) throw new Error(data.detail || "Failed to delete account");
68
+ return data;
69
+ }
70
+
71
+ export function logout() {
72
+ localStorage.removeItem('serinity_token');
73
+ }
74
+
75
+ export function isAuthenticated() {
76
+ return !!localStorage.getItem('serinity_token');
77
+ }
static/js/profiles.js ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from './state.js';
2
+ import * as api from './api.js';
3
+ import * as ui from './ui.js';
4
+
5
+ export async function fetchPatients() {
6
+ try {
7
+ const patients = await api.fetchPatientsList();
8
+ if (patients.length > 0) {
9
+ state.patientId = patients[0].patient_id;
10
+ await showDashboard(state.patientId);
11
+ } else {
12
+ // This should never happen now since the backend auto-creates a profile, but just in case:
13
+ ui.showAlert("Profile Error", "No patient profile found. Please contact support.");
14
+ }
15
+ } catch (err) {
16
+ console.error("Failed to load patient profile:", err);
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Fetches patient dashboard data (past sessions, domains, etc.) and renders the dashboard screen.
22
+ *
23
+ * @param {string} pId - The patient ID to load dashboard for.
24
+ */
25
+ export async function showDashboard(pId) {
26
+ try {
27
+ const data = await api.loadDashboard(pId);
28
+
29
+ document.getElementById("dashboardPatientName").textContent = data.patient.name;
30
+ document.getElementById("dashboardPatientAge").textContent = data.patient.age ? `Age: ${data.patient.age}` : "Age: --";
31
+
32
+ const list = document.getElementById("dashboardSessionsList");
33
+ list.innerHTML = "";
34
+
35
+ const dashStartBtn = document.getElementById("dashboardStartBtn");
36
+ dashStartBtn.textContent = "NEW SESSION";
37
+
38
+ if (data.sessions && data.sessions.length > 0) {
39
+ data.sessions.forEach(sess => {
40
+ const d = new Date(sess.created_at).toLocaleString();
41
+ const summary = sess.rolling_summary || "No summary available.";
42
+ list.innerHTML += `
43
+ <details class="group p-4 border border-ink bg-paper shadow-sm mb-3">
44
+ <summary class="text-xs text-moss font-utility tracking-widest uppercase cursor-pointer select-none group-open:mb-2 flex justify-between">
45
+ ${d}
46
+ <span class="text-ink transition-transform group-open:rotate-180">▼</span>
47
+ </summary>
48
+ <div class="text-sm text-ink whitespace-pre-wrap mt-2 pl-2 border-l-2 border-clay">${summary}</div>
49
+ </details>
50
+ `;
51
+ });
52
+ } else {
53
+ list.innerHTML = `<p class="text-clay text-sm italic">No previous sessions found.</p>`;
54
+ }
55
+
56
+ const domainsDiv = document.getElementById("dashboardDomains");
57
+ if (domainsDiv && data.profile) {
58
+ domainsDiv.innerHTML = `<h3 class="font-utility uppercase text-clay text-sm border-b border-ink/30 pb-2">Clinical Profile</h3>`;
59
+ const renderDomain = (title, items) => {
60
+ if (!items || items.length === 0) return;
61
+ const html = `
62
+ <details class="group mt-4 border-b border-clay/30 pb-2 cursor-pointer">
63
+ <summary class="dashboard-label mb-1 select-none flex justify-between">
64
+ ${title}
65
+ <span class="text-ink text-xs transition-transform group-open:rotate-180">▼</span>
66
+ </summary>
67
+ <ul class="list-disc pl-5 text-sm dashboard-value space-y-1 mt-2 text-ink">
68
+ ${items.map(i => `<li>${i}</li>`).join('')}
69
+ </ul>
70
+ </details>
71
+ `;
72
+ domainsDiv.innerHTML += html;
73
+ };
74
+
75
+ renderDomain("Emotional Themes", data.profile.emotional_themes);
76
+ renderDomain("Thinking Patterns", data.profile.thinking_patterns);
77
+ renderDomain("Stressors", data.profile.stressors);
78
+ renderDomain("Protective Factors", data.profile.protective_factors);
79
+
80
+ if (data.profile.risk_assessment) {
81
+ domainsDiv.innerHTML += `
82
+ <details class="group mt-4 border-b border-clay/30 pb-2 cursor-pointer">
83
+ <summary class="dashboard-label mb-1 select-none flex justify-between">
84
+ Risk Assessment
85
+ <span class="text-ink text-xs transition-transform group-open:rotate-180">▼</span>
86
+ </summary>
87
+ <p class="text-sm dashboard-value mt-2 text-ink">${data.profile.risk_assessment}</p>
88
+ </details>
89
+ `;
90
+ }
91
+ }
92
+
93
+ ui.switchScreen("dashboardScreen");
94
+
95
+ } catch (err) {
96
+ console.error("Dashboard error", err);
97
+ alert("Could not load patient dashboard.");
98
+ }
99
+ }
static/js/session.js ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from './state.js';
2
+ import * as api from './api.js';
3
+ import * as ui from './ui.js';
4
+ import * as audio from './audio.js';
5
+ import * as profiles from './profiles.js';
6
+ import * as auth from './auth.js';
7
+
8
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
+
10
+ /**
11
+ * Processes the assistant's split message response.
12
+ * Displays and speaks the empathetic acknowledgment first,
13
+ * then pauses before delivering the follow-up clinical question.
14
+ *
15
+ * @param {string} message - The raw message containing the && separator.
16
+ */
17
+ export async function handleAssistantResponses(message) {
18
+ if (!message) return;
19
+
20
+ const parts = message.split("&&").map(p => p.trim()).filter(Boolean);
21
+
22
+ ui.addMessage(parts[0], "assistant");
23
+ await audio.speak(parts[0]);
24
+
25
+ if (parts[1]) {
26
+ ui.setStatusText("Assistant is thinking...");
27
+ await sleep(500);
28
+
29
+ ui.addTyping(false, "Thinking...");
30
+ await sleep(500);
31
+ ui.removeTyping();
32
+
33
+ ui.addMessage(parts[1], "assistant");
34
+ await audio.speak(parts[1]);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Ends the current active session, clears intervals, and returns the user to the dashboard.
40
+ */
41
+ export async function endSession() {
42
+ if (state.sessionInterval) {
43
+ clearInterval(state.sessionInterval);
44
+ state.sessionInterval = null;
45
+ }
46
+ state.setSessionId(null);
47
+ state.setPatientId(null);
48
+ ui.switchScreen("dashboard");
49
+ }
50
+
51
+ /**
52
+ * Sends a text message to the backend and handles the response.
53
+ *
54
+ * @param {string} message - The text message to send.
55
+ * @param {string|null} emotion - Optional emotion tag.
56
+ */
57
+ /**
58
+ * Submits a new text message to the active session and waits for the AI response.
59
+ * @param {string} text - The user's input message.
60
+ * @returns {Promise<boolean>} True if successful, false otherwise.
61
+ */
62
+ export async function recordMessage(text) {
63
+ return await sendTextMessage(text);
64
+ }
65
+
66
+ export async function sendTextMessage(message, emotion = null) {
67
+ ui.addMessage(message, "user");
68
+ ui.addTyping(false, "Analyzing...");
69
+
70
+ try {
71
+ const data = await api.sendChatText(state.sessionId, state.patientId, message, emotion);
72
+ ui.removeTyping();
73
+
74
+ if (data.session_id && data.session_id !== state.sessionId) {
75
+ state.setSessionId(data.session_id);
76
+ }
77
+
78
+ await handleAssistantResponses(data.assistant_message);
79
+
80
+ // Only reset status text if audio is NOT enabled,
81
+ // otherwise audio.js will handle resetting it when speech finishes.
82
+ if (!state.isAudioOutputEnabled) {
83
+ ui.setStatusText(state.isVoiceMode ? "Hold Space to speak" : "Type your message");
84
+ }
85
+
86
+ if (data.risk_flagged) {
87
+ ui.showSafetyBanner();
88
+ } else {
89
+ ui.hideSafetyBanner();
90
+ }
91
+ } catch (err) {
92
+ ui.removeTyping();
93
+ if (err.message && (err.message.includes("Tokens Exhausted") || err.message.includes("429"))) {
94
+ ui.showAlert("Tokens Exhausted", "Your LLM provider tokens have been exhausted. Please wait or upgrade your plan.");
95
+ } else {
96
+ ui.addMessage("I apologize, but I'm having trouble connecting right now.", "assistant");
97
+ }
98
+ console.error("Error:", err);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Starts a new session for the given patient ID.
104
+ * Transitions UI to main app and starts session timers.
105
+ *
106
+ * @param {string} pId - The patient ID to start the session for.
107
+ */
108
+ /**
109
+ * Starts a new chat session with the AI for the currently selected patient.
110
+ * Emits an initial context message to the backend.
111
+ */
112
+ export async function startActualSession(pId) {
113
+ try {
114
+ const data = await api.startSessionReq(pId);
115
+ state.setSessionId(data.session_id);
116
+ state.setPatientId(data.patient_id);
117
+
118
+ ui.switchScreen("mainApp");
119
+ document.getElementById("chat").innerHTML = "";
120
+
121
+ state.sessionStartTime = Date.now();
122
+ state.exchangeCount = 0;
123
+ ui.updateSessionStats();
124
+ if (state.sessionInterval) clearInterval(state.sessionInterval);
125
+ state.sessionInterval = setInterval(ui.updateSessionStats, 60000);
126
+
127
+ handleAssistantResponses(data.assistant_message);
128
+ } catch (e) {
129
+ console.error("Failed to start session:", e);
130
+ ui.showAlert("Session Failed", e.message || "Failed to start session. Please try again.");
131
+ ui.switchScreen("dashboardScreen");
132
+ const startButton = document.getElementById("dashboardStartBtn");
133
+ if (startButton) {
134
+ startButton.textContent = "START CONSULTATION";
135
+ startButton.disabled = false;
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Resumes an existing active session.
142
+ *
143
+ * @param {string} sessionId - The ID of the session to resume.
144
+ */
145
+ export async function continueActualSession(sessionId) {
146
+ try {
147
+ state.setSessionId(sessionId);
148
+ ui.switchScreen("mainApp");
149
+
150
+ document.getElementById("chat").innerHTML = "";
151
+
152
+ const data = await api.getSessionMessages(sessionId);
153
+ if (data.messages) {
154
+ data.messages.forEach(msg => {
155
+ if (msg.role !== "system") {
156
+ if (msg.role === 'assistant') {
157
+ const parts = msg.content.split("&&").map(p => p.trim()).filter(Boolean);
158
+ parts.forEach(part => ui.addMessage(part, 'assistant'));
159
+ } else {
160
+ ui.addMessage(msg.content, msg.role);
161
+ }
162
+ }
163
+ });
164
+ }
165
+
166
+ state.sessionStartTime = Date.now();
167
+ state.exchangeCount = Math.floor((data.messages || []).length / 2);
168
+ ui.updateSessionStats();
169
+ if (state.sessionInterval) clearInterval(state.sessionInterval);
170
+ state.sessionInterval = setInterval(ui.updateSessionStats, 60000);
171
+ } catch (e) {
172
+ console.error("Failed to continue session:", e);
173
+ alert("Failed to continue session. Please try again.");
174
+ location.reload();
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Pings the backend to check health and initializes routing based on URL path.
180
+ * Retries with exponential backoff if backend is not ready.
181
+ *
182
+ * @param {number} retryCount - Number of initialization retries so far.
183
+ */
184
+ export async function initializeSession(retryCount = 0) {
185
+ const startButton = document.getElementById("startButton");
186
+ const wakeupText = document.getElementById("wakeupText");
187
+
188
+ if (retryCount === 0) {
189
+ if (wakeupText) wakeupText.textContent = "Waking up backend (this may take up to a minute)...";
190
+ if (startButton) {
191
+ startButton.disabled = false;
192
+ startButton.textContent = "Start Consultation";
193
+ }
194
+ }
195
+ try {
196
+ await api.pingHealth();
197
+ document.getElementById("loadingDots").style.display = "none";
198
+ if (wakeupText) wakeupText.style.display = "none";
199
+
200
+ if (startButton) {
201
+ startButton.disabled = false;
202
+ startButton.textContent = "Start Consultation";
203
+ }
204
+
205
+ // Handle hash routing from OAuth or direct links
206
+ if (window.location.hash) {
207
+ const hash = window.location.hash.substring(1);
208
+ let newPath = "/";
209
+ if (hash === "dashboardScreen") newPath = "/dashboard";
210
+ else if (hash === "mainApp") newPath = "/session";
211
+
212
+ if (newPath !== "/") {
213
+ window.history.replaceState(null, "", newPath);
214
+ }
215
+ }
216
+
217
+ // Handle deep linking / routing on load
218
+ const path = window.location.pathname;
219
+ if (path === "/dashboard") {
220
+ // Always re-fetch on refresh — state.patientId is in-memory and lost on reload
221
+ await profiles.fetchPatients();
222
+ } else if (path === "/session") {
223
+ if (!state.sessionId) {
224
+ window.location.href = "/";
225
+ } else {
226
+ ui.switchScreen("mainApp", false);
227
+ }
228
+ } else {
229
+ // Allow user to click the start button
230
+ }
231
+ } catch (error) {
232
+ console.error(`Backend not ready (attempt ${retryCount + 1}):`, error);
233
+ if (retryCount < 15) {
234
+ const delay = Math.min(2000 * Math.pow(1.3, retryCount), 30000);
235
+ setTimeout(() => initializeSession(retryCount + 1), delay);
236
+ } else {
237
+ document.getElementById("loadingDots").style.display = "none";
238
+ if (startButton) {
239
+ startButton.disabled = false;
240
+ startButton.textContent = "Retry Connection";
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Renews the browser's TTS user-activation token synchronously.
248
+ * Must be called directly inside an onclick or keydown/keyup handler.
249
+ */
250
+ export function primeTTS() {
251
+ if (state.isAudioOutputEnabled && window.speechSynthesis) {
252
+ const primer = new SpeechSynthesisUtterance(' ');
253
+ primer.volume = 0;
254
+ primer.rate = 10;
255
+ window.speechSynthesis.speak(primer);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Toggle audio output on/off and update the button visuals.
261
+ *
262
+ * @param {boolean} enabled - Whether audio should be enabled.
263
+ */
264
+ export function setAudioOutput(enabled) {
265
+ state.isAudioOutputEnabled = enabled;
266
+
267
+ const mobileToggle = document.getElementById("audioOutputToggleMobile");
268
+ const track = document.getElementById("audioToggleTrack");
269
+ const dot = document.getElementById("audioToggleDot");
270
+ const iconMobile = document.getElementById("audioToggleIconMobile");
271
+
272
+ const desktopToggle = document.getElementById("audioOutputToggleDesktop");
273
+ const labelDesktop = document.getElementById("audioToggleLabelDesktop");
274
+
275
+ if (mobileToggle && mobileToggle.checked !== enabled) {
276
+ mobileToggle.checked = enabled;
277
+ }
278
+
279
+ if (enabled) {
280
+ // Prime the TTS engine with a silent utterance linked to this click gesture.
281
+ if (window.speechSynthesis) {
282
+ window.speechSynthesis.getVoices();
283
+ const primer = new SpeechSynthesisUtterance(' ');
284
+ primer.volume = 0;
285
+ primer.rate = 10;
286
+ window.speechSynthesis.speak(primer);
287
+ }
288
+
289
+ // Update mobile UI
290
+ if (track) { track.classList.remove("bg-ink/30"); track.classList.add("bg-moss"); }
291
+ if (dot) { dot.classList.add("translate-x-4"); }
292
+ if (iconMobile) { iconMobile.classList.remove("text-ink/50"); iconMobile.classList.add("text-moss"); }
293
+
294
+ // Update desktop UI
295
+ if (desktopToggle) {
296
+ desktopToggle.classList.add("bg-moss", "border-moss", "text-paper");
297
+ desktopToggle.classList.remove("text-ink", "border-ink");
298
+ }
299
+ if (labelDesktop) labelDesktop.textContent = "Audio On";
300
+
301
+ } else {
302
+ if (window.speechSynthesis && window.speechSynthesis.speaking) {
303
+ window.speechSynthesis.cancel();
304
+ }
305
+ state.isAISpeaking = false;
306
+
307
+ // Update mobile UI
308
+ if (track) { track.classList.remove("bg-moss"); track.classList.add("bg-ink/30"); }
309
+ if (dot) { dot.classList.remove("translate-x-4"); }
310
+ if (iconMobile) { iconMobile.classList.remove("text-moss"); iconMobile.classList.add("text-ink/50"); }
311
+
312
+ // Update desktop UI
313
+ if (desktopToggle) {
314
+ desktopToggle.classList.remove("bg-moss", "border-moss", "text-paper");
315
+ desktopToggle.classList.add("text-ink", "border-ink");
316
+ }
317
+ if (labelDesktop) labelDesktop.textContent = "Audio Off";
318
+ }
319
+ }
static/js/state.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const state = {
2
+ sessionId: sessionStorage.getItem("serinity_session_id") || null,
3
+ patientId: localStorage.getItem("serinity_patient_id") || null,
4
+ isListening: false,
5
+ isRecordingPending: false,
6
+ isVoiceMode: false,
7
+ isAudioOutputEnabled: false,
8
+ isAISpeaking: false,
9
+ exchangeCount: 0,
10
+ sessionStartTime: null,
11
+ sessionInterval: null,
12
+
13
+ setPatientId(id) {
14
+ this.patientId = id;
15
+ if (id) {
16
+ localStorage.setItem("serinity_patient_id", id);
17
+ } else {
18
+ localStorage.removeItem("serinity_patient_id");
19
+ }
20
+ },
21
+
22
+ setSessionId(id) {
23
+ this.sessionId = id;
24
+ if (id) {
25
+ sessionStorage.setItem("serinity_session_id", id);
26
+ } else {
27
+ sessionStorage.removeItem("serinity_session_id");
28
+ }
29
+ }
30
+ };
static/js/ui.js ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from './state.js';
2
+
3
+ // Cache DOM elements that exist on load
4
+ const uiElements = {
5
+ chat: document.getElementById("chat"),
6
+ chatWrapper: document.getElementById("chatWrapper"),
7
+ statusText: document.getElementById("status"),
8
+ micBtn: document.getElementById("micBtn"),
9
+ sessionStats: document.getElementById("sessionStats"),
10
+ voiceModeBtn: document.getElementById("voiceModeBtn"),
11
+ textModeBtn: document.getElementById("textModeBtn"),
12
+ voiceInput: document.getElementById("voiceInput"),
13
+ textInput: document.getElementById("textInput"),
14
+ chatInput: document.getElementById("chatInput")
15
+ };
16
+
17
+ let typingNode = null;
18
+
19
+ const screens = ["loadingScreen", "authScreen", "dashboardScreen", "mainApp"];
20
+
21
+ export function getPathForScreen(screen) {
22
+ const map = {
23
+ "loadingScreen": "/",
24
+ "authScreen": "/login",
25
+ "dashboardScreen": "/dashboard",
26
+ "mainApp": "/session"
27
+ };
28
+ return map[screen] || "/";
29
+ }
30
+
31
+ export function switchScreen(targetScreen, pushToHistory = true) {
32
+ window.currentScreen = targetScreen;
33
+ screens.forEach(s => {
34
+ const el = document.getElementById(s);
35
+ if (el) {
36
+ if (s === targetScreen) {
37
+ if (s === 'mainApp' || s === 'dashboardScreen') el.classList.remove("hidden");
38
+ el.style.display = "flex";
39
+ } else {
40
+ el.style.display = "none";
41
+ }
42
+ }
43
+ });
44
+
45
+ if (pushToHistory) {
46
+ history.pushState({ screen: targetScreen }, "", getPathForScreen(targetScreen));
47
+ }
48
+ }
49
+
50
+ export function scrollToBottom() {
51
+ setTimeout(() => {
52
+ if (uiElements.chatWrapper) {
53
+ uiElements.chatWrapper.scrollTop = uiElements.chatWrapper.scrollHeight;
54
+ }
55
+ }, 100);
56
+ }
57
+
58
+ export function updateSessionStats() {
59
+ if (!state.sessionStartTime) return;
60
+ if (!uiElements.sessionStats) return;
61
+ const elapsed = Math.floor((Date.now() - state.sessionStartTime) / 60000);
62
+ uiElements.sessionStats.textContent = `Session · ${elapsed} min · ${state.exchangeCount} exchanges`;
63
+ }
64
+
65
+ export function addMessage(text, role) {
66
+ const messageDiv = document.createElement("div");
67
+ messageDiv.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'} fade-in w-full mb-6`;
68
+
69
+ const contentDiv = document.createElement("div");
70
+
71
+ if (role === 'assistant') {
72
+ const label = document.createElement("span");
73
+ label.className = "assistant-label";
74
+ label.textContent = "ASSISTANT ·";
75
+ contentDiv.appendChild(label);
76
+ contentDiv.className = "pl-4 border-l-2 border-moss max-w-[85%] sm:max-w-[75%]";
77
+ } else {
78
+ const label = document.createElement("span");
79
+ label.className = "assistant-label text-right block";
80
+ const dashName = document.getElementById("dashboardPatientName");
81
+ const userName = dashName ? dashName.innerText : "USER";
82
+ label.textContent = "· " + userName.toUpperCase();
83
+ contentDiv.appendChild(label);
84
+ contentDiv.className = "pr-4 border-r-2 border-clay text-right max-w-[85%] sm:max-w-[75%]";
85
+ }
86
+
87
+ const textNode = document.createElement("p");
88
+ textNode.className = "text-ink whitespace-pre-wrap";
89
+ textNode.textContent = text;
90
+ contentDiv.appendChild(textNode);
91
+
92
+ messageDiv.appendChild(contentDiv);
93
+ if (uiElements.chat) {
94
+ uiElements.chat.appendChild(messageDiv);
95
+ scrollToBottom();
96
+ }
97
+
98
+ if (role === 'user') {
99
+ state.exchangeCount++;
100
+ updateSessionStats();
101
+ }
102
+ }
103
+
104
+ export function addTyping(isUser = false, customText = null) {
105
+ if (typingNode) return;
106
+
107
+ const messageDiv = document.createElement("div");
108
+ messageDiv.className = `flex ${isUser ? 'justify-end' : 'justify-start'} fade-in w-full mb-6`;
109
+
110
+ const contentDiv = document.createElement("div");
111
+
112
+ if (!isUser) {
113
+ const label = document.createElement("span");
114
+ label.className = "assistant-label";
115
+ label.textContent = "ASSISTANT ·";
116
+ contentDiv.appendChild(label);
117
+ contentDiv.className = "pl-4 border-l-2 border-moss max-w-[85%] sm:max-w-[75%]";
118
+ } else {
119
+ const label = document.createElement("span");
120
+ label.className = "assistant-label text-right block";
121
+ const dashName = document.getElementById("dashboardPatientName");
122
+ const userName = dashName ? dashName.innerText : "USER";
123
+ label.textContent = "· " + userName.toUpperCase();
124
+ contentDiv.appendChild(label);
125
+ contentDiv.className = "pr-4 border-r-2 border-clay text-right max-w-[85%] sm:max-w-[75%]";
126
+ }
127
+
128
+ if (customText) {
129
+ const textNode = document.createElement("p");
130
+ textNode.className = "text-moss text-sm italic font-utility animate-pulse";
131
+ textNode.textContent = customText;
132
+ contentDiv.appendChild(textNode);
133
+ } else {
134
+ const indicator = document.createElement("div");
135
+ indicator.className = "typing-indicator flex gap-1 mt-2" + (isUser ? " justify-end" : "");
136
+ indicator.innerHTML = `<span></span><span></span><span></span>`;
137
+ contentDiv.appendChild(indicator);
138
+ }
139
+
140
+ messageDiv.appendChild(contentDiv);
141
+
142
+ typingNode = messageDiv;
143
+ if (uiElements.chat) {
144
+ uiElements.chat.appendChild(messageDiv);
145
+ scrollToBottom();
146
+ }
147
+ }
148
+
149
+ export function removeTyping() {
150
+ if (!typingNode) return;
151
+ typingNode.remove();
152
+ typingNode = null;
153
+ }
154
+
155
+ export function setStatusText(text) {
156
+ if (uiElements.statusText) {
157
+ uiElements.statusText.textContent = text;
158
+ }
159
+ }
160
+
161
+ export function toggleMicButtonVisuals(isRecording) {
162
+ if (!uiElements.micBtn) return;
163
+ if (isRecording) {
164
+ uiElements.micBtn.classList.add("bg-moss", "border-moss", "text-paper");
165
+ } else {
166
+ uiElements.micBtn.classList.remove("bg-moss", "border-moss", "text-paper");
167
+ }
168
+ }
169
+
170
+ export function showSafetyBanner() {
171
+ if (!document.getElementById("safetyBanner")) {
172
+ const banner = document.createElement("div");
173
+ banner.id = "safetyBanner";
174
+ banner.className = "bg-signal text-paper p-4 shadow-md z-50 fixed top-0 left-0 right-0 fade-in";
175
+ banner.innerHTML = `
176
+ <div class="max-w-5xl mx-auto flex w-full justify-between items-start">
177
+ <div>
178
+ <h3 class="font-utility uppercase tracking-widest text-sm mb-1 font-bold">Emergency Support</h3>
179
+ <p class="text-sm">You are not alone. Free, confidential support is available right now.</p>
180
+ <ul class="list-none text-sm font-utility space-y-1 mt-2">
181
+ <li>iCall (TISS): 9152987821</li>
182
+ <li>Kiran: 1800-599-0019</li>
183
+ <li>Vandrevala: 1860-2662-345</li>
184
+ </ul>
185
+ </div>
186
+ <button onclick="document.getElementById('safetyBanner').remove()" class="text-paper hover:opacity-70 text-2xl font-bold p-2 cursor-pointer leading-none">&times;</button>
187
+ </div>
188
+ `;
189
+ document.body.appendChild(banner);
190
+ }
191
+ }
192
+
193
+ export function hideSafetyBanner() {
194
+ const banner = document.getElementById("safetyBanner");
195
+ if (banner) {
196
+ banner.remove();
197
+ }
198
+ }
199
+
200
+ export function showConfirm(title, text, callback) {
201
+ const confirmationModal = document.getElementById("confirmationModal");
202
+ const confirmModalTitle = document.getElementById("confirmModalTitle");
203
+ const confirmModalText = document.getElementById("confirmModalText");
204
+ const confirmModalCancelBtn = document.getElementById("confirmModalCancelBtn");
205
+ const confirmModalConfirmBtn = document.getElementById("confirmModalConfirmBtn");
206
+
207
+ confirmModalTitle.textContent = title;
208
+ confirmModalText.textContent = text;
209
+
210
+ confirmModalCancelBtn.onclick = () => {
211
+ confirmationModal.style.display = "none";
212
+ };
213
+
214
+ confirmModalConfirmBtn.onclick = () => {
215
+ confirmationModal.style.display = "none";
216
+ if (callback) callback();
217
+ };
218
+
219
+ confirmationModal.style.display = "flex";
220
+ }
221
+
222
+ export function showAlert(title, text) {
223
+ const alertModal = document.getElementById("alertModal");
224
+ if (!alertModal) return alert(text); // Fallback
225
+
226
+ document.getElementById("alertModalTitle").textContent = title;
227
+ document.getElementById("alertModalText").textContent = text;
228
+
229
+ const closeModal = () => {
230
+ alertModal.style.display = "none";
231
+ };
232
+
233
+ document.getElementById("alertModalOkBtn").onclick = closeModal;
234
+
235
+ alertModal.onclick = (e) => {
236
+ if (e.target === alertModal) {
237
+ closeModal();
238
+ }
239
+ };
240
+
241
+ alertModal.style.display = "flex";
242
+ }