Upload 19 files
Browse files- Requirements.txt +32 -0
- api/__pycache__/dependencies.cpython-311.pyc +0 -0
- api/__pycache__/dependencies.cpython-313.pyc +0 -0
- api/__pycache__/routes.cpython-311.pyc +0 -0
- api/__pycache__/routes.cpython-313.pyc +0 -0
- api/dependencies.py +180 -0
- api/routers/__pycache__/auth.cpython-311.pyc +0 -0
- api/routers/auth.py +111 -0
- api/routes.py +234 -0
- static/css/style.css +277 -0
- static/icons/logo.svg +26 -0
- static/js/api.js +157 -0
- static/js/app.js +588 -0
- static/js/audio.js +218 -0
- static/js/auth.js +77 -0
- static/js/profiles.js +99 -0
- static/js/session.js +319 -0
- static/js/state.js +30 -0
- static/js/ui.js +242 -0
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">×</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 |
+
}
|