banao-tech commited on
Commit
014cb53
·
verified ·
1 Parent(s): 3abacde

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +697 -19
index.html CHANGED
@@ -1,19 +1,697 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Vidya — Onboarding</title>
6
+ <style>
7
+ :root {
8
+ --bg: #f5f6f8;
9
+ --surface: #ffffff;
10
+ --surface-2: #f8f9fb;
11
+ --border: #e3e6ec;
12
+ --text: #1a1f2e;
13
+ --muted: #6b7280;
14
+ --accent: #4f46e5;
15
+ --accent-2: #4338ca;
16
+ --success: #059669;
17
+ --warn: #d97706;
18
+ --error: #dc2626;
19
+ --user: #4f46e5;
20
+ --ai: #1a1f2e;
21
+ }
22
+
23
+ * { box-sizing: border-box; }
24
+ html, body { height: 100%; }
25
+ body {
26
+ margin: 0;
27
+ font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ font-size: 14px;
31
+ line-height: 1.5;
32
+ }
33
+
34
+ .app {
35
+ max-width: 1200px;
36
+ margin: 0 auto;
37
+ padding: 28px 24px;
38
+ }
39
+
40
+ header {
41
+ display: flex;
42
+ justify-content: space-between;
43
+ align-items: center;
44
+ margin-bottom: 24px;
45
+ }
46
+ .brand { display: flex; align-items: center; gap: 12px; }
47
+ .brand-mark {
48
+ width: 36px; height: 36px; border-radius: 8px;
49
+ background: var(--accent);
50
+ display: flex; align-items: center; justify-content: center;
51
+ color: white; font-weight: 700; font-size: 16px;
52
+ }
53
+ .brand-title { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
54
+ .brand-sub { font-size: 12px; color: var(--muted); }
55
+
56
+ .status {
57
+ display: inline-flex; align-items: center; gap: 8px;
58
+ padding: 6px 12px; border-radius: 6px;
59
+ background: var(--surface); border: 1px solid var(--border);
60
+ font-size: 12px; color: var(--muted);
61
+ }
62
+ .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
63
+ .dot.live { background: var(--success); box-shadow: 0 0 0 3px rgba(5,150,105,0.15); }
64
+ .dot.err { background: var(--error); }
65
+ .dot.busy { background: var(--warn); }
66
+
67
+ .config {
68
+ display: grid;
69
+ grid-template-columns: 1fr auto;
70
+ gap: 12px;
71
+ padding: 14px;
72
+ background: var(--surface);
73
+ border: 1px solid var(--border);
74
+ border-radius: 10px;
75
+ margin-bottom: 20px;
76
+ }
77
+ .field label {
78
+ display: block; font-size: 11px; color: var(--muted);
79
+ margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600;
80
+ }
81
+ .field input {
82
+ width: 100%; padding: 9px 12px;
83
+ background: var(--surface);
84
+ color: var(--text);
85
+ border: 1px solid var(--border);
86
+ border-radius: 6px;
87
+ font-size: 13px; font-family: inherit;
88
+ transition: border 0.15s;
89
+ }
90
+ .field input:focus {
91
+ outline: none; border-color: var(--accent);
92
+ }
93
+ .btn {
94
+ padding: 9px 18px;
95
+ background: var(--accent);
96
+ color: white; border: none; border-radius: 6px;
97
+ font-weight: 600; cursor: pointer; font-size: 13px;
98
+ transition: background 0.15s;
99
+ align-self: end;
100
+ }
101
+ .btn:hover { background: var(--accent-2); }
102
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
103
+ .btn.danger { background: var(--error); }
104
+ .btn.danger:hover { background: #b91c1c; }
105
+
106
+ .main {
107
+ display: grid;
108
+ grid-template-columns: 1fr 340px;
109
+ gap: 20px;
110
+ }
111
+ @media (max-width: 920px) {
112
+ .main { grid-template-columns: 1fr; }
113
+ }
114
+
115
+ .card {
116
+ background: var(--surface);
117
+ border: 1px solid var(--border);
118
+ border-radius: 10px;
119
+ padding: 20px;
120
+ }
121
+ .card-title {
122
+ font-size: 11px;
123
+ color: var(--muted);
124
+ text-transform: uppercase;
125
+ letter-spacing: 0.05em;
126
+ font-weight: 600;
127
+ margin: 0 0 16px 0;
128
+ }
129
+
130
+ .stage {
131
+ display: flex; flex-direction: column; align-items: center;
132
+ padding: 20px 0 12px 0;
133
+ }
134
+ .orb {
135
+ width: 120px; height: 120px; border-radius: 50%;
136
+ background: var(--surface-2);
137
+ border: 2px solid var(--border);
138
+ position: relative;
139
+ transition: all 0.3s ease;
140
+ display: flex; align-items: center; justify-content: center;
141
+ }
142
+ .orb.speaking {
143
+ background: #eef2ff;
144
+ border-color: var(--accent);
145
+ animation: pulse-accent 1.4s ease-in-out infinite;
146
+ }
147
+ .orb.listening {
148
+ background: #ecfdf5;
149
+ border-color: var(--success);
150
+ animation: pulse-success 1.0s ease-in-out infinite;
151
+ }
152
+ .orb-icon {
153
+ font-size: 32px; opacity: 0.85;
154
+ transition: opacity 0.2s;
155
+ }
156
+ @keyframes pulse-accent {
157
+ 0%,100% { box-shadow: 0 0 0 0 rgba(79,70,229,0.0); }
158
+ 50% { box-shadow: 0 0 0 14px rgba(79,70,229,0.08); }
159
+ }
160
+ @keyframes pulse-success {
161
+ 0%,100% { box-shadow: 0 0 0 0 rgba(5,150,105,0.0); }
162
+ 50% { box-shadow: 0 0 0 14px rgba(5,150,105,0.10); }
163
+ }
164
+ .stage-label {
165
+ margin-top: 14px;
166
+ font-size: 12px;
167
+ color: var(--muted);
168
+ font-weight: 500;
169
+ }
170
+
171
+ .transcript {
172
+ max-height: 420px;
173
+ overflow-y: auto;
174
+ padding: 4px 4px 4px 0;
175
+ }
176
+ .empty-state {
177
+ color: var(--muted);
178
+ text-align: center;
179
+ padding: 40px 20px;
180
+ font-size: 13px;
181
+ }
182
+ .turn {
183
+ margin-bottom: 12px;
184
+ padding: 12px 14px;
185
+ border-radius: 8px;
186
+ background: var(--surface-2);
187
+ font-size: 13px;
188
+ border: 1px solid var(--border);
189
+ }
190
+ .turn .who {
191
+ font-size: 10px;
192
+ text-transform: uppercase;
193
+ letter-spacing: 0.05em;
194
+ margin-bottom: 6px;
195
+ font-weight: 700;
196
+ }
197
+ .turn.user .who { color: var(--user); }
198
+ .turn.ai .who { color: var(--muted); }
199
+ .turn.user { background: #eef2ff; border-color: #c7d2fe; }
200
+ .turn .text { color: var(--text); line-height: 1.55; }
201
+
202
+ .log {
203
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
204
+ font-size: 11px;
205
+ max-height: 560px;
206
+ overflow-y: auto;
207
+ background: var(--surface-2);
208
+ padding: 10px;
209
+ border-radius: 6px;
210
+ border: 1px solid var(--border);
211
+ color: var(--text);
212
+ }
213
+ .log .row { margin-bottom: 2px; white-space: pre-wrap; }
214
+ .log .ts { color: var(--muted); margin-right: 6px; }
215
+ .log .ok { color: var(--success); }
216
+ .log .err { color: var(--error); }
217
+ .log .info { color: var(--accent); }
218
+ .log .warn { color: var(--warn); }
219
+
220
+ .summary {
221
+ display: none;
222
+ margin-top: 20px;
223
+ background: var(--surface);
224
+ border: 1px solid var(--border);
225
+ border-radius: 10px;
226
+ padding: 24px;
227
+ }
228
+ .summary.show { display: block; }
229
+ .summary h3 { margin: 0 0 4px 0; font-size: 16px; color: var(--text); }
230
+ .summary p.lead { color: var(--muted); margin: 0 0 18px 0; font-size: 13px; }
231
+ .summary-grid {
232
+ display: grid;
233
+ grid-template-columns: repeat(2, 1fr);
234
+ gap: 12px;
235
+ }
236
+ .summary-item {
237
+ background: var(--surface-2);
238
+ border: 1px solid var(--border);
239
+ border-radius: 8px;
240
+ padding: 14px;
241
+ }
242
+ .summary-item .lbl {
243
+ font-size: 11px; color: var(--muted);
244
+ text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600;
245
+ margin-bottom: 6px;
246
+ }
247
+ .summary-item .val {
248
+ font-size: 14px; color: var(--text); font-weight: 500;
249
+ }
250
+
251
+ ::-webkit-scrollbar { width: 8px; }
252
+ ::-webkit-scrollbar-track { background: transparent; }
253
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
254
+ ::-webkit-scrollbar-thumb:hover { background: #c8ccd4; }
255
+ </style>
256
+ </head>
257
+ <body>
258
+
259
+ <div class="app">
260
+
261
+ <header>
262
+ <div class="brand">
263
+ <div class="brand-mark">V</div>
264
+ <div>
265
+ <div class="brand-title">Vidya</div>
266
+ <div class="brand-sub">Onboarding Conversation</div>
267
+ </div>
268
+ </div>
269
+ <div class="status">
270
+ <span class="dot" id="statusDot"></span>
271
+ <span id="statusText">Disconnected</span>
272
+ </div>
273
+ </header>
274
+
275
+ <div class="config">
276
+ <div class="field">
277
+ <label>User ID</label>
278
+ <input id="userId" value="44898973" />
279
+ </div>
280
+ <button class="btn" id="connectBtn">Start Session</button>
281
+ </div>
282
+
283
+ <div class="main">
284
+
285
+ <div>
286
+ <div class="card">
287
+ <h2 class="card-title">Conversation</h2>
288
+ <div class="stage">
289
+ <div class="orb" id="orb">
290
+ <span class="orb-icon" id="orbIcon">🎙️</span>
291
+ </div>
292
+ <div class="stage-label" id="stageLabel">Press Start Session to begin</div>
293
+ </div>
294
+ </div>
295
+
296
+ <div class="card" style="margin-top: 20px;">
297
+ <h2 class="card-title">Transcript</h2>
298
+ <div class="transcript" id="transcript">
299
+ <div class="empty-state">The conversation will appear here.</div>
300
+ </div>
301
+ </div>
302
+
303
+ <div class="summary" id="summary">
304
+ <h3>Onboarding Complete</h3>
305
+ <p class="lead">Your responses have been captured.</p>
306
+ <div class="summary-grid" id="summaryGrid"></div>
307
+ </div>
308
+ </div>
309
+
310
+ <div>
311
+ <div class="card">
312
+ <h2 class="card-title">Event Log</h2>
313
+ <div class="log" id="log"></div>
314
+ </div>
315
+ </div>
316
+
317
+ </div>
318
+ </div>
319
+
320
+ <script>
321
+ // ====================================================================
322
+ // CONFIG
323
+ // ====================================================================
324
+ const SEND_RATE = 16000;
325
+ const RECV_RATE = 24000;
326
+ const CHUNK_SIZE = 1024;
327
+ const MODE = "onboarding";
328
+ const ENDPOINT = "ws://live-assistant-alb-401098210.ap-south-1.elb.amazonaws.com:80";
329
+
330
+ // ====================================================================
331
+ // STATE
332
+ // ====================================================================
333
+ let ws = null;
334
+ let audioCtx = null;
335
+ let playbackCtx = null;
336
+ let micStream = null;
337
+ let micProcessor = null;
338
+ let micSource = null;
339
+ let nextPlayTime = 0;
340
+ let isConnected = false;
341
+ let isSpeaking = false;
342
+ let speakingTimer = null;
343
+
344
+ const state = {
345
+ userBuf: "",
346
+ aiBuf: "",
347
+ userTurnEl: null,
348
+ aiTurnEl: null,
349
+ };
350
+
351
+ // ====================================================================
352
+ // HELPERS
353
+ // ====================================================================
354
+ const $ = (id) => document.getElementById(id);
355
+
356
+ function setStatus(text, cls = "") {
357
+ $("statusText").textContent = text;
358
+ const dot = $("statusDot");
359
+ dot.classList.remove("live", "err", "busy");
360
+ if (cls) dot.classList.add(cls);
361
+ }
362
+
363
+ function log(msg, cls = "") {
364
+ const row = document.createElement("div");
365
+ row.className = "row";
366
+ const ts = new Date().toTimeString().slice(0, 8);
367
+ row.innerHTML = `<span class="ts">${ts}</span><span class="${cls}">${escapeHtml(msg)}</span>`;
368
+ const el = $("log");
369
+ el.appendChild(row);
370
+ el.scrollTop = el.scrollHeight;
371
+ }
372
+
373
+ function escapeHtml(s) {
374
+ return String(s).replace(/[&<>"']/g, (c) => ({
375
+ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"
376
+ }[c]));
377
+ }
378
+
379
+ function setOrbState(s) {
380
+ const orb = $("orb");
381
+ const icon = $("orbIcon");
382
+ const label = $("stageLabel");
383
+ orb.classList.remove("speaking", "listening");
384
+ if (s === "speaking") {
385
+ orb.classList.add("speaking");
386
+ icon.textContent = "🔊";
387
+ label.textContent = "Assistant is speaking…";
388
+ } else if (s === "listening") {
389
+ orb.classList.add("listening");
390
+ icon.textContent = "🎤";
391
+ label.textContent = "Listening — speak naturally";
392
+ } else if (s === "idle") {
393
+ icon.textContent = "🎙️";
394
+ label.textContent = "Ready";
395
+ } else {
396
+ icon.textContent = "🎙️";
397
+ label.textContent = "Press Start Session to begin";
398
+ }
399
+ }
400
+
401
+ function appendTurn(who) {
402
+ const tr = $("transcript");
403
+ const empty = tr.querySelector(".empty-state");
404
+ if (empty) empty.remove();
405
+ const div = document.createElement("div");
406
+ div.className = "turn " + (who === "user" ? "user" : "ai");
407
+ div.innerHTML = `<div class="who">${who === "user" ? "You" : "Assistant"}</div><div class="text"></div>`;
408
+ tr.appendChild(div);
409
+ tr.scrollTop = tr.scrollHeight;
410
+ return div;
411
+ }
412
+
413
+ function updateTurnText(el, text) {
414
+ if (el) el.querySelector(".text").textContent = text;
415
+ $("transcript").scrollTop = $("transcript").scrollHeight;
416
+ }
417
+
418
+ // ====================================================================
419
+ // MIC
420
+ // ====================================================================
421
+ async function startMic() {
422
+ micStream = await navigator.mediaDevices.getUserMedia({
423
+ audio: {
424
+ channelCount: 1,
425
+ echoCancellation: true,
426
+ noiseSuppression: true,
427
+ autoGainControl: true,
428
+ },
429
+ });
430
+
431
+ audioCtx = new AudioContext({ sampleRate: SEND_RATE });
432
+ micSource = audioCtx.createMediaStreamSource(micStream);
433
+ micProcessor = audioCtx.createScriptProcessor(CHUNK_SIZE, 1, 1);
434
+
435
+ micProcessor.onaudioprocess = (e) => {
436
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
437
+ const float32 = e.inputBuffer.getChannelData(0);
438
+ const pcm16 = floatToPCM16(float32);
439
+ const b64 = bytesToBase64(new Uint8Array(pcm16.buffer));
440
+ ws.send(JSON.stringify({ type: "audio_chunk", audio: b64 }));
441
+ };
442
+
443
+ micSource.connect(micProcessor);
444
+ micProcessor.connect(audioCtx.destination);
445
+ log("Microphone capturing @ 16kHz", "ok");
446
+ }
447
+
448
+ function stopMic() {
449
+ if (micProcessor) { micProcessor.disconnect(); micProcessor = null; }
450
+ if (micSource) { micSource.disconnect(); micSource = null; }
451
+ if (audioCtx) { audioCtx.close(); audioCtx = null; }
452
+ if (micStream) { micStream.getTracks().forEach(t => t.stop()); micStream = null; }
453
+ }
454
+
455
+ function floatToPCM16(float32) {
456
+ const out = new Int16Array(float32.length);
457
+ for (let i = 0; i < float32.length; i++) {
458
+ let s = Math.max(-1, Math.min(1, float32[i]));
459
+ out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
460
+ }
461
+ return out;
462
+ }
463
+
464
+ function bytesToBase64(bytes) {
465
+ let bin = "";
466
+ const chunk = 0x8000;
467
+ for (let i = 0; i < bytes.length; i += chunk) {
468
+ bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
469
+ }
470
+ return btoa(bin);
471
+ }
472
+
473
+ function base64ToBytes(b64) {
474
+ const bin = atob(b64);
475
+ const out = new Uint8Array(bin.length);
476
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
477
+ return out;
478
+ }
479
+
480
+ // ====================================================================
481
+ // PLAYBACK
482
+ // ====================================================================
483
+ function ensurePlaybackCtx() {
484
+ if (!playbackCtx) {
485
+ playbackCtx = new AudioContext({ sampleRate: RECV_RATE });
486
+ nextPlayTime = playbackCtx.currentTime;
487
+ }
488
+ }
489
+
490
+ function playPCM(int16) {
491
+ ensurePlaybackCtx();
492
+ const float32 = new Float32Array(int16.length);
493
+ for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 0x8000;
494
+
495
+ const buf = playbackCtx.createBuffer(1, float32.length, RECV_RATE);
496
+ buf.copyToChannel(float32, 0);
497
+
498
+ const src = playbackCtx.createBufferSource();
499
+ src.buffer = buf;
500
+ src.connect(playbackCtx.destination);
501
+
502
+ const now = playbackCtx.currentTime;
503
+ const startAt = Math.max(now, nextPlayTime);
504
+ src.start(startAt);
505
+ nextPlayTime = startAt + buf.duration;
506
+
507
+ if (!isSpeaking) {
508
+ isSpeaking = true;
509
+ setOrbState("speaking");
510
+ }
511
+ if (speakingTimer) clearTimeout(speakingTimer);
512
+ const remainingMs = Math.max(0, (nextPlayTime - now) * 1000);
513
+ speakingTimer = setTimeout(() => {
514
+ isSpeaking = false;
515
+ if (isConnected) setOrbState("listening");
516
+ }, remainingMs + 120);
517
+ }
518
+
519
+ // ====================================================================
520
+ // WEBSOCKET
521
+ // ====================================================================
522
+ async function connect() {
523
+ const userId = $("userId").value.trim();
524
+ const url = `${ENDPOINT}/ws/${MODE}/${userId}`;
525
+
526
+ log(`Connecting → ${url}`, "info");
527
+ setStatus("Connecting…", "busy");
528
+
529
+ try {
530
+ ws = new WebSocket(url);
531
+ } catch (e) {
532
+ log("Failed to create WebSocket: " + e.message, "err");
533
+ setStatus("Error", "err");
534
+ return;
535
+ }
536
+ ws.binaryType = "arraybuffer";
537
+
538
+ ws.onopen = async () => {
539
+ isConnected = true;
540
+ setStatus("Connected", "live");
541
+ setOrbState("idle");
542
+ log("WebSocket connected", "ok");
543
+ $("connectBtn").textContent = "End Session";
544
+ $("connectBtn").classList.add("danger");
545
+ try {
546
+ await startMic();
547
+ setOrbState("listening");
548
+ } catch (e) {
549
+ log("Mic error: " + e.message, "err");
550
+ }
551
+ };
552
+
553
+ ws.onmessage = (event) => {
554
+ if (event.data instanceof ArrayBuffer) {
555
+ const int16 = new Int16Array(event.data);
556
+ playPCM(int16);
557
+ return;
558
+ }
559
+ handleMessage(event.data);
560
+ };
561
+
562
+ ws.onerror = () => {
563
+ log("WebSocket error", "err");
564
+ setStatus("Error", "err");
565
+ };
566
+
567
+ ws.onclose = () => {
568
+ isConnected = false;
569
+ setStatus("Disconnected");
570
+ setOrbState("");
571
+ log("Connection closed", "info");
572
+ $("connectBtn").textContent = "Start Session";
573
+ $("connectBtn").classList.remove("danger");
574
+ stopMic();
575
+ };
576
+ }
577
+
578
+ function disconnect() {
579
+ if (ws) ws.close();
580
+ stopMic();
581
+ }
582
+
583
+ function handleMessage(raw) {
584
+ let data;
585
+ try { data = JSON.parse(raw); } catch { return; }
586
+ const t = data.type;
587
+ const d = data.data;
588
+
589
+ switch (t) {
590
+ case "audio_chunk": {
591
+ if (data.is_last_chunk) return;
592
+ if (data.audio) {
593
+ const bytes = base64ToBytes(data.audio);
594
+ const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
595
+ playPCM(int16);
596
+ }
597
+ break;
598
+ }
599
+
600
+ case "user_transcript": {
601
+ state.userBuf += d || "";
602
+ if (!state.userTurnEl) state.userTurnEl = appendTurn("user");
603
+ updateTurnText(state.userTurnEl, state.userBuf);
604
+ break;
605
+ }
606
+
607
+ case "model_text": {
608
+ state.aiBuf += d || "";
609
+ if (!state.aiTurnEl) state.aiTurnEl = appendTurn("ai");
610
+ updateTurnText(state.aiTurnEl, state.aiBuf);
611
+ break;
612
+ }
613
+
614
+ case "turn_complete": {
615
+ const hasUser = state.userBuf.trim() !== "";
616
+ const hasBot = state.aiBuf.trim() !== "";
617
+ if (hasUser && hasBot) {
618
+ log("Turn captured", "ok");
619
+ } else if (hasBot) {
620
+ log("Assistant prompt (no user input)", "info");
621
+ } else {
622
+ log("Empty turn", "warn");
623
+ }
624
+ state.userBuf = "";
625
+ state.aiBuf = "";
626
+ state.userTurnEl = null;
627
+ state.aiTurnEl = null;
628
+ break;
629
+ }
630
+
631
+ case "conversation_ended": {
632
+ log("Conversation ended — generating summary…", "info");
633
+ $("stageLabel").textContent = "Generating summary…";
634
+ break;
635
+ }
636
+
637
+ case "summary_complete": {
638
+ log("✅ Summary received", "ok");
639
+ const s = data.summary || {};
640
+ renderSummary(s);
641
+ setOrbState("");
642
+ $("stageLabel").textContent = "Onboarding complete";
643
+ setTimeout(() => disconnect(), 1200);
644
+ break;
645
+ }
646
+
647
+ case "summary_error": {
648
+ log("❌ Summary error: " + (data.message || "unknown"), "err");
649
+ break;
650
+ }
651
+
652
+ case "onboarding_complete": {
653
+ log("🎉 Onboarding finished", "ok");
654
+ setTimeout(() => disconnect(), 1500);
655
+ break;
656
+ }
657
+
658
+ case "error": {
659
+ log("Server error: " + (data.message || "unknown"), "err");
660
+ break;
661
+ }
662
+ }
663
+ }
664
+
665
+ function renderSummary(s) {
666
+ const grid = $("summaryGrid");
667
+ grid.innerHTML = "";
668
+ const items = [
669
+ ["Primary Goal", s.learning_objectives?.primary_goal],
670
+ ["Skill Level", s.background_skills?.level],
671
+ ["Learning Style", s.learning_style?.preferred_format],
672
+ ["Timeline", s.timeline?.goal_timeline],
673
+ ];
674
+ for (const [lbl, val] of items) {
675
+ const div = document.createElement("div");
676
+ div.className = "summary-item";
677
+ div.innerHTML = `<div class="lbl">${lbl}</div><div class="val">${escapeHtml(val || "—")}</div>`;
678
+ grid.appendChild(div);
679
+ }
680
+ $("summary").classList.add("show");
681
+ }
682
+
683
+ // ====================================================================
684
+ // WIRE UP
685
+ // ====================================================================
686
+ $("connectBtn").addEventListener("click", () => {
687
+ if (isConnected) disconnect();
688
+ else connect();
689
+ });
690
+
691
+ window.addEventListener("beforeunload", () => {
692
+ if (ws) ws.close();
693
+ stopMic();
694
+ });
695
+ </script>
696
+ </body>
697
+ </html>