Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>Vidya — Onboarding</title> | |
| <style> | |
| :root { | |
| --bg: #f5f6f8; | |
| --surface: #ffffff; | |
| --surface-2: #f8f9fb; | |
| --border: #e3e6ec; | |
| --text: #1a1f2e; | |
| --muted: #6b7280; | |
| --accent: #4f46e5; | |
| --accent-2: #4338ca; | |
| --success: #059669; | |
| --warn: #d97706; | |
| --error: #dc2626; | |
| --user: #4f46e5; | |
| --ai: #1a1f2e; | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; } | |
| body { | |
| margin: 0; | |
| font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-size: 14px; | |
| line-height: 1.5; | |
| } | |
| .app { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 28px 24px; | |
| } | |
| header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 24px; | |
| } | |
| .brand { display: flex; align-items: center; gap: 12px; } | |
| .brand-mark { | |
| width: 36px; height: 36px; border-radius: 8px; | |
| background: var(--accent); | |
| display: flex; align-items: center; justify-content: center; | |
| color: white; font-weight: 700; font-size: 16px; | |
| } | |
| .brand-title { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; } | |
| .brand-sub { font-size: 12px; color: var(--muted); } | |
| .status { | |
| display: inline-flex; align-items: center; gap: 8px; | |
| padding: 6px 12px; border-radius: 6px; | |
| background: var(--surface); border: 1px solid var(--border); | |
| font-size: 12px; color: var(--muted); | |
| } | |
| .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); } | |
| .dot.live { background: var(--success); box-shadow: 0 0 0 3px rgba(5,150,105,0.15); } | |
| .dot.err { background: var(--error); } | |
| .dot.busy { background: var(--warn); } | |
| .config { | |
| display: grid; | |
| grid-template-columns: 1fr auto; | |
| gap: 12px; | |
| padding: 14px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| margin-bottom: 20px; | |
| } | |
| .field label { | |
| display: block; font-size: 11px; color: var(--muted); | |
| margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; | |
| } | |
| .field input { | |
| width: 100%; padding: 9px 12px; | |
| background: var(--surface); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| font-size: 13px; font-family: inherit; | |
| transition: border 0.15s; | |
| } | |
| .field input:focus { | |
| outline: none; border-color: var(--accent); | |
| } | |
| .btn { | |
| padding: 9px 18px; | |
| background: var(--accent); | |
| color: white; border: none; border-radius: 6px; | |
| font-weight: 600; cursor: pointer; font-size: 13px; | |
| transition: background 0.15s; | |
| align-self: end; | |
| } | |
| .btn:hover { background: var(--accent-2); } | |
| .btn:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .btn.danger { background: var(--error); } | |
| .btn.danger:hover { background: #b91c1c; } | |
| .main { | |
| display: grid; | |
| grid-template-columns: 1fr 340px; | |
| gap: 20px; | |
| } | |
| @media (max-width: 920px) { | |
| .main { grid-template-columns: 1fr; } | |
| } | |
| .card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 20px; | |
| } | |
| .card-title { | |
| font-size: 11px; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| font-weight: 600; | |
| margin: 0 0 16px 0; | |
| } | |
| .stage { | |
| display: flex; flex-direction: column; align-items: center; | |
| padding: 20px 0 12px 0; | |
| } | |
| .orb { | |
| width: 120px; height: 120px; border-radius: 50%; | |
| background: var(--surface-2); | |
| border: 2px solid var(--border); | |
| position: relative; | |
| transition: all 0.3s ease; | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .orb.speaking { | |
| background: #eef2ff; | |
| border-color: var(--accent); | |
| animation: pulse-accent 1.4s ease-in-out infinite; | |
| } | |
| .orb.listening { | |
| background: #ecfdf5; | |
| border-color: var(--success); | |
| animation: pulse-success 1.0s ease-in-out infinite; | |
| } | |
| .orb-icon { | |
| font-size: 32px; opacity: 0.85; | |
| transition: opacity 0.2s; | |
| } | |
| @keyframes pulse-accent { | |
| 0%,100% { box-shadow: 0 0 0 0 rgba(79,70,229,0.0); } | |
| 50% { box-shadow: 0 0 0 14px rgba(79,70,229,0.08); } | |
| } | |
| @keyframes pulse-success { | |
| 0%,100% { box-shadow: 0 0 0 0 rgba(5,150,105,0.0); } | |
| 50% { box-shadow: 0 0 0 14px rgba(5,150,105,0.10); } | |
| } | |
| .stage-label { | |
| margin-top: 14px; | |
| font-size: 12px; | |
| color: var(--muted); | |
| font-weight: 500; | |
| } | |
| .transcript { | |
| max-height: 420px; | |
| overflow-y: auto; | |
| padding: 4px 4px 4px 0; | |
| } | |
| .empty-state { | |
| color: var(--muted); | |
| text-align: center; | |
| padding: 40px 20px; | |
| font-size: 13px; | |
| } | |
| .turn { | |
| margin-bottom: 12px; | |
| padding: 12px 14px; | |
| border-radius: 8px; | |
| background: var(--surface-2); | |
| font-size: 13px; | |
| border: 1px solid var(--border); | |
| } | |
| .turn .who { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| margin-bottom: 6px; | |
| font-weight: 700; | |
| } | |
| .turn.user .who { color: var(--user); } | |
| .turn.ai .who { color: var(--muted); } | |
| .turn.user { background: #eef2ff; border-color: #c7d2fe; } | |
| .turn .text { color: var(--text); line-height: 1.55; } | |
| .log { | |
| font-family: ui-monospace, "SF Mono", Menlo, monospace; | |
| font-size: 11px; | |
| max-height: 560px; | |
| overflow-y: auto; | |
| background: var(--surface-2); | |
| padding: 10px; | |
| border-radius: 6px; | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| } | |
| .log .row { margin-bottom: 2px; white-space: pre-wrap; } | |
| .log .ts { color: var(--muted); margin-right: 6px; } | |
| .log .ok { color: var(--success); } | |
| .log .err { color: var(--error); } | |
| .log .info { color: var(--accent); } | |
| .log .warn { color: var(--warn); } | |
| .summary { | |
| display: none; | |
| margin-top: 20px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 24px; | |
| } | |
| .summary.show { display: block; } | |
| .summary h3 { margin: 0 0 4px 0; font-size: 16px; color: var(--text); } | |
| .summary p.lead { color: var(--muted); margin: 0 0 18px 0; font-size: 13px; } | |
| .summary-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, 1fr); | |
| gap: 12px; | |
| } | |
| .summary-item { | |
| background: var(--surface-2); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 14px; | |
| } | |
| .summary-item .lbl { | |
| font-size: 11px; color: var(--muted); | |
| text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; | |
| margin-bottom: 6px; | |
| } | |
| .summary-item .val { | |
| font-size: 14px; color: var(--text); font-weight: 500; | |
| } | |
| ::-webkit-scrollbar { width: 8px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #c8ccd4; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <header> | |
| <div class="brand"> | |
| <div class="brand-mark">V</div> | |
| <div> | |
| <div class="brand-title">Vidya</div> | |
| <div class="brand-sub">Onboarding Conversation</div> | |
| </div> | |
| </div> | |
| <div class="status"> | |
| <span class="dot" id="statusDot"></span> | |
| <span id="statusText">Disconnected</span> | |
| </div> | |
| </header> | |
| <div class="config"> | |
| <div class="field"> | |
| <label>User ID</label> | |
| <input id="userId" value="44898973" /> | |
| </div> | |
| <button class="btn" id="connectBtn">Start Session</button> | |
| </div> | |
| <div class="main"> | |
| <div> | |
| <div class="card"> | |
| <h2 class="card-title">Conversation</h2> | |
| <div class="stage"> | |
| <div class="orb" id="orb"> | |
| <span class="orb-icon" id="orbIcon">🎙️</span> | |
| </div> | |
| <div class="stage-label" id="stageLabel">Press Start Session to begin</div> | |
| </div> | |
| </div> | |
| <div class="card" style="margin-top: 20px;"> | |
| <h2 class="card-title">Transcript</h2> | |
| <div class="transcript" id="transcript"> | |
| <div class="empty-state">The conversation will appear here.</div> | |
| </div> | |
| </div> | |
| <div class="summary" id="summary"> | |
| <h3>Onboarding Complete</h3> | |
| <p class="lead">Your responses have been captured.</p> | |
| <div class="summary-grid" id="summaryGrid"></div> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="card"> | |
| <h2 class="card-title">Event Log</h2> | |
| <div class="log" id="log"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ==================================================================== | |
| // CONFIG | |
| // ==================================================================== | |
| const SEND_RATE = 16000; | |
| const RECV_RATE = 24000; | |
| const CHUNK_SIZE = 1024; | |
| const MODE = "onboarding"; | |
| const ENDPOINT = "ws://live-assistant-alb-401098210.ap-south-1.elb.amazonaws.com:80"; | |
| // ==================================================================== | |
| // STATE | |
| // ==================================================================== | |
| let ws = null; | |
| let audioCtx = null; | |
| let playbackCtx = null; | |
| let micStream = null; | |
| let micProcessor = null; | |
| let micSource = null; | |
| let nextPlayTime = 0; | |
| let isConnected = false; | |
| let isSpeaking = false; | |
| let speakingTimer = null; | |
| const state = { | |
| userBuf: "", | |
| aiBuf: "", | |
| userTurnEl: null, | |
| aiTurnEl: null, | |
| }; | |
| // ==================================================================== | |
| // HELPERS | |
| // ==================================================================== | |
| const $ = (id) => document.getElementById(id); | |
| function setStatus(text, cls = "") { | |
| $("statusText").textContent = text; | |
| const dot = $("statusDot"); | |
| dot.classList.remove("live", "err", "busy"); | |
| if (cls) dot.classList.add(cls); | |
| } | |
| function log(msg, cls = "") { | |
| const row = document.createElement("div"); | |
| row.className = "row"; | |
| const ts = new Date().toTimeString().slice(0, 8); | |
| row.innerHTML = `<span class="ts">${ts}</span><span class="${cls}">${escapeHtml(msg)}</span>`; | |
| const el = $("log"); | |
| el.appendChild(row); | |
| el.scrollTop = el.scrollHeight; | |
| } | |
| function escapeHtml(s) { | |
| return String(s).replace(/[&<>"']/g, (c) => ({ | |
| "&":"&","<":"<",">":">","\"":""","'":"'" | |
| }[c])); | |
| } | |
| function setOrbState(s) { | |
| const orb = $("orb"); | |
| const icon = $("orbIcon"); | |
| const label = $("stageLabel"); | |
| orb.classList.remove("speaking", "listening"); | |
| if (s === "speaking") { | |
| orb.classList.add("speaking"); | |
| icon.textContent = "🔊"; | |
| label.textContent = "Assistant is speaking…"; | |
| } else if (s === "listening") { | |
| orb.classList.add("listening"); | |
| icon.textContent = "🎤"; | |
| label.textContent = "Listening — speak naturally"; | |
| } else if (s === "idle") { | |
| icon.textContent = "🎙️"; | |
| label.textContent = "Ready"; | |
| } else { | |
| icon.textContent = "🎙️"; | |
| label.textContent = "Press Start Session to begin"; | |
| } | |
| } | |
| function appendTurn(who) { | |
| const tr = $("transcript"); | |
| const empty = tr.querySelector(".empty-state"); | |
| if (empty) empty.remove(); | |
| const div = document.createElement("div"); | |
| div.className = "turn " + (who === "user" ? "user" : "ai"); | |
| div.innerHTML = `<div class="who">${who === "user" ? "You" : "Assistant"}</div><div class="text"></div>`; | |
| tr.appendChild(div); | |
| tr.scrollTop = tr.scrollHeight; | |
| return div; | |
| } | |
| function updateTurnText(el, text) { | |
| if (el) el.querySelector(".text").textContent = text; | |
| $("transcript").scrollTop = $("transcript").scrollHeight; | |
| } | |
| // ==================================================================== | |
| // MIC | |
| // ==================================================================== | |
| async function startMic() { | |
| micStream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| channelCount: 1, | |
| echoCancellation: true, | |
| noiseSuppression: true, | |
| autoGainControl: true, | |
| }, | |
| }); | |
| audioCtx = new AudioContext({ sampleRate: SEND_RATE }); | |
| micSource = audioCtx.createMediaStreamSource(micStream); | |
| micProcessor = audioCtx.createScriptProcessor(CHUNK_SIZE, 1, 1); | |
| micProcessor.onaudioprocess = (e) => { | |
| if (!ws || ws.readyState !== WebSocket.OPEN) return; | |
| const float32 = e.inputBuffer.getChannelData(0); | |
| const pcm16 = floatToPCM16(float32); | |
| const b64 = bytesToBase64(new Uint8Array(pcm16.buffer)); | |
| ws.send(JSON.stringify({ type: "audio_chunk", audio: b64 })); | |
| }; | |
| micSource.connect(micProcessor); | |
| micProcessor.connect(audioCtx.destination); | |
| log("Microphone capturing @ 16kHz", "ok"); | |
| } | |
| function stopMic() { | |
| if (micProcessor) { micProcessor.disconnect(); micProcessor = null; } | |
| if (micSource) { micSource.disconnect(); micSource = null; } | |
| if (audioCtx) { audioCtx.close(); audioCtx = null; } | |
| if (micStream) { micStream.getTracks().forEach(t => t.stop()); micStream = null; } | |
| } | |
| function floatToPCM16(float32) { | |
| const out = new Int16Array(float32.length); | |
| for (let i = 0; i < float32.length; i++) { | |
| let s = Math.max(-1, Math.min(1, float32[i])); | |
| out[i] = s < 0 ? s * 0x8000 : s * 0x7fff; | |
| } | |
| return out; | |
| } | |
| function bytesToBase64(bytes) { | |
| let bin = ""; | |
| const chunk = 0x8000; | |
| for (let i = 0; i < bytes.length; i += chunk) { | |
| bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); | |
| } | |
| return btoa(bin); | |
| } | |
| function base64ToBytes(b64) { | |
| const bin = atob(b64); | |
| const out = new Uint8Array(bin.length); | |
| for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); | |
| return out; | |
| } | |
| // ==================================================================== | |
| // PLAYBACK | |
| // ==================================================================== | |
| function ensurePlaybackCtx() { | |
| if (!playbackCtx) { | |
| playbackCtx = new AudioContext({ sampleRate: RECV_RATE }); | |
| nextPlayTime = playbackCtx.currentTime; | |
| } | |
| } | |
| function playPCM(int16) { | |
| ensurePlaybackCtx(); | |
| const float32 = new Float32Array(int16.length); | |
| for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 0x8000; | |
| const buf = playbackCtx.createBuffer(1, float32.length, RECV_RATE); | |
| buf.copyToChannel(float32, 0); | |
| const src = playbackCtx.createBufferSource(); | |
| src.buffer = buf; | |
| src.connect(playbackCtx.destination); | |
| const now = playbackCtx.currentTime; | |
| const startAt = Math.max(now, nextPlayTime); | |
| src.start(startAt); | |
| nextPlayTime = startAt + buf.duration; | |
| if (!isSpeaking) { | |
| isSpeaking = true; | |
| setOrbState("speaking"); | |
| } | |
| if (speakingTimer) clearTimeout(speakingTimer); | |
| const remainingMs = Math.max(0, (nextPlayTime - now) * 1000); | |
| speakingTimer = setTimeout(() => { | |
| isSpeaking = false; | |
| if (isConnected) setOrbState("listening"); | |
| }, remainingMs + 120); | |
| } | |
| // ==================================================================== | |
| // WEBSOCKET | |
| // ==================================================================== | |
| async function connect() { | |
| const userId = $("userId").value.trim(); | |
| const url = `${ENDPOINT}/ws/${MODE}/${userId}`; | |
| log(`Connecting → ${url}`, "info"); | |
| setStatus("Connecting…", "busy"); | |
| try { | |
| ws = new WebSocket(url); | |
| } catch (e) { | |
| log("Failed to create WebSocket: " + e.message, "err"); | |
| setStatus("Error", "err"); | |
| return; | |
| } | |
| ws.binaryType = "arraybuffer"; | |
| ws.onopen = async () => { | |
| isConnected = true; | |
| setStatus("Connected", "live"); | |
| setOrbState("idle"); | |
| log("WebSocket connected", "ok"); | |
| $("connectBtn").textContent = "End Session"; | |
| $("connectBtn").classList.add("danger"); | |
| try { | |
| await startMic(); | |
| setOrbState("listening"); | |
| } catch (e) { | |
| log("Mic error: " + e.message, "err"); | |
| } | |
| }; | |
| ws.onmessage = (event) => { | |
| if (event.data instanceof ArrayBuffer) { | |
| const int16 = new Int16Array(event.data); | |
| playPCM(int16); | |
| return; | |
| } | |
| handleMessage(event.data); | |
| }; | |
| ws.onerror = () => { | |
| log("WebSocket error", "err"); | |
| setStatus("Error", "err"); | |
| }; | |
| ws.onclose = () => { | |
| isConnected = false; | |
| setStatus("Disconnected"); | |
| setOrbState(""); | |
| log("Connection closed", "info"); | |
| $("connectBtn").textContent = "Start Session"; | |
| $("connectBtn").classList.remove("danger"); | |
| stopMic(); | |
| }; | |
| } | |
| function disconnect() { | |
| if (ws) ws.close(); | |
| stopMic(); | |
| } | |
| function handleMessage(raw) { | |
| let data; | |
| try { data = JSON.parse(raw); } catch { return; } | |
| const t = data.type; | |
| const d = data.data; | |
| switch (t) { | |
| case "audio_chunk": { | |
| if (data.is_last_chunk) return; | |
| if (data.audio) { | |
| const bytes = base64ToBytes(data.audio); | |
| const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2)); | |
| playPCM(int16); | |
| } | |
| break; | |
| } | |
| case "user_transcript": { | |
| state.userBuf += d || ""; | |
| if (!state.userTurnEl) state.userTurnEl = appendTurn("user"); | |
| updateTurnText(state.userTurnEl, state.userBuf); | |
| break; | |
| } | |
| case "model_text": { | |
| state.aiBuf += d || ""; | |
| if (!state.aiTurnEl) state.aiTurnEl = appendTurn("ai"); | |
| updateTurnText(state.aiTurnEl, state.aiBuf); | |
| break; | |
| } | |
| case "turn_complete": { | |
| const hasUser = state.userBuf.trim() !== ""; | |
| const hasBot = state.aiBuf.trim() !== ""; | |
| if (hasUser && hasBot) { | |
| log("Turn captured", "ok"); | |
| } else if (hasBot) { | |
| log("Assistant prompt (no user input)", "info"); | |
| } else { | |
| log("Empty turn", "warn"); | |
| } | |
| state.userBuf = ""; | |
| state.aiBuf = ""; | |
| state.userTurnEl = null; | |
| state.aiTurnEl = null; | |
| break; | |
| } | |
| case "conversation_ended": { | |
| log("Conversation ended — generating summary…", "info"); | |
| $("stageLabel").textContent = "Generating summary…"; | |
| break; | |
| } | |
| case "summary_complete": { | |
| log("✅ Summary received", "ok"); | |
| const s = data.summary || {}; | |
| renderSummary(s); | |
| setOrbState(""); | |
| $("stageLabel").textContent = "Onboarding complete"; | |
| setTimeout(() => disconnect(), 1200); | |
| break; | |
| } | |
| case "summary_error": { | |
| log("❌ Summary error: " + (data.message || "unknown"), "err"); | |
| break; | |
| } | |
| case "onboarding_complete": { | |
| log("🎉 Onboarding finished", "ok"); | |
| setTimeout(() => disconnect(), 1500); | |
| break; | |
| } | |
| case "error": { | |
| log("Server error: " + (data.message || "unknown"), "err"); | |
| break; | |
| } | |
| } | |
| } | |
| function renderSummary(s) { | |
| const grid = $("summaryGrid"); | |
| grid.innerHTML = ""; | |
| const items = [ | |
| ["Primary Goal", s.learning_objectives?.primary_goal], | |
| ["Skill Level", s.background_skills?.level], | |
| ["Learning Style", s.learning_style?.preferred_format], | |
| ["Timeline", s.timeline?.goal_timeline], | |
| ]; | |
| for (const [lbl, val] of items) { | |
| const div = document.createElement("div"); | |
| div.className = "summary-item"; | |
| div.innerHTML = `<div class="lbl">${lbl}</div><div class="val">${escapeHtml(val || "—")}</div>`; | |
| grid.appendChild(div); | |
| } | |
| $("summary").classList.add("show"); | |
| } | |
| // ==================================================================== | |
| // WIRE UP | |
| // ==================================================================== | |
| $("connectBtn").addEventListener("click", () => { | |
| if (isConnected) disconnect(); | |
| else connect(); | |
| }); | |
| window.addEventListener("beforeunload", () => { | |
| if (ws) ws.close(); | |
| stopMic(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |