Delete static
Browse files- static/css/style.css +0 -277
- static/icons/logo.svg +0 -26
- static/js/api.js +0 -147
- static/js/app.js +0 -717
- static/js/audio.js +0 -218
- static/js/auth.js +0 -135
- static/js/profiles.js +0 -99
- static/js/session.js +0 -314
- static/js/state.js +0 -30
- static/js/ui.js +0 -242
static/css/style.css
DELETED
|
@@ -1,277 +0,0 @@
|
|
| 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
DELETED
static/js/api.js
DELETED
|
@@ -1,147 +0,0 @@
|
|
| 1 |
-
export const BACKEND_URL = "https://amogh1221-serinity.hf.space";
|
| 2 |
-
|
| 3 |
-
// Helper to get token
|
| 4 |
-
export function getToken() {
|
| 5 |
-
return localStorage.getItem('serinity_token');
|
| 6 |
-
}
|
| 7 |
-
|
| 8 |
-
// Global fetch wrapper for auth
|
| 9 |
-
export async function authFetch(url, options = {}) {
|
| 10 |
-
const token = getToken();
|
| 11 |
-
const headers = { ...options.headers };
|
| 12 |
-
|
| 13 |
-
if (token) {
|
| 14 |
-
headers['Authorization'] = `Bearer ${token}`;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
// Don't override FormData headers
|
| 18 |
-
if (!(options.body instanceof FormData) && !headers['Content-Type']) {
|
| 19 |
-
headers['Content-Type'] = 'application/json';
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
const res = await fetch(url, { ...options, headers });
|
| 23 |
-
|
| 24 |
-
// Handle unauthorized globaly
|
| 25 |
-
if (res.status === 401) {
|
| 26 |
-
localStorage.removeItem('serinity_token');
|
| 27 |
-
window.dispatchEvent(new Event('unauthorized'));
|
| 28 |
-
throw new Error('Unauthorized');
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
return res;
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
/**
|
| 35 |
-
* Fetches the list of patients associated with the logged-in user.
|
| 36 |
-
* @returns {Promise<Array>} List of patient objects.
|
| 37 |
-
*/
|
| 38 |
-
export async function fetchPatientsList() {
|
| 39 |
-
const res = await authFetch(`${BACKEND_URL}/patients`, { cache: "no-store" });
|
| 40 |
-
return res.json();
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
/**
|
| 44 |
-
* Loads the dashboard data for a specific patient.
|
| 45 |
-
* @param {string} patientId
|
| 46 |
-
* @returns {Promise<Object>} Dashboard overview data.
|
| 47 |
-
*/
|
| 48 |
-
export async function loadDashboard(patientId) {
|
| 49 |
-
const res = await authFetch(`${BACKEND_URL}/patients/${patientId}/dashboard`, { cache: "no-store" });
|
| 50 |
-
if (!res.ok) throw new Error("Dashboard load failed");
|
| 51 |
-
return res.json();
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
/**
|
| 55 |
-
* Resets a patient's historical profile and sessions.
|
| 56 |
-
* @param {string} patientId
|
| 57 |
-
*/
|
| 58 |
-
export async function resetPatientProfile(patientId) {
|
| 59 |
-
return authFetch(`${BACKEND_URL}/patients/${patientId}/reset`, { method: "POST" });
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
/**
|
| 63 |
-
* Deletes a patient profile completely.
|
| 64 |
-
* @param {string} patientId
|
| 65 |
-
*/
|
| 66 |
-
export async function deletePatientProfile(patientId) {
|
| 67 |
-
return authFetch(`${BACKEND_URL}/patients/${patientId}`, { method: "DELETE" });
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
/**
|
| 71 |
-
* Creates a new patient profile linked to the user.
|
| 72 |
-
* @param {Object} data
|
| 73 |
-
* @returns {Promise<Object>} The created patient data.
|
| 74 |
-
*/
|
| 75 |
-
export async function createPatientProfile(data) {
|
| 76 |
-
const res = await authFetch(`${BACKEND_URL}/patients/create`, {
|
| 77 |
-
method: "POST",
|
| 78 |
-
body: JSON.stringify(data)
|
| 79 |
-
});
|
| 80 |
-
return res.json();
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
export async function pingHealth() {
|
| 84 |
-
// health doesn't need auth, but authFetch is safe to use
|
| 85 |
-
return fetch(`${BACKEND_URL}/health`);
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
export async function startSessionReq(patientId) {
|
| 89 |
-
const res = await authFetch(`${BACKEND_URL}/start`, {
|
| 90 |
-
method: "POST",
|
| 91 |
-
body: JSON.stringify({ patient_id: patientId })
|
| 92 |
-
});
|
| 93 |
-
return res.json();
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
export async function sendChatText(sessionId, patientId, message, emotion) {
|
| 97 |
-
const res = await authFetch(`${BACKEND_URL}/chat_text`, {
|
| 98 |
-
method: "POST",
|
| 99 |
-
body: JSON.stringify({
|
| 100 |
-
message: message,
|
| 101 |
-
session_id: sessionId,
|
| 102 |
-
patient_id: patientId,
|
| 103 |
-
emotion: emotion
|
| 104 |
-
})
|
| 105 |
-
});
|
| 106 |
-
if (!res.ok) {
|
| 107 |
-
let errorDetail = `HTTP Error ${res.status}`;
|
| 108 |
-
try {
|
| 109 |
-
const data = await res.json();
|
| 110 |
-
errorDetail = data.detail || errorDetail;
|
| 111 |
-
} catch(e) {}
|
| 112 |
-
throw new Error(errorDetail);
|
| 113 |
-
}
|
| 114 |
-
return res.json();
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
export async function transcribeAudio(audioBlob) {
|
| 118 |
-
const formData = new FormData();
|
| 119 |
-
formData.append('audio', audioBlob, 'recording.webm');
|
| 120 |
-
|
| 121 |
-
// Custom headers because authFetch handles FormData correctly if Content-Type is omitted
|
| 122 |
-
const res = await authFetch(`${BACKEND_URL}/transcribe`, {
|
| 123 |
-
method: "POST",
|
| 124 |
-
body: formData
|
| 125 |
-
});
|
| 126 |
-
return res.json();
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
export async function endSessionReq(sessionId, patientId) {
|
| 130 |
-
return authFetch(`${BACKEND_URL}/end_session`, {
|
| 131 |
-
method: "POST",
|
| 132 |
-
body: JSON.stringify({
|
| 133 |
-
session_id: sessionId,
|
| 134 |
-
patient_id: patientId
|
| 135 |
-
})
|
| 136 |
-
});
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
export async function getActiveSession(patientId) {
|
| 140 |
-
const res = await authFetch(`${BACKEND_URL}/patients/${patientId}/active_session`, { cache: "no-store" });
|
| 141 |
-
return res.json();
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
export async function getSessionMessages(sessionId) {
|
| 145 |
-
const res = await authFetch(`${BACKEND_URL}/sessions/${sessionId}/messages`, { cache: "no-store" });
|
| 146 |
-
return res.json();
|
| 147 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/js/app.js
DELETED
|
@@ -1,717 +0,0 @@
|
|
| 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 |
-
let _signupPayload = null;
|
| 187 |
-
|
| 188 |
-
document.getElementById("signupGetOtpBtn")?.addEventListener("click", async () => {
|
| 189 |
-
const payload = {
|
| 190 |
-
name: document.getElementById("signupName").value.trim(),
|
| 191 |
-
username: document.getElementById("signupUsername").value.trim(),
|
| 192 |
-
email: document.getElementById("signupEmail").value.trim(),
|
| 193 |
-
password: document.getElementById("signupPassword").value,
|
| 194 |
-
gender: document.getElementById("signupGender").value,
|
| 195 |
-
age: parseInt(document.getElementById("signupAge").value) || null,
|
| 196 |
-
nationality: document.getElementById("signupNationality").value,
|
| 197 |
-
primary_concern: document.getElementById("signupConcern").value,
|
| 198 |
-
emergency_contact_name: document.getElementById("signupEmergencyName").value.trim() || null,
|
| 199 |
-
emergency_contact_phone: document.getElementById("signupEmergencyPhone").value.trim() || null
|
| 200 |
-
};
|
| 201 |
-
|
| 202 |
-
if (!payload.name || !payload.username || !payload.email || !payload.password || !payload.gender || !payload.nationality || !payload.primary_concern) {
|
| 203 |
-
return ui.showAlert("Signup Error", "Please fill out all required fields (*).");
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
if (payload.password.length < 8) {
|
| 207 |
-
return ui.showAlert("Signup Error", "Password must be at least 8 characters long.");
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
if (payload.age !== null && (payload.age < 5 || payload.age > 99)) {
|
| 211 |
-
return ui.showAlert("Signup Error", "Age must be between 5 and 99.");
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
try {
|
| 215 |
-
document.getElementById("signupGetOtpBtn").textContent = "Sending OTP...";
|
| 216 |
-
document.getElementById("signupGetOtpBtn").disabled = true;
|
| 217 |
-
|
| 218 |
-
await auth.requestSignupOtp(payload.email, payload.username);
|
| 219 |
-
|
| 220 |
-
_signupPayload = payload;
|
| 221 |
-
|
| 222 |
-
// Clear OTP code and switch to signup OTP panel
|
| 223 |
-
const signupOtpCodeField = document.getElementById("signupOtpCode");
|
| 224 |
-
if (signupOtpCodeField) {
|
| 225 |
-
signupOtpCodeField.value = "";
|
| 226 |
-
}
|
| 227 |
-
showAuthPanel("signupOtpStepContainer");
|
| 228 |
-
|
| 229 |
-
} catch (e) {
|
| 230 |
-
ui.showAlert("Signup Failed", e.message);
|
| 231 |
-
} finally {
|
| 232 |
-
document.getElementById("signupGetOtpBtn").textContent = "Get OTP";
|
| 233 |
-
document.getElementById("signupGetOtpBtn").disabled = false;
|
| 234 |
-
}
|
| 235 |
-
});
|
| 236 |
-
|
| 237 |
-
document.getElementById("signupCompleteBtn")?.addEventListener("click", async () => {
|
| 238 |
-
if (!_signupPayload) {
|
| 239 |
-
return ui.showAlert("Signup Error", "Missing signup details. Please refresh and try again.");
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
const signupOtpCodeField = document.getElementById("signupOtpCode");
|
| 243 |
-
if (!signupOtpCodeField) {
|
| 244 |
-
return ui.showAlert("Signup Error", "OTP code field not found. Please refresh and try again.");
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
const otpCode = signupOtpCodeField.value.trim();
|
| 248 |
-
if (otpCode.length !== 6) {
|
| 249 |
-
return ui.showAlert("Signup Error", "Please enter the 6-digit OTP code.");
|
| 250 |
-
}
|
| 251 |
-
|
| 252 |
-
try {
|
| 253 |
-
const completeBtn = document.getElementById("signupCompleteBtn");
|
| 254 |
-
if (completeBtn) {
|
| 255 |
-
completeBtn.textContent = "Creating Account...";
|
| 256 |
-
completeBtn.disabled = true;
|
| 257 |
-
}
|
| 258 |
-
|
| 259 |
-
const payload = {
|
| 260 |
-
..._signupPayload,
|
| 261 |
-
otp_code: otpCode
|
| 262 |
-
};
|
| 263 |
-
|
| 264 |
-
await auth.signup(payload);
|
| 265 |
-
|
| 266 |
-
_signupPayload = null;
|
| 267 |
-
if (signupOtpCodeField) {
|
| 268 |
-
signupOtpCodeField.value = "";
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
-
// Account created successfully, go back to login
|
| 272 |
-
ui.showAlert("Success", "Account created successfully!");
|
| 273 |
-
showAuthPanel("loginFormContainer");
|
| 274 |
-
|
| 275 |
-
await profiles.fetchPatients();
|
| 276 |
-
} catch (e) {
|
| 277 |
-
ui.showAlert("Signup Failed", e.message);
|
| 278 |
-
} finally {
|
| 279 |
-
const completeBtn = document.getElementById("signupCompleteBtn");
|
| 280 |
-
if (completeBtn) {
|
| 281 |
-
completeBtn.textContent = "Complete Signup";
|
| 282 |
-
completeBtn.disabled = false;
|
| 283 |
-
}
|
| 284 |
-
}
|
| 285 |
-
});
|
| 286 |
-
|
| 287 |
-
document.getElementById("requestOtpBtn")?.addEventListener("click", async () => {
|
| 288 |
-
const email = document.getElementById("forgotPwdEmail").value.trim();
|
| 289 |
-
if (!email) return ui.showAlert("Reset Password", "Please enter your email.");
|
| 290 |
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
| 291 |
-
if (!emailRegex.test(email)) return ui.showAlert("Reset Password", "Please enter a valid email address.");
|
| 292 |
-
try {
|
| 293 |
-
document.getElementById("requestOtpBtn").textContent = "Sending...";
|
| 294 |
-
document.getElementById("requestOtpBtn").disabled = true;
|
| 295 |
-
await auth.forgotPassword(email);
|
| 296 |
-
document.getElementById("resetOtpCode").value = "";
|
| 297 |
-
showAuthPanel("otpStepContainer");
|
| 298 |
-
} catch(e) {
|
| 299 |
-
ui.showAlert("Reset Password", e.message);
|
| 300 |
-
} finally {
|
| 301 |
-
document.getElementById("requestOtpBtn").textContent = "Send Code";
|
| 302 |
-
document.getElementById("requestOtpBtn").disabled = false;
|
| 303 |
-
}
|
| 304 |
-
});
|
| 305 |
-
|
| 306 |
-
document.getElementById("verifyOtpBtn")?.addEventListener("click", async () => {
|
| 307 |
-
const email = document.getElementById("forgotPwdEmail").value.trim();
|
| 308 |
-
const otp = document.getElementById("resetOtpCode").value.trim();
|
| 309 |
-
if (otp.length !== 6) return ui.showAlert("Reset Password", "Please enter the 6-digit code.");
|
| 310 |
-
try {
|
| 311 |
-
document.getElementById("verifyOtpBtn").textContent = "Verifying...";
|
| 312 |
-
document.getElementById("verifyOtpBtn").disabled = true;
|
| 313 |
-
await auth.verifyOtp(email, otp);
|
| 314 |
-
_otpVerifiedEmail = email;
|
| 315 |
-
document.getElementById("resetNewPassword").value = "";
|
| 316 |
-
showAuthPanel("newPwdStepContainer");
|
| 317 |
-
} catch(e) {
|
| 318 |
-
ui.showAlert("Verification Failed", e.message);
|
| 319 |
-
} finally {
|
| 320 |
-
document.getElementById("verifyOtpBtn").textContent = "Verify Code";
|
| 321 |
-
document.getElementById("verifyOtpBtn").disabled = false;
|
| 322 |
-
}
|
| 323 |
-
});
|
| 324 |
-
|
| 325 |
-
document.getElementById("submitResetBtn")?.addEventListener("click", async () => {
|
| 326 |
-
// If _otpVerifiedEmail is null, this means a refresh happened — redirect to login
|
| 327 |
-
if (!_otpVerifiedEmail) {
|
| 328 |
-
showAuthPanel("loginFormContainer");
|
| 329 |
-
return;
|
| 330 |
-
}
|
| 331 |
-
const newPass = document.getElementById("resetNewPassword").value;
|
| 332 |
-
if (!newPass || newPass.length < 8) return ui.showAlert("Reset Password", "New password must be at least 8 characters long.");
|
| 333 |
-
try {
|
| 334 |
-
document.getElementById("submitResetBtn").textContent = "Updating...";
|
| 335 |
-
document.getElementById("submitResetBtn").disabled = true;
|
| 336 |
-
const otp = document.getElementById("resetOtpCode").value.trim();
|
| 337 |
-
await auth.resetPassword(_otpVerifiedEmail, otp, newPass);
|
| 338 |
-
_otpVerifiedEmail = null;
|
| 339 |
-
ui.showAlert("Success", "Password reset successfully! Please log in.");
|
| 340 |
-
showAuthPanel("loginFormContainer");
|
| 341 |
-
} catch (e) {
|
| 342 |
-
ui.showAlert("Reset Failed", e.message);
|
| 343 |
-
} finally {
|
| 344 |
-
document.getElementById("submitResetBtn").textContent = "Update Password";
|
| 345 |
-
document.getElementById("submitResetBtn").disabled = false;
|
| 346 |
-
}
|
| 347 |
-
});
|
| 348 |
-
|
| 349 |
-
// --- Profiles & Dashboard Events ---
|
| 350 |
-
|
| 351 |
-
const patientDropdown = document.getElementById("patientDropdown");
|
| 352 |
-
if (patientDropdown) patientDropdown.onchange = profiles.toggleSelectButton;
|
| 353 |
-
|
| 354 |
-
const dashboardLogoutBtn = document.getElementById("dashboardLogoutBtn");
|
| 355 |
-
if (dashboardLogoutBtn) {
|
| 356 |
-
dashboardLogoutBtn.onclick = () => {
|
| 357 |
-
auth.logout();
|
| 358 |
-
ui.switchScreen("authScreen");
|
| 359 |
-
};
|
| 360 |
-
}
|
| 361 |
-
|
| 362 |
-
const dashboardResetBtn = document.getElementById("dashboardResetBtn");
|
| 363 |
-
if (dashboardResetBtn) {
|
| 364 |
-
dashboardResetBtn.onclick = () => {
|
| 365 |
-
ui.showConfirm(
|
| 366 |
-
"Reset Profile?",
|
| 367 |
-
"Are you sure you want to reset this profile? All sessions, messages, and analysis will be permanently deleted. The patient record will remain.",
|
| 368 |
-
async () => {
|
| 369 |
-
try {
|
| 370 |
-
await api.resetPatientProfile(state.patientId);
|
| 371 |
-
} catch (e) {
|
| 372 |
-
console.error("Error resetting profile:", e);
|
| 373 |
-
}
|
| 374 |
-
document.getElementById("dashboardSessionsList").innerHTML = `<p class="text-clay text-sm italic">No previous sessions found.</p>`;
|
| 375 |
-
document.getElementById("dashboardDomains").innerHTML = `
|
| 376 |
-
<h3 class="font-utility uppercase text-clay text-sm border-b border-ink/30 pb-2">Clinical Profile</h3>
|
| 377 |
-
<p class="text-clay text-sm italic mt-4">Profile has been reset.</p>
|
| 378 |
-
`;
|
| 379 |
-
}
|
| 380 |
-
);
|
| 381 |
-
};
|
| 382 |
-
}
|
| 383 |
-
|
| 384 |
-
const dashboardDeleteBtn = document.getElementById("dashboardDeleteBtn");
|
| 385 |
-
if (dashboardDeleteBtn) {
|
| 386 |
-
dashboardDeleteBtn.onclick = () => {
|
| 387 |
-
document.getElementById("deleteAccountConfirmModal").style.display = "flex";
|
| 388 |
-
};
|
| 389 |
-
}
|
| 390 |
-
|
| 391 |
-
const deleteAccountCancelBtn = document.getElementById("deleteAccountCancelBtn");
|
| 392 |
-
if (deleteAccountCancelBtn) {
|
| 393 |
-
deleteAccountCancelBtn.onclick = () => {
|
| 394 |
-
document.getElementById("deleteAccountConfirmModal").style.display = "none";
|
| 395 |
-
};
|
| 396 |
-
}
|
| 397 |
-
|
| 398 |
-
const deleteAccountSendOtpBtn = document.getElementById("deleteAccountSendOtpBtn");
|
| 399 |
-
if (deleteAccountSendOtpBtn) {
|
| 400 |
-
deleteAccountSendOtpBtn.onclick = async () => {
|
| 401 |
-
try {
|
| 402 |
-
deleteAccountSendOtpBtn.textContent = "Sending OTP...";
|
| 403 |
-
deleteAccountSendOtpBtn.disabled = true;
|
| 404 |
-
|
| 405 |
-
await auth.requestDeleteAccountOtp();
|
| 406 |
-
|
| 407 |
-
// Close confirmation modal and show OTP modal
|
| 408 |
-
document.getElementById("deleteAccountConfirmModal").style.display = "none";
|
| 409 |
-
document.getElementById("deleteAccountOtpCode").value = "";
|
| 410 |
-
document.getElementById("deleteAccountOtpModal").style.display = "flex";
|
| 411 |
-
|
| 412 |
-
} catch (e) {
|
| 413 |
-
console.error("Error requesting OTP:", e);
|
| 414 |
-
ui.showAlert("Error", e.message);
|
| 415 |
-
} finally {
|
| 416 |
-
deleteAccountSendOtpBtn.textContent = "SEND OTP";
|
| 417 |
-
deleteAccountSendOtpBtn.disabled = false;
|
| 418 |
-
}
|
| 419 |
-
};
|
| 420 |
-
}
|
| 421 |
-
|
| 422 |
-
const closeDeleteOtpModalBtn = document.getElementById("closeDeleteOtpModalBtn");
|
| 423 |
-
if (closeDeleteOtpModalBtn) {
|
| 424 |
-
closeDeleteOtpModalBtn.onclick = () => {
|
| 425 |
-
document.getElementById("deleteAccountOtpModal").style.display = "none";
|
| 426 |
-
};
|
| 427 |
-
}
|
| 428 |
-
|
| 429 |
-
const submitDeleteOtpBtn = document.getElementById("submitDeleteOtpBtn");
|
| 430 |
-
if (submitDeleteOtpBtn) {
|
| 431 |
-
submitDeleteOtpBtn.onclick = async () => {
|
| 432 |
-
const code = document.getElementById("deleteAccountOtpCode").value.trim();
|
| 433 |
-
if (code.length !== 6) return ui.showAlert("Error", "Please enter a valid 6-digit code.");
|
| 434 |
-
try {
|
| 435 |
-
submitDeleteOtpBtn.textContent = "Verifying...";
|
| 436 |
-
submitDeleteOtpBtn.disabled = true;
|
| 437 |
-
|
| 438 |
-
await auth.verifyDeleteAccount(code);
|
| 439 |
-
|
| 440 |
-
document.getElementById("deleteAccountOtpModal").style.display = "none";
|
| 441 |
-
auth.logout();
|
| 442 |
-
ui.switchScreen("authScreen");
|
| 443 |
-
|
| 444 |
-
} catch (e) {
|
| 445 |
-
ui.showAlert("Error", e.message);
|
| 446 |
-
} finally {
|
| 447 |
-
submitDeleteOtpBtn.textContent = "Verify & Delete";
|
| 448 |
-
submitDeleteOtpBtn.disabled = false;
|
| 449 |
-
}
|
| 450 |
-
};
|
| 451 |
-
}
|
| 452 |
-
const dashboardStartBtn = document.getElementById("dashboardStartBtn");
|
| 453 |
-
if (dashboardStartBtn) {
|
| 454 |
-
dashboardStartBtn.onclick = async () => {
|
| 455 |
-
ui.switchScreen("loadingScreen", false);
|
| 456 |
-
startButton.textContent = "Checking Session...";
|
| 457 |
-
startButton.disabled = true;
|
| 458 |
-
|
| 459 |
-
try {
|
| 460 |
-
const active = await api.getActiveSession(state.patientId);
|
| 461 |
-
if (active && active.session_id) {
|
| 462 |
-
ui.switchScreen("dashboardScreen");
|
| 463 |
-
const modal = document.getElementById("continueSessionModal");
|
| 464 |
-
modal.style.display = "flex";
|
| 465 |
-
|
| 466 |
-
document.getElementById("continueSessionBtn").onclick = async () => {
|
| 467 |
-
modal.style.display = "none";
|
| 468 |
-
ui.switchScreen("loadingScreen", false);
|
| 469 |
-
startButton.textContent = "Resuming Session...";
|
| 470 |
-
await session.continueActualSession(active.session_id);
|
| 471 |
-
};
|
| 472 |
-
|
| 473 |
-
document.getElementById("endAndStartNewBtn").onclick = async () => {
|
| 474 |
-
modal.style.display = "none";
|
| 475 |
-
ui.switchScreen("loadingScreen", false);
|
| 476 |
-
startButton.textContent = "Loading Session...";
|
| 477 |
-
state.setSessionId(null);
|
| 478 |
-
await api.endSessionReq(active.session_id, state.patientId);
|
| 479 |
-
await session.startActualSession(state.patientId);
|
| 480 |
-
};
|
| 481 |
-
} else {
|
| 482 |
-
startButton.textContent = "Loading Session...";
|
| 483 |
-
await session.startActualSession(state.patientId);
|
| 484 |
-
}
|
| 485 |
-
} catch (e) {
|
| 486 |
-
console.error(e);
|
| 487 |
-
startButton.textContent = "Loading Session...";
|
| 488 |
-
await session.startActualSession(state.patientId);
|
| 489 |
-
}
|
| 490 |
-
};
|
| 491 |
-
}
|
| 492 |
-
|
| 493 |
-
const selectProfileBtn = document.getElementById("selectProfileBtn");
|
| 494 |
-
if (selectProfileBtn) {
|
| 495 |
-
selectProfileBtn.onclick = async () => {
|
| 496 |
-
state.setPatientId(patientDropdown.value);
|
| 497 |
-
await profiles.showDashboard(state.patientId);
|
| 498 |
-
};
|
| 499 |
-
}
|
| 500 |
-
|
| 501 |
-
const createProfileBtn = document.getElementById("createProfileBtn");
|
| 502 |
-
if (createProfileBtn) {
|
| 503 |
-
createProfileBtn.onclick = async () => {
|
| 504 |
-
const nameInput = document.getElementById("newPatientName");
|
| 505 |
-
const ageInput = document.getElementById("newPatientAge");
|
| 506 |
-
const genderInput = document.getElementById("newPatientGender");
|
| 507 |
-
const occupationInput = document.getElementById("newPatientOccupation");
|
| 508 |
-
const concernInput = document.getElementById("newPatientConcern");
|
| 509 |
-
|
| 510 |
-
const name = nameInput.value.trim();
|
| 511 |
-
const age = ageInput.value;
|
| 512 |
-
const gender = genderInput ? genderInput.value : null;
|
| 513 |
-
const occupation = occupationInput ? occupationInput.value.trim() : null;
|
| 514 |
-
|
| 515 |
-
if (!name || !age || !gender || !occupation) {
|
| 516 |
-
ui.showAlert("Missing Information", "Please fill out all 4 required fields (Name, Age, Gender, and Occupation) to create a new profile.");
|
| 517 |
-
return;
|
| 518 |
-
}
|
| 519 |
-
|
| 520 |
-
const parsedAge = parseInt(age);
|
| 521 |
-
if (isNaN(parsedAge) || parsedAge < 5 || parsedAge > 99) {
|
| 522 |
-
ui.showAlert("Invalid Age", "Age must be between 5 and 99.");
|
| 523 |
-
return;
|
| 524 |
-
}
|
| 525 |
-
|
| 526 |
-
try {
|
| 527 |
-
const data = {
|
| 528 |
-
name: name,
|
| 529 |
-
age: parseInt(age),
|
| 530 |
-
gender: gender,
|
| 531 |
-
occupation: occupation,
|
| 532 |
-
primary_concern: concernInput ? concernInput.value.trim() : null
|
| 533 |
-
};
|
| 534 |
-
const pData = await api.createPatientProfile(data);
|
| 535 |
-
state.setPatientId(pData.patient_id);
|
| 536 |
-
await profiles.showDashboard(state.patientId);
|
| 537 |
-
} catch (e) {
|
| 538 |
-
console.error("Failed to create profile:", e);
|
| 539 |
-
ui.showAlert("Error", "Failed to create patient profile. Please try again.");
|
| 540 |
-
}
|
| 541 |
-
};
|
| 542 |
-
}
|
| 543 |
-
|
| 544 |
-
if (startButton) {
|
| 545 |
-
startButton.onclick = async () => {
|
| 546 |
-
const text = startButton.textContent.trim();
|
| 547 |
-
if (text.includes("Start Consultation")) {
|
| 548 |
-
if (!auth.isAuthenticated()) {
|
| 549 |
-
ui.switchScreen("authScreen", false);
|
| 550 |
-
return;
|
| 551 |
-
}
|
| 552 |
-
|
| 553 |
-
startButton.textContent = "Loading...";
|
| 554 |
-
startButton.disabled = true;
|
| 555 |
-
try {
|
| 556 |
-
await profiles.fetchPatients();
|
| 557 |
-
} catch(e) {
|
| 558 |
-
console.error(e);
|
| 559 |
-
ui.showAlert("Error", "Failed to load dashboard. Check console.");
|
| 560 |
-
} finally {
|
| 561 |
-
startButton.textContent = "Start Consultation";
|
| 562 |
-
startButton.disabled = false;
|
| 563 |
-
}
|
| 564 |
-
} else if (text.includes("Retry")) {
|
| 565 |
-
startButton.textContent = "Initializing...";
|
| 566 |
-
startButton.disabled = true;
|
| 567 |
-
session.initializeSession();
|
| 568 |
-
}
|
| 569 |
-
};
|
| 570 |
-
}
|
| 571 |
-
|
| 572 |
-
// --- Session UI Events (Toggle Text/Voice, etc) ---
|
| 573 |
-
|
| 574 |
-
if (voiceModeBtn) {
|
| 575 |
-
voiceModeBtn.onclick = (e) => {
|
| 576 |
-
state.isVoiceMode = true;
|
| 577 |
-
voiceModeBtn.classList.add("active");
|
| 578 |
-
textModeBtn.classList.remove("active");
|
| 579 |
-
voiceInput.style.display = "flex";
|
| 580 |
-
textInput.style.display = "none";
|
| 581 |
-
|
| 582 |
-
// Auto-enable audio output when switching to voice mode
|
| 583 |
-
if (!state.isAudioOutputEnabled) {
|
| 584 |
-
session.setAudioOutput(true);
|
| 585 |
-
}
|
| 586 |
-
e.currentTarget.blur();
|
| 587 |
-
};
|
| 588 |
-
}
|
| 589 |
-
|
| 590 |
-
if (textModeBtn) {
|
| 591 |
-
textModeBtn.onclick = (e) => {
|
| 592 |
-
state.isVoiceMode = false;
|
| 593 |
-
textModeBtn.classList.add("active");
|
| 594 |
-
voiceModeBtn.classList.remove("active");
|
| 595 |
-
voiceInput.style.display = "none";
|
| 596 |
-
textInput.style.display = "block";
|
| 597 |
-
chatInput.focus();
|
| 598 |
-
};
|
| 599 |
-
}
|
| 600 |
-
|
| 601 |
-
const voiceSelect = document.getElementById("voiceSelect");
|
| 602 |
-
if (voiceSelect) {
|
| 603 |
-
voiceSelect.addEventListener('change', (e) => e.target.blur());
|
| 604 |
-
}
|
| 605 |
-
|
| 606 |
-
const audioOutputToggleMobile = document.getElementById("audioOutputToggleMobile");
|
| 607 |
-
if (audioOutputToggleMobile) {
|
| 608 |
-
audioOutputToggleMobile.addEventListener('change', (e) => {
|
| 609 |
-
session.setAudioOutput(e.target.checked);
|
| 610 |
-
e.target.blur();
|
| 611 |
-
});
|
| 612 |
-
}
|
| 613 |
-
|
| 614 |
-
const audioOutputToggleDesktop = document.getElementById("audioOutputToggleDesktop");
|
| 615 |
-
if (audioOutputToggleDesktop) {
|
| 616 |
-
audioOutputToggleDesktop.addEventListener('click', (e) => {
|
| 617 |
-
session.setAudioOutput(!state.isAudioOutputEnabled);
|
| 618 |
-
e.currentTarget.blur();
|
| 619 |
-
});
|
| 620 |
-
}
|
| 621 |
-
|
| 622 |
-
if (sendBtn) {
|
| 623 |
-
sendBtn.onclick = () => {
|
| 624 |
-
const message = chatInput.value.trim();
|
| 625 |
-
if (message) {
|
| 626 |
-
chatInput.value = "";
|
| 627 |
-
session.primeTTS(); // Renew activation synchronously
|
| 628 |
-
session.sendTextMessage(message);
|
| 629 |
-
}
|
| 630 |
-
};
|
| 631 |
-
}
|
| 632 |
-
|
| 633 |
-
if (chatInput) {
|
| 634 |
-
chatInput.onkeypress = (e) => {
|
| 635 |
-
if (e.key === 'Enter') sendBtn.onclick();
|
| 636 |
-
};
|
| 637 |
-
}
|
| 638 |
-
|
| 639 |
-
// --- Audio Recording Events ---
|
| 640 |
-
|
| 641 |
-
if (micBtn) {
|
| 642 |
-
micBtn.addEventListener('pointerdown', (e) => {
|
| 643 |
-
e.preventDefault();
|
| 644 |
-
if (!state.isVoiceMode || state.isAISpeaking) return;
|
| 645 |
-
if (!state.isListening && !state.isRecordingPending) {
|
| 646 |
-
micBtn.style.transform = 'scale(0.95)';
|
| 647 |
-
audio.startRecording(session.sendTextMessage);
|
| 648 |
-
}
|
| 649 |
-
});
|
| 650 |
-
|
| 651 |
-
const stopMicAction = (e) => {
|
| 652 |
-
e.preventDefault();
|
| 653 |
-
if (state.isVoiceMode && (state.isListening || state.isRecordingPending)) {
|
| 654 |
-
micBtn.style.transform = 'none';
|
| 655 |
-
session.primeTTS(); // Renew activation synchronously
|
| 656 |
-
audio.stopRecording();
|
| 657 |
-
}
|
| 658 |
-
};
|
| 659 |
-
|
| 660 |
-
micBtn.addEventListener('pointerup', stopMicAction);
|
| 661 |
-
micBtn.addEventListener('pointercancel', stopMicAction);
|
| 662 |
-
micBtn.addEventListener('pointerleave', stopMicAction);
|
| 663 |
-
}
|
| 664 |
-
|
| 665 |
-
// Spacebar hold-to-record (capture phase to intercept before browser synthetic clicks)
|
| 666 |
-
window.addEventListener('keydown', (e) => {
|
| 667 |
-
if (e.code !== 'Space' || e.repeat) return;
|
| 668 |
-
if (!state.isVoiceMode) return;
|
| 669 |
-
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return;
|
| 670 |
-
|
| 671 |
-
e.preventDefault();
|
| 672 |
-
e.stopPropagation();
|
| 673 |
-
|
| 674 |
-
if (state.isAISpeaking) return;
|
| 675 |
-
if (!state.isListening && !state.isRecordingPending) {
|
| 676 |
-
audio.startRecording(session.sendTextMessage);
|
| 677 |
-
}
|
| 678 |
-
}, { capture: true });
|
| 679 |
-
|
| 680 |
-
window.addEventListener('keyup', (e) => {
|
| 681 |
-
if (e.code !== 'Space') return;
|
| 682 |
-
if (!state.isVoiceMode) return;
|
| 683 |
-
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return;
|
| 684 |
-
|
| 685 |
-
e.preventDefault();
|
| 686 |
-
e.stopPropagation();
|
| 687 |
-
|
| 688 |
-
session.primeTTS(); // Renew activation synchronously
|
| 689 |
-
audio.stopRecording();
|
| 690 |
-
}, { capture: true });
|
| 691 |
-
|
| 692 |
-
const endSessionBtn = document.getElementById("endSessionBtn");
|
| 693 |
-
if (endSessionBtn) {
|
| 694 |
-
endSessionBtn.onclick = async () => {
|
| 695 |
-
if (!state.sessionId) return;
|
| 696 |
-
|
| 697 |
-
if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
|
| 698 |
-
ui.setStatusText("Ending session...");
|
| 699 |
-
|
| 700 |
-
try {
|
| 701 |
-
await api.endSessionReq(state.sessionId, state.patientId);
|
| 702 |
-
state.setSessionId(null);
|
| 703 |
-
window.location.href = "/";
|
| 704 |
-
} catch (err) {
|
| 705 |
-
console.error("Failed to end session:", err);
|
| 706 |
-
state.setSessionId(null);
|
| 707 |
-
window.location.href = "/";
|
| 708 |
-
}
|
| 709 |
-
};
|
| 710 |
-
}
|
| 711 |
-
}
|
| 712 |
-
|
| 713 |
-
// Bootstrap application
|
| 714 |
-
api.pingHealth().catch(() => console.log("Wakeup ping sent."));
|
| 715 |
-
audio.loadVoices();
|
| 716 |
-
bindEvents();
|
| 717 |
-
window.onload = () => session.initializeSession();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/js/audio.js
DELETED
|
@@ -1,218 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,135 +0,0 @@
|
|
| 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 |
-
export async function requestSignupOtp(email, username) {
|
| 20 |
-
const res = await fetch(`${BACKEND_URL}/api/auth/signup/request-otp`, {
|
| 21 |
-
method: "POST",
|
| 22 |
-
headers: { "Content-Type": "application/json" },
|
| 23 |
-
body: JSON.stringify({ email, username })
|
| 24 |
-
});
|
| 25 |
-
|
| 26 |
-
const data = await res.json();
|
| 27 |
-
if (!res.ok) {
|
| 28 |
-
if (Array.isArray(data.detail)) {
|
| 29 |
-
throw new Error(data.detail[0].msg || "Validation error");
|
| 30 |
-
}
|
| 31 |
-
throw new Error(data.detail || "Failed to request OTP");
|
| 32 |
-
}
|
| 33 |
-
return data;
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
export async function signup(payload) {
|
| 37 |
-
const res = await fetch(`${BACKEND_URL}/api/auth/signup`, {
|
| 38 |
-
method: "POST",
|
| 39 |
-
headers: { "Content-Type": "application/json" },
|
| 40 |
-
body: JSON.stringify(payload)
|
| 41 |
-
});
|
| 42 |
-
|
| 43 |
-
const data = await res.json();
|
| 44 |
-
if (!res.ok) {
|
| 45 |
-
if (Array.isArray(data.detail)) {
|
| 46 |
-
throw new Error(data.detail[0].msg || "Validation error");
|
| 47 |
-
}
|
| 48 |
-
throw new Error(data.detail || "Signup failed");
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
localStorage.setItem('serinity_token', data.access_token);
|
| 52 |
-
return data;
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
export async function forgotPassword(email) {
|
| 56 |
-
const res = await fetch(`${BACKEND_URL}/api/auth/forgot-password`, {
|
| 57 |
-
method: "POST",
|
| 58 |
-
headers: { "Content-Type": "application/json" },
|
| 59 |
-
body: JSON.stringify({ email })
|
| 60 |
-
});
|
| 61 |
-
|
| 62 |
-
const data = await res.json();
|
| 63 |
-
if (!res.ok) {
|
| 64 |
-
// Pydantic validation errors return detail as an array
|
| 65 |
-
if (res.status === 422 || Array.isArray(data.detail)) {
|
| 66 |
-
throw new Error("Enter a valid email address.");
|
| 67 |
-
}
|
| 68 |
-
throw new Error(data.detail || "Failed to request OTP");
|
| 69 |
-
}
|
| 70 |
-
return data;
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
export async function verifyOtp(email, otp_code) {
|
| 74 |
-
const res = await fetch(`${BACKEND_URL}/api/auth/verify-otp`, {
|
| 75 |
-
method: "POST",
|
| 76 |
-
headers: { "Content-Type": "application/json" },
|
| 77 |
-
body: JSON.stringify({ email, otp_code })
|
| 78 |
-
});
|
| 79 |
-
const data = await res.json();
|
| 80 |
-
if (!res.ok) throw new Error(data.detail || "Invalid OTP");
|
| 81 |
-
return data;
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
export async function resetPassword(email, otp_code, new_password) {
|
| 85 |
-
const res = await fetch(`${BACKEND_URL}/api/auth/reset-password`, {
|
| 86 |
-
method: "POST",
|
| 87 |
-
headers: { "Content-Type": "application/json" },
|
| 88 |
-
body: JSON.stringify({ email, otp_code, new_password })
|
| 89 |
-
});
|
| 90 |
-
|
| 91 |
-
const data = await res.json();
|
| 92 |
-
if (!res.ok) {
|
| 93 |
-
if (Array.isArray(data.detail)) {
|
| 94 |
-
throw new Error(data.detail[0].msg || "Validation error");
|
| 95 |
-
}
|
| 96 |
-
throw new Error(data.detail || "Failed to reset password");
|
| 97 |
-
}
|
| 98 |
-
return data;
|
| 99 |
-
}
|
| 100 |
-
export async function requestDeleteAccountOtp() {
|
| 101 |
-
const token = localStorage.getItem('serinity_token');
|
| 102 |
-
const res = await fetch(`${BACKEND_URL}/api/auth/delete-account/request-otp`, {
|
| 103 |
-
method: "POST",
|
| 104 |
-
headers: {
|
| 105 |
-
"Content-Type": "application/json",
|
| 106 |
-
"Authorization": `Bearer ${token}`
|
| 107 |
-
}
|
| 108 |
-
});
|
| 109 |
-
const data = await res.json();
|
| 110 |
-
if (!res.ok) throw new Error(data.detail || "Failed to request OTP");
|
| 111 |
-
return data;
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
export async function verifyDeleteAccount(otp_code) {
|
| 115 |
-
const token = localStorage.getItem('serinity_token');
|
| 116 |
-
const res = await fetch(`${BACKEND_URL}/api/auth/delete-account/verify`, {
|
| 117 |
-
method: "POST",
|
| 118 |
-
headers: {
|
| 119 |
-
"Content-Type": "application/json",
|
| 120 |
-
"Authorization": `Bearer ${token}`
|
| 121 |
-
},
|
| 122 |
-
body: JSON.stringify({ otp_code })
|
| 123 |
-
});
|
| 124 |
-
const data = await res.json();
|
| 125 |
-
if (!res.ok) throw new Error(data.detail || "Failed to delete account");
|
| 126 |
-
return data;
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
export function logout() {
|
| 130 |
-
localStorage.removeItem('serinity_token');
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
export function isAuthenticated() {
|
| 134 |
-
return !!localStorage.getItem('serinity_token');
|
| 135 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/js/profiles.js
DELETED
|
@@ -1,99 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,314 +0,0 @@
|
|
| 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 |
-
alert("Failed to start session. Please try again.");
|
| 131 |
-
location.reload();
|
| 132 |
-
}
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
/**
|
| 136 |
-
* Resumes an existing active session.
|
| 137 |
-
*
|
| 138 |
-
* @param {string} sessionId - The ID of the session to resume.
|
| 139 |
-
*/
|
| 140 |
-
export async function continueActualSession(sessionId) {
|
| 141 |
-
try {
|
| 142 |
-
state.setSessionId(sessionId);
|
| 143 |
-
ui.switchScreen("mainApp");
|
| 144 |
-
|
| 145 |
-
document.getElementById("chat").innerHTML = "";
|
| 146 |
-
|
| 147 |
-
const data = await api.getSessionMessages(sessionId);
|
| 148 |
-
if (data.messages) {
|
| 149 |
-
data.messages.forEach(msg => {
|
| 150 |
-
if (msg.role !== "system") {
|
| 151 |
-
if (msg.role === 'assistant') {
|
| 152 |
-
const parts = msg.content.split("&&").map(p => p.trim()).filter(Boolean);
|
| 153 |
-
parts.forEach(part => ui.addMessage(part, 'assistant'));
|
| 154 |
-
} else {
|
| 155 |
-
ui.addMessage(msg.content, msg.role);
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
});
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
state.sessionStartTime = Date.now();
|
| 162 |
-
state.exchangeCount = Math.floor((data.messages || []).length / 2);
|
| 163 |
-
ui.updateSessionStats();
|
| 164 |
-
if (state.sessionInterval) clearInterval(state.sessionInterval);
|
| 165 |
-
state.sessionInterval = setInterval(ui.updateSessionStats, 60000);
|
| 166 |
-
} catch (e) {
|
| 167 |
-
console.error("Failed to continue session:", e);
|
| 168 |
-
alert("Failed to continue session. Please try again.");
|
| 169 |
-
location.reload();
|
| 170 |
-
}
|
| 171 |
-
}
|
| 172 |
-
|
| 173 |
-
/**
|
| 174 |
-
* Pings the backend to check health and initializes routing based on URL path.
|
| 175 |
-
* Retries with exponential backoff if backend is not ready.
|
| 176 |
-
*
|
| 177 |
-
* @param {number} retryCount - Number of initialization retries so far.
|
| 178 |
-
*/
|
| 179 |
-
export async function initializeSession(retryCount = 0) {
|
| 180 |
-
const startButton = document.getElementById("startButton");
|
| 181 |
-
const wakeupText = document.getElementById("wakeupText");
|
| 182 |
-
|
| 183 |
-
if (retryCount === 0) {
|
| 184 |
-
if (wakeupText) wakeupText.textContent = "Waking up backend (this may take up to a minute)...";
|
| 185 |
-
if (startButton) {
|
| 186 |
-
startButton.disabled = false;
|
| 187 |
-
startButton.textContent = "Start Consultation";
|
| 188 |
-
}
|
| 189 |
-
}
|
| 190 |
-
try {
|
| 191 |
-
await api.pingHealth();
|
| 192 |
-
document.getElementById("loadingDots").style.display = "none";
|
| 193 |
-
if (wakeupText) wakeupText.style.display = "none";
|
| 194 |
-
|
| 195 |
-
if (startButton) {
|
| 196 |
-
startButton.disabled = false;
|
| 197 |
-
startButton.textContent = "Start Consultation";
|
| 198 |
-
}
|
| 199 |
-
|
| 200 |
-
// Handle hash routing from OAuth or direct links
|
| 201 |
-
if (window.location.hash) {
|
| 202 |
-
const hash = window.location.hash.substring(1);
|
| 203 |
-
let newPath = "/";
|
| 204 |
-
if (hash === "dashboardScreen") newPath = "/dashboard";
|
| 205 |
-
else if (hash === "mainApp") newPath = "/session";
|
| 206 |
-
|
| 207 |
-
if (newPath !== "/") {
|
| 208 |
-
window.history.replaceState(null, "", newPath);
|
| 209 |
-
}
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
// Handle deep linking / routing on load
|
| 213 |
-
const path = window.location.pathname;
|
| 214 |
-
if (path === "/dashboard") {
|
| 215 |
-
// Always re-fetch on refresh — state.patientId is in-memory and lost on reload
|
| 216 |
-
await profiles.fetchPatients();
|
| 217 |
-
} else if (path === "/session") {
|
| 218 |
-
if (!state.sessionId) {
|
| 219 |
-
window.location.href = "/";
|
| 220 |
-
} else {
|
| 221 |
-
ui.switchScreen("mainApp", false);
|
| 222 |
-
}
|
| 223 |
-
} else {
|
| 224 |
-
// Allow user to click the start button
|
| 225 |
-
}
|
| 226 |
-
} catch (error) {
|
| 227 |
-
console.error(`Backend not ready (attempt ${retryCount + 1}):`, error);
|
| 228 |
-
if (retryCount < 15) {
|
| 229 |
-
const delay = Math.min(2000 * Math.pow(1.3, retryCount), 30000);
|
| 230 |
-
setTimeout(() => initializeSession(retryCount + 1), delay);
|
| 231 |
-
} else {
|
| 232 |
-
document.getElementById("loadingDots").style.display = "none";
|
| 233 |
-
if (startButton) {
|
| 234 |
-
startButton.disabled = false;
|
| 235 |
-
startButton.textContent = "Retry Connection";
|
| 236 |
-
}
|
| 237 |
-
}
|
| 238 |
-
}
|
| 239 |
-
}
|
| 240 |
-
|
| 241 |
-
/**
|
| 242 |
-
* Renews the browser's TTS user-activation token synchronously.
|
| 243 |
-
* Must be called directly inside an onclick or keydown/keyup handler.
|
| 244 |
-
*/
|
| 245 |
-
export function primeTTS() {
|
| 246 |
-
if (state.isAudioOutputEnabled && window.speechSynthesis) {
|
| 247 |
-
const primer = new SpeechSynthesisUtterance(' ');
|
| 248 |
-
primer.volume = 0;
|
| 249 |
-
primer.rate = 10;
|
| 250 |
-
window.speechSynthesis.speak(primer);
|
| 251 |
-
}
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
-
/**
|
| 255 |
-
* Toggle audio output on/off and update the button visuals.
|
| 256 |
-
*
|
| 257 |
-
* @param {boolean} enabled - Whether audio should be enabled.
|
| 258 |
-
*/
|
| 259 |
-
export function setAudioOutput(enabled) {
|
| 260 |
-
state.isAudioOutputEnabled = enabled;
|
| 261 |
-
|
| 262 |
-
const mobileToggle = document.getElementById("audioOutputToggleMobile");
|
| 263 |
-
const track = document.getElementById("audioToggleTrack");
|
| 264 |
-
const dot = document.getElementById("audioToggleDot");
|
| 265 |
-
const iconMobile = document.getElementById("audioToggleIconMobile");
|
| 266 |
-
|
| 267 |
-
const desktopToggle = document.getElementById("audioOutputToggleDesktop");
|
| 268 |
-
const labelDesktop = document.getElementById("audioToggleLabelDesktop");
|
| 269 |
-
|
| 270 |
-
if (mobileToggle && mobileToggle.checked !== enabled) {
|
| 271 |
-
mobileToggle.checked = enabled;
|
| 272 |
-
}
|
| 273 |
-
|
| 274 |
-
if (enabled) {
|
| 275 |
-
// Prime the TTS engine with a silent utterance linked to this click gesture.
|
| 276 |
-
if (window.speechSynthesis) {
|
| 277 |
-
window.speechSynthesis.getVoices();
|
| 278 |
-
const primer = new SpeechSynthesisUtterance(' ');
|
| 279 |
-
primer.volume = 0;
|
| 280 |
-
primer.rate = 10;
|
| 281 |
-
window.speechSynthesis.speak(primer);
|
| 282 |
-
}
|
| 283 |
-
|
| 284 |
-
// Update mobile UI
|
| 285 |
-
if (track) { track.classList.remove("bg-ink/30"); track.classList.add("bg-moss"); }
|
| 286 |
-
if (dot) { dot.classList.add("translate-x-4"); }
|
| 287 |
-
if (iconMobile) { iconMobile.classList.remove("text-ink/50"); iconMobile.classList.add("text-moss"); }
|
| 288 |
-
|
| 289 |
-
// Update desktop UI
|
| 290 |
-
if (desktopToggle) {
|
| 291 |
-
desktopToggle.classList.add("bg-moss", "border-moss", "text-paper");
|
| 292 |
-
desktopToggle.classList.remove("text-ink", "border-ink");
|
| 293 |
-
}
|
| 294 |
-
if (labelDesktop) labelDesktop.textContent = "Audio On";
|
| 295 |
-
|
| 296 |
-
} else {
|
| 297 |
-
if (window.speechSynthesis && window.speechSynthesis.speaking) {
|
| 298 |
-
window.speechSynthesis.cancel();
|
| 299 |
-
}
|
| 300 |
-
state.isAISpeaking = false;
|
| 301 |
-
|
| 302 |
-
// Update mobile UI
|
| 303 |
-
if (track) { track.classList.remove("bg-moss"); track.classList.add("bg-ink/30"); }
|
| 304 |
-
if (dot) { dot.classList.remove("translate-x-4"); }
|
| 305 |
-
if (iconMobile) { iconMobile.classList.remove("text-moss"); iconMobile.classList.add("text-ink/50"); }
|
| 306 |
-
|
| 307 |
-
// Update desktop UI
|
| 308 |
-
if (desktopToggle) {
|
| 309 |
-
desktopToggle.classList.remove("bg-moss", "border-moss", "text-paper");
|
| 310 |
-
desktopToggle.classList.add("text-ink", "border-ink");
|
| 311 |
-
}
|
| 312 |
-
if (labelDesktop) labelDesktop.textContent = "Audio Off";
|
| 313 |
-
}
|
| 314 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/js/state.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,242 +0,0 @@
|
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|