Spaces:
Sleeping
Sleeping
| const app = document.querySelector("#app"); | |
| const toast = document.querySelector("#toast"); | |
| const API = "/api/v1"; | |
| function showToast(message) { | |
| toast.textContent = message; | |
| toast.classList.add("show"); | |
| clearTimeout(showToast.timer); | |
| showToast.timer = setTimeout(() => toast.classList.remove("show"), 3400); | |
| } | |
| function escapeHtml(value = "") { | |
| return String(value).replace(/[&<>"']/g, (char) => ({ | |
| "&": "&", "<": "<", ">": ">", '"': """, "'": "'", | |
| })[char]); | |
| } | |
| function lessonId() { | |
| const match = location.pathname.match(/^\/lesson\/([^/]+)/); | |
| return match && match[1] !== "new" ? match[1] : null; | |
| } | |
| async function fetchLesson(id) { | |
| const response = await fetch(`${API}/sessions/${id}`); | |
| if (!response.ok) throw new Error("Lesson not found"); | |
| return response.json(); | |
| } | |
| function setActiveNavigation() { | |
| document.querySelectorAll("[data-nav]").forEach((link) => { | |
| link.classList.toggle("active", link.dataset.nav === location.pathname); | |
| }); | |
| } | |
| function landingPage() { | |
| return ` | |
| <section class="page"> | |
| <div class="hero"> | |
| <div class="hero-copy"> | |
| <span class="eyebrow">Meet your adaptive AI teacher</span> | |
| <h1>Learn anything.<br><span class="gradient-text">Understand it deeply.</span></h1> | |
| <p class="lead">Gargi listens, explains, draws and adapts in real time—turning every question into a lesson made just for you.</p> | |
| <div class="hero-actions"> | |
| <a class="btn btn-primary" href="/lesson/new">Start a lesson <span>→</span></a> | |
| <a class="btn btn-secondary" href="#how-it-works">See how it works <span>↓</span></a> | |
| </div> | |
| <div class="trust-row"> | |
| <div class="avatar-stack"><span>AI</span><span>STEM</span><span>ART</span></div> | |
| <span>From quantum physics to poetry—ask freely.</span> | |
| </div> | |
| </div> | |
| <div class="hero-visual"> | |
| <div class="logo-stage"><img src="/static/logo-mark.svg" alt="Gargi AI owl"></div> | |
| <div class="float-card float-one"><i>✦</i><span>Adapts to your level</span></div> | |
| <div class="float-card float-two"><i>◉</i><span>Natural voice lessons</span></div> | |
| </div> | |
| </div> | |
| <div id="how-it-works" class="section-heading"> | |
| <div><span class="eyebrow">One classroom, many ways to learn</span><h2>Teaching that meets you<br>where you are.</h2></div> | |
| <p>Speak naturally, watch ideas take shape on the whiteboard, and check your understanding without leaving the lesson.</p> | |
| </div> | |
| <div class="feature-grid"> | |
| <article class="feature-card"><div class="feature-icon">◉</div><h3>Talk it through</h3><p>Ask questions in your own words. Gemini Live listens, understands and responds with a natural teaching voice.</p></article> | |
| <article class="feature-card"><div class="feature-icon">✦</div><h3>See the idea</h3><p>Gargi turns explanations into diagrams, equations and step-by-step whiteboard notes.</p></article> | |
| <article class="feature-card"><div class="feature-icon">✓</div><h3>Make it stick</h3><p>Quick adaptive quizzes uncover misconceptions and shape the next explanation around you.</p></article> | |
| </div> | |
| </section>`; | |
| } | |
| function setupPage() { | |
| return ` | |
| <section class="page"> | |
| <div class="page-intro"> | |
| <span class="eyebrow">Your next lesson starts here</span> | |
| <h1>What are you <span class="gradient-text">curious about?</span></h1> | |
| <p class="lead">Give Gargi a topic and a little context. Your classroom will be ready in seconds.</p> | |
| </div> | |
| <div class="setup-shell"> | |
| <form id="lesson-form" class="setup-card"> | |
| <div class="form-grid"> | |
| <div class="form-field full"> | |
| <label for="topic">What would you like to learn?</label> | |
| <input id="topic" name="topic" required minlength="2" maxlength="200" placeholder="e.g. Why do planets stay in orbit?"> | |
| <div class="suggestions"> | |
| <button class="suggestion" type="button" data-topic="How black holes bend space and time">Black holes</button> | |
| <button class="suggestion" type="button" data-topic="How photosynthesis helps plants grow">Photosynthesis</button> | |
| <button class="suggestion" type="button" data-topic="The basics of probability">Probability</button> | |
| </div> | |
| </div> | |
| <div class="form-field"> | |
| <label for="level">My current level</label> | |
| <select id="level" name="learner_level"> | |
| <option value="beginner">Beginner - start from scratch</option> | |
| <option value="intermediate">Intermediate - I know the basics</option> | |
| <option value="advanced">Advanced - challenge me</option> | |
| </select> | |
| </div> | |
| <div class="form-field"> | |
| <label for="language">Lesson language</label> | |
| <select id="language" name="language"> | |
| <option value="en-US">English</option> | |
| <option value="hi-IN">Hindi</option> | |
| </select> | |
| </div> | |
| <div class="form-field full"> | |
| <label for="objective">What do you want to understand? <span class="optional">(optional)</span></label> | |
| <textarea id="objective" name="objective" maxlength="500" placeholder="Tell Gargi what success looks like for this lesson..."></textarea> | |
| </div> | |
| </div> | |
| <div class="setup-footer"> | |
| <p class="setup-note">No perfect prompt needed. Start broad and ask follow-up questions naturally inside the classroom.</p> | |
| <button class="btn btn-primary" type="submit">Create my classroom <span>→</span></button> | |
| </div> | |
| </form> | |
| </div> | |
| </section>`; | |
| } | |
| function renderBoard(blocks = []) { | |
| if (!blocks.length) { | |
| return `<div class="empty-board"><div class="sketch">✦</div><h3>Your ideas will appear here</h3><p>Start the conversation and Gargi will build diagrams, equations and notes around your questions.</p></div>`; | |
| } | |
| return blocks.map((block) => { | |
| if (block.type === "bullets") { | |
| return `<div class="board-block"><ul>${block.content.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul></div>`; | |
| } | |
| if (block.type === "mermaid") { | |
| return `<div class="board-block diagram"><span>Concept diagram</span>${renderMermaidDiagram(block.content)}</div>`; | |
| } | |
| if (block.type === "equation") { | |
| return renderEquation(block.content); | |
| } | |
| return `<div class="board-block ${block.type}">${escapeHtml(block.content)}</div>`; | |
| }).join(""); | |
| } | |
| function readLatexGroup(source, start) { | |
| if (source[start] !== "{") return null; | |
| let depth = 0; | |
| for (let index = start; index < source.length; index++) { | |
| const char = source[index]; | |
| if (char === "{" && source[index - 1] !== "\\") depth++; | |
| if (char === "}" && source[index - 1] !== "\\") depth--; | |
| if (depth === 0) return {content: source.slice(start + 1, index), end: index + 1}; | |
| } | |
| return null; | |
| } | |
| function findLatexFraction(source) { | |
| const start = source.indexOf("\\frac"); | |
| if (start < 0) return null; | |
| let cursor = start + "\\frac".length; | |
| while (/\s/.test(source[cursor])) cursor++; | |
| const numerator = readLatexGroup(source, cursor); | |
| if (!numerator) return null; | |
| cursor = numerator.end; | |
| while (/\s/.test(source[cursor])) cursor++; | |
| const denominator = readLatexGroup(source, cursor); | |
| if (!denominator) return null; | |
| return {start, end: denominator.end, numerator: numerator.content, denominator: denominator.content}; | |
| } | |
| function formatLatexInline(source = "") { | |
| let output = String(source).trim().replace(/^\$|\$$/g, ""); | |
| let textMatch = output.match(/\\text\s*\{/); | |
| while (textMatch) { | |
| const groupStart = textMatch.index + textMatch[0].lastIndexOf("{"); | |
| const group = readLatexGroup(output, groupStart); | |
| if (!group) break; | |
| output = `${output.slice(0, textMatch.index)}${group.content}${output.slice(group.end)}`; | |
| textMatch = output.match(/\\text\s*\{/); | |
| } | |
| output = output | |
| .replace(/\\left|\\right/g, "") | |
| .replace(/\\times/g, "×") | |
| .replace(/\\cdot/g, "·") | |
| .replace(/\\leq?/g, "≤") | |
| .replace(/\\geq?/g, "≥") | |
| .replace(/\\neq/g, "≠") | |
| .replace(/\\approx/g, "≈") | |
| .replace(/\\pi/g, "π") | |
| .replace(/\\%/g, "%") | |
| .replace(/\s+/g, " "); | |
| return escapeHtml(output); | |
| } | |
| function renderEquation(source = "") { | |
| const equation = String(source).trim(); | |
| const fraction = findLatexFraction(equation); | |
| if (!fraction) { | |
| return `<div class="board-block equation"><div class="equation-card"><span class="eq-inline">${formatLatexInline(equation)}</span></div></div>`; | |
| } | |
| const before = equation.slice(0, fraction.start).trim(); | |
| const after = equation.slice(fraction.end).trim(); | |
| return ` | |
| <div class="board-block equation"> | |
| <div class="equation-card"> | |
| ${before ? `<span class="eq-prefix">${formatLatexInline(before)}</span>` : ""} | |
| <span class="eq-fraction" aria-label="${formatLatexInline(fraction.numerator)} over ${formatLatexInline(fraction.denominator)}"> | |
| <span class="eq-top">${formatLatexInline(fraction.numerator)}</span> | |
| <span class="eq-bottom">${formatLatexInline(fraction.denominator)}</span> | |
| </span> | |
| ${after ? `<span class="eq-suffix">${formatLatexInline(after)}</span>` : ""} | |
| </div> | |
| </div>`; | |
| } | |
| function parseMermaidNode(raw = "") { | |
| const trimmed = raw.trim(); | |
| const match = trimmed.match(/^([A-Za-z0-9_-]+)\s*(?:\[\s*([^\]]+?)\s*\]|\(\(\s*([^)]+?)\s*\)\)|\(\s*([^)]+?)\s*\)|\{\s*([^}]+?)\s*\})?$/); | |
| if (!match) return {id: trimmed, label: trimmed.replace(/_/g, " ")}; | |
| const [, id, square, circle, round, brace] = match; | |
| return {id, label: square || circle || round || brace || id.replace(/_/g, " ")}; | |
| } | |
| function renderMermaidDiagram(source = "") { | |
| const lines = String(source) | |
| .split(/\n|;/) | |
| .map((line) => line.trim()) | |
| .filter(Boolean) | |
| .filter((line) => !/^(graph|flowchart)\s+/i.test(line)); | |
| const edges = lines.map((line) => { | |
| const match = line.match(/^(.+?)\s*[-.=]+>\s*(?:\|(.+?)\|\s*)?(.+)$/) || line.match(/^(.+?)\s*--\s*(?:\|(.+?)\|\s*)?(.+)$/); | |
| if (!match) return null; | |
| const from = parseMermaidNode(match[1]); | |
| const label = match[2] || ""; | |
| const to = parseMermaidNode(match[3]); | |
| return {from, label, to}; | |
| }).filter(Boolean); | |
| if (!edges.length) return `<pre>${escapeHtml(source)}</pre>`; | |
| return `<div class="mermaid-flow">${edges.map((edge) => ` | |
| <div class="flow-row"> | |
| <div class="flow-node">${escapeHtml(edge.from.label)}</div> | |
| <div class="flow-link">${edge.label ? `<small>${escapeHtml(edge.label)}</small>` : ""}<b></b></div> | |
| <div class="flow-node">${escapeHtml(edge.to.label)}</div> | |
| </div>`).join("")}</div>`; | |
| } | |
| function classroomPage(lesson) { | |
| const artifact = lesson.artifacts.at(-1); | |
| const messages = lesson.messages.length | |
| ? lesson.messages.map((message) => `<div class="message ${message.role}"><strong>${message.role === "student" ? "You" : "Gargi"}</strong><span>${escapeHtml(message.text)}</span></div>`).join("") | |
| : `<div class="transcript-empty">Your conversation will appear here.<br>Use the microphone or type a question below.</div>`; | |
| return ` | |
| <section class="page classroom-page"> | |
| <div class="lesson-bar"> | |
| <div class="lesson-title"> | |
| <div class="mini-mark"><img src="/static/logo-mark.svg" width="30" alt=""></div> | |
| <div><h1>${escapeHtml(lesson.topic)}</h1><p>${escapeHtml(lesson.learner_level)} · ${escapeHtml(lesson.language)} · Adaptive lesson</p></div> | |
| </div> | |
| <div class="lesson-actions"> | |
| <span id="live-status" class="status-pill connecting">Connecting</span> | |
| <a id="quiz-link" class="btn btn-secondary ${artifact ? "" : "hidden"}" href="#quiz">Take quiz</a> | |
| <a class="btn btn-ghost" href="/lesson/${lesson.id}/summary">End lesson</a> | |
| </div> | |
| </div> | |
| <div class="classroom-grid"> | |
| <section id="teacher-panel" class="class-panel teacher-panel"> | |
| <div class="teacher-top"><span>GARGI · LIVE TEACHER</span><span>Gemini Live</span></div> | |
| <div id="avatar-mount" class="avatar-placeholder" aria-label="Live2D avatar mount"> | |
| <div class="voice-rings"><i></i><i></i><i></i></div> | |
| <canvas id="avatar-canvas" aria-hidden="true"></canvas> | |
| <img id="avatar-fallback" src="/static/logo-mark.svg" alt="Gargi avatar placeholder"> | |
| <span id="avatar-label">Loading Haru Live2D...</span> | |
| </div> | |
| <div class="voice-control"> | |
| <button id="mic-button" class="mic-button" aria-label="Start voice question"><span class="mic-icon">◉</span></button> | |
| <p id="mic-label">Tap to ask Gargi a question</p> | |
| <small>Tap again when you finish speaking</small> | |
| </div> | |
| </section> | |
| <section class="class-panel whiteboard-panel"> | |
| <div class="panel-head"><h2>Interactive whiteboard</h2><span id="board-status">Updates after each answer</span></div> | |
| <div id="whiteboard" class="whiteboard-content">${renderBoard(artifact?.whiteboard_blocks)}</div> | |
| <script id="classroom-quiz-data" type="application/json">${JSON.stringify(artifact?.quiz || []).replace(/</g, "\\u003c")}</script> | |
| <script id="classroom-board-data" type="application/json">${JSON.stringify(artifact?.whiteboard_blocks || []).replace(/</g, "\\u003c")}</script> | |
| </section> | |
| <aside class="class-panel side-panel"> | |
| <div class="panel-head"><h2>Conversation</h2><span id="message-count">${lesson.messages.length} messages</span></div> | |
| <div id="transcript" class="transcript">${messages}</div> | |
| <div class="ask-box"> | |
| <form id="ask-form"> | |
| <input id="ask-input" maxlength="4000" placeholder="Type a question..." autocomplete="off"> | |
| <button class="send-btn" aria-label="Send question">➤</button> | |
| </form> | |
| </div> | |
| </aside> | |
| </div> | |
| </section>`; | |
| } | |
| function emptyState(title, copy) { | |
| return `<section class="page"><div class="surface empty-state"><img src="/static/logo-mark.svg" alt=""><h2>${title}</h2><p class="lead empty-copy">${copy}</p><a class="btn btn-primary" href="/lesson/new">Start a new lesson</a></div></section>`; | |
| } | |
| function quizPage(lesson) { | |
| const quiz = lesson.artifacts.at(-1)?.quiz || []; | |
| if (!quiz.length) return emptyState("No quiz yet", "Ask Gargi a question first. Your adaptive quiz appears after the explanation."); | |
| return ` | |
| <section class="page quiz-shell"> | |
| <div class="page-intro"><span class="eyebrow">Knowledge check</span><h1>Let us make it <span class="gradient-text">stick.</span></h1><p class="lead">Three quick questions based on your latest conversation.</p></div> | |
| <div class="progress-row"><span id="progress-label"></span><div class="progress-track"><div id="progress-fill" class="progress-fill"></div></div><span id="score-label">0 correct</span></div> | |
| <div id="quiz-card" class="surface quiz-card"></div> | |
| <script id="quiz-data" type="application/json">${JSON.stringify(quiz).replace(/</g, "\\u003c")}</script> | |
| </section>`; | |
| } | |
| function summaryPage(lesson) { | |
| const latest = lesson.artifacts.at(-1); | |
| const score = lesson.quiz_attempts.length ? Math.round(lesson.quiz_attempts.at(-1).score * 100) : 0; | |
| return ` | |
| <section class="page summary-shell"> | |
| <div class="summary-hero"> | |
| <span class="eyebrow summary-eyebrow">Lesson snapshot</span> | |
| <h1>Curiosity looks good on you.</h1> | |
| <p>${escapeHtml(lesson.topic)} · ${Math.floor(lesson.messages.length / 2)} conversation turns</p> | |
| <div class="hero-actions"><a class="btn btn-secondary" href="/lesson/${lesson.id}">Continue lesson</a><a class="btn summary-new" href="/lesson/new">Learn something new</a></div> | |
| </div> | |
| <div class="summary-grid"> | |
| <article class="surface summary-card"> | |
| <h3>What you explored</h3> | |
| <p>${escapeHtml(latest?.summary || lesson.objective || "Start a conversation to build your lesson summary.")}</p> | |
| <div class="stat-row"> | |
| <div class="stat"><strong>${Math.floor(lesson.messages.length / 2)}</strong><span>Questions</span></div> | |
| <div class="stat"><strong>${lesson.artifacts.length}</strong><span>Explanations</span></div> | |
| <div class="stat"><strong>${score}%</strong><span>Quiz score</span></div> | |
| </div> | |
| </article> | |
| <article class="surface summary-card"> | |
| <h3>Understanding improved</h3> | |
| ${lesson.misconceptions.length ? `<ul>${lesson.misconceptions.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "<p>Gargi will track and clarify misconceptions as your lesson develops.</p>"} | |
| </article> | |
| <article class="surface summary-card"> | |
| <h3>Suggested next step</h3> | |
| <p>${escapeHtml(latest?.suggested_follow_up || "Continue the lesson and ask for a practical example.")}</p> | |
| <a class="btn btn-secondary" href="/lesson/${lesson.id}">Keep exploring →</a> | |
| </article> | |
| <article class="surface summary-card"> | |
| <h3>Lesson details</h3> | |
| <p><strong>Level:</strong> ${escapeHtml(lesson.learner_level)}<br><strong>Language:</strong> ${escapeHtml(lesson.language)}<br><strong>Status:</strong> ${escapeHtml(lesson.status)}</p> | |
| ${latest ? `<a class="btn btn-secondary" href="/lesson/${lesson.id}/quiz">Review quiz →</a>` : ""} | |
| </article> | |
| </div> | |
| </section>`; | |
| } | |
| async function render() { | |
| setActiveNavigation(); | |
| try { | |
| if (location.pathname === "/") app.innerHTML = landingPage(); | |
| else if (location.pathname === "/lesson/new") app.innerHTML = setupPage(); | |
| else { | |
| const lesson = await fetchLesson(lessonId()); | |
| if (location.pathname.endsWith("/quiz")) app.innerHTML = quizPage(lesson); | |
| else if (location.pathname.endsWith("/summary")) app.innerHTML = summaryPage(lesson); | |
| else app.innerHTML = classroomPage(lesson); | |
| } | |
| bindPage(); | |
| } catch { | |
| app.innerHTML = emptyState("We could not find that classroom", "The lesson may have ended or the link is no longer available."); | |
| } | |
| } | |
| function bindPage() { | |
| document.querySelectorAll("[data-topic]").forEach((button) => { | |
| button.addEventListener("click", () => { document.querySelector("#topic").value = button.dataset.topic; }); | |
| }); | |
| document.querySelector("#lesson-form")?.addEventListener("submit", async (event) => { | |
| event.preventDefault(); | |
| const button = event.submitter; | |
| button.disabled = true; | |
| button.textContent = "Preparing classroom..."; | |
| try { | |
| const response = await fetch(`${API}/sessions`, { | |
| method: "POST", | |
| headers: {"Content-Type": "application/json"}, | |
| body: JSON.stringify(Object.fromEntries(new FormData(event.currentTarget))), | |
| }); | |
| if (!response.ok) throw new Error("Could not create the lesson."); | |
| location.href = `/lesson/${(await response.json()).id}`; | |
| } catch (error) { | |
| showToast(error.message); | |
| button.disabled = false; | |
| button.innerHTML = "Create my classroom <span>→</span>"; | |
| } | |
| }); | |
| const quizData = document.querySelector("#quiz-data"); | |
| if (quizData) initializeQuiz(JSON.parse(quizData.textContent)); | |
| initializeClassroom(); | |
| } | |
| function initializeQuiz(quiz) { | |
| let index = 0; | |
| let correct = 0; | |
| const id = lessonId(); | |
| const card = document.querySelector("#quiz-card"); | |
| function showQuestion() { | |
| const question = quiz[index]; | |
| const answers = question.type === "multiple_choice" | |
| ? question.options.map((option) => `<label class="answer-option"><input type="radio" name="answer" value="${escapeHtml(option)}"><span>${escapeHtml(option)}</span></label>`).join("") | |
| : `<div class="form-field"><textarea id="short-answer" placeholder="Explain in your own words..."></textarea></div>`; | |
| card.innerHTML = `<span class="question-number">Question ${index + 1}</span><h2 class="question-title">${escapeHtml(question.question)}</h2><div class="answer-list">${answers}</div><div class="quiz-actions"><a class="btn btn-ghost" href="/lesson/${id}">Back to class</a><button id="submit-answer" class="btn btn-primary">Check answer</button></div>`; | |
| document.querySelector("#progress-label").textContent = `Question ${index + 1} of ${quiz.length}`; | |
| document.querySelector("#progress-fill").style.width = `${((index + 1) / quiz.length) * 100}%`; | |
| document.querySelectorAll(".answer-option").forEach((option) => option.addEventListener("click", () => { | |
| document.querySelectorAll(".answer-option").forEach((item) => item.classList.remove("selected")); | |
| option.classList.add("selected"); | |
| })); | |
| document.querySelector("#submit-answer").addEventListener("click", async () => { | |
| const answer = question.type === "multiple_choice" | |
| ? document.querySelector('input[name="answer"]:checked')?.value | |
| : document.querySelector("#short-answer")?.value.trim(); | |
| if (!answer) return showToast("Choose or write an answer first."); | |
| const response = await fetch(`${API}/sessions/${id}/quiz/${question.id}/answer`, { | |
| method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({answer}), | |
| }); | |
| if (!response.ok) return showToast("The answer could not be checked."); | |
| const result = await response.json(); | |
| if (result.is_correct) correct++; | |
| document.querySelector("#score-label").textContent = `${correct} correct`; | |
| card.innerHTML = `<span class="question-number">${result.is_correct ? "Nicely done" : "Let us clarify it"}</span><h2 class="question-title">${result.is_correct ? "That is correct." : `Expected answer: ${escapeHtml(result.expected_answer)}`}</h2><p class="lead">${escapeHtml(result.explanation)}</p><div class="quiz-actions"><span></span><button id="next-question" class="btn btn-primary">${index === quiz.length - 1 ? "View lesson summary" : "Next question"} →</button></div>`; | |
| document.querySelector("#next-question").addEventListener("click", () => { | |
| if (index === quiz.length - 1) location.href = `/lesson/${id}/summary`; | |
| else { index++; showQuestion(); } | |
| }); | |
| }); | |
| } | |
| showQuestion(); | |
| } | |
| function initializeClassroom() { | |
| const form = document.querySelector("#ask-form"); | |
| if (!form) return; | |
| const id = lessonId(); | |
| const panel = document.querySelector("#teacher-panel"); | |
| const status = document.querySelector("#live-status"); | |
| const transcript = document.querySelector("#transcript"); | |
| const board = document.querySelector("#whiteboard"); | |
| const boardStatus = document.querySelector("#board-status"); | |
| const quizLink = document.querySelector("#quiz-link"); | |
| const micButton = document.querySelector("#mic-button"); | |
| const micLabel = document.querySelector("#mic-label"); | |
| const messageCount = document.querySelector("#message-count"); | |
| const avatarMount = document.querySelector("#avatar-mount"); | |
| const avatarCanvas = document.querySelector("#avatar-canvas"); | |
| const avatarFallback = document.querySelector("#avatar-fallback"); | |
| const avatarLabel = document.querySelector("#avatar-label"); | |
| const protocol = location.protocol === "https:" ? "wss:" : "ws:"; | |
| let socket; | |
| let socketReady = false; | |
| let recording = false; | |
| let mediaStream; | |
| let captureContext; | |
| let micNode; | |
| let micSource; | |
| let playbackContext; | |
| let nextPlayTime = 0; | |
| let activeSources = 0; | |
| let playbackSources = new Set(); | |
| let startingMicrophone = false; | |
| let pendingTyped = ""; | |
| let currentQuiz = JSON.parse(document.querySelector("#classroom-quiz-data")?.textContent || "[]"); | |
| let currentBoardBlocks = JSON.parse(document.querySelector("#classroom-board-data")?.textContent || "[]"); | |
| let heardLiveAudioThisTurn = false; | |
| let fallbackSpeechText = ""; | |
| let fallbackSpeechBuffer = ""; | |
| let fallbackSpeechTimer; | |
| let fallbackLipSyncTimer; | |
| let live2dApp; | |
| let live2dModel; | |
| function setState(name, label) { | |
| panel.dataset.state = name; | |
| status.className = `status-pill ${name}`; | |
| status.textContent = label; | |
| micLabel.textContent = name === "listening" ? "Listening... tap when finished" : label; | |
| } | |
| function updateCount() { | |
| const count = transcript.querySelectorAll(".message").length; | |
| messageCount.textContent = `${count} message${count === 1 ? "" : "s"}`; | |
| } | |
| function appendMessage(role, text, merge = false) { | |
| transcript.querySelector(".transcript-empty")?.remove(); | |
| const last = transcript.lastElementChild; | |
| if (merge && last?.classList.contains(role)) { | |
| last.querySelector("span").textContent += text; | |
| } else { | |
| transcript.insertAdjacentHTML("beforeend", `<div class="message ${role}"><strong>${role === "student" ? "You" : "Gargi"}</strong><span>${escapeHtml(text)}</span></div>`); | |
| } | |
| transcript.scrollTop = transcript.scrollHeight; | |
| updateCount(); | |
| } | |
| function showWhiteboard() { | |
| board.innerHTML = renderBoard(currentBoardBlocks); | |
| boardStatus.textContent = currentBoardBlocks.length ? "Whiteboard view" : "Updates after each answer"; | |
| quizLink.textContent = "Take quiz"; | |
| quizLink.href = "#quiz"; | |
| } | |
| function showInlineQuiz() { | |
| if (!currentQuiz.length) return showToast("Ask Gargi a question first. Your quiz appears after the explanation."); | |
| let index = 0; | |
| let correct = 0; | |
| boardStatus.textContent = "Quiz in whiteboard"; | |
| quizLink.textContent = "Whiteboard"; | |
| quizLink.href = "#whiteboard"; | |
| board.innerHTML = ` | |
| <div class="inline-quiz"> | |
| <div class="progress-row"><span id="inline-progress-label"></span><div class="progress-track"><div id="inline-progress-fill" class="progress-fill"></div></div><span id="inline-score-label">0 correct</span></div> | |
| <div id="inline-quiz-card" class="surface quiz-card"></div> | |
| </div>`; | |
| const card = board.querySelector("#inline-quiz-card"); | |
| function showQuestion() { | |
| const question = currentQuiz[index]; | |
| const answers = question.type === "multiple_choice" | |
| ? question.options.map((option) => `<label class="answer-option"><input type="radio" name="inline-answer" value="${escapeHtml(option)}"><span>${escapeHtml(option)}</span></label>`).join("") | |
| : `<div class="form-field"><textarea id="inline-short-answer" placeholder="Explain in your own words..."></textarea></div>`; | |
| card.innerHTML = `<span class="question-number">Question ${index + 1}</span><h2 class="question-title">${escapeHtml(question.question)}</h2><div class="answer-list">${answers}</div><div class="quiz-actions"><button id="inline-back-board" class="btn btn-ghost" type="button">Back to whiteboard</button><button id="inline-submit-answer" class="btn btn-primary" type="button">Check answer</button></div>`; | |
| board.querySelector("#inline-progress-label").textContent = `Question ${index + 1} of ${currentQuiz.length}`; | |
| board.querySelector("#inline-progress-fill").style.width = `${((index + 1) / currentQuiz.length) * 100}%`; | |
| board.querySelectorAll(".answer-option").forEach((option) => option.addEventListener("click", () => { | |
| board.querySelectorAll(".answer-option").forEach((item) => item.classList.remove("selected")); | |
| option.classList.add("selected"); | |
| })); | |
| board.querySelector("#inline-back-board").addEventListener("click", showWhiteboard); | |
| board.querySelector("#inline-submit-answer").addEventListener("click", async () => { | |
| const answer = question.type === "multiple_choice" | |
| ? board.querySelector('input[name="inline-answer"]:checked')?.value | |
| : board.querySelector("#inline-short-answer")?.value.trim(); | |
| if (!answer) return showToast("Choose or write an answer first."); | |
| const response = await fetch(`${API}/sessions/${id}/quiz/${question.id}/answer`, { | |
| method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({answer}), | |
| }); | |
| if (!response.ok) return showToast("The answer could not be checked."); | |
| const result = await response.json(); | |
| if (result.is_correct) correct++; | |
| board.querySelector("#inline-score-label").textContent = `${correct} correct`; | |
| card.innerHTML = `<span class="question-number">${result.is_correct ? "Nicely done" : "Let us clarify it"}</span><h2 class="question-title">${result.is_correct ? "That is correct." : `Expected answer: ${escapeHtml(result.expected_answer)}`}</h2><p class="lead">${escapeHtml(result.explanation)}</p><div class="quiz-actions"><button id="inline-review-board" class="btn btn-ghost" type="button">Review whiteboard</button><button id="inline-next-question" class="btn btn-primary" type="button">${index === currentQuiz.length - 1 ? "Finish quiz" : "Next question"} →</button></div>`; | |
| board.querySelector("#inline-review-board").addEventListener("click", showWhiteboard); | |
| board.querySelector("#inline-next-question").addEventListener("click", () => { | |
| if (index === currentQuiz.length - 1) { | |
| showToast(`Quiz complete: ${correct}/${currentQuiz.length} correct.`); | |
| showWhiteboard(); | |
| } else { | |
| index++; | |
| showQuestion(); | |
| } | |
| }); | |
| }); | |
| } | |
| showQuestion(); | |
| } | |
| function setAvatarMouth(level = 0) { | |
| if (!live2dModel?.internalModel?.coreModel) return; | |
| try { | |
| const coreModel = live2dModel.internalModel.coreModel; | |
| const value = Math.max(0, Math.min(1, level)); | |
| if (typeof coreModel.setParameterValueById === "function") { | |
| coreModel.setParameterValueById("ParamMouthOpenY", value); | |
| } | |
| } catch {} | |
| } | |
| function stopFallbackLipSync() { | |
| clearInterval(fallbackLipSyncTimer); | |
| fallbackLipSyncTimer = null; | |
| panel.style.setProperty("--talk-level", "0"); | |
| setAvatarMouth(0); | |
| } | |
| function startFallbackLipSync() { | |
| clearInterval(fallbackLipSyncTimer); | |
| fallbackLipSyncTimer = setInterval(() => { | |
| const level = 0.18 + Math.random() * 0.62; | |
| panel.style.setProperty("--talk-level", level.toFixed(2)); | |
| setAvatarMouth(level); | |
| }, 90); | |
| } | |
| async function initializeLive2DAvatar() { | |
| if (!avatarCanvas || !avatarMount || !window.PIXI?.live2d?.Live2DModel) { | |
| if (avatarLabel) avatarLabel.textContent = "Live2D runtime unavailable"; | |
| return; | |
| } | |
| try { | |
| live2dApp = new PIXI.Application({ | |
| view: avatarCanvas, | |
| resizeTo: avatarMount, | |
| backgroundAlpha: 0, | |
| antialias: true, | |
| autoDensity: true, | |
| }); | |
| live2dModel = await PIXI.live2d.Live2DModel.from("/static/vendor/live2d/haru/haru_greeter_t03.model3.json", { | |
| autoInteract: true, | |
| }); | |
| live2dModel.anchor.set(0.5, 0.52); | |
| live2dApp.stage.addChild(live2dModel); | |
| const fitModel = () => { | |
| const width = live2dApp.renderer.width; | |
| const height = live2dApp.renderer.height; | |
| if (!width || !height) return; | |
| live2dModel.scale.set(1); | |
| live2dModel.position.set(width / 2, height * 0.78); | |
| const scale = Math.min(width / live2dModel.width, height / live2dModel.height) * 1.68; | |
| live2dModel.scale.set(scale); | |
| }; | |
| fitModel(); | |
| window.addEventListener("resize", fitModel); | |
| avatarFallback?.classList.add("hidden"); | |
| avatarMount.classList.add("live2d-ready"); | |
| avatarLabel.textContent = "Haru Live2D teacher ready"; | |
| live2dModel.on("hit", () => live2dModel.motion("Tap")); | |
| } catch (error) { | |
| console.warn("Live2D avatar failed to load", error); | |
| avatarLabel.textContent = "Live2D fallback active"; | |
| } | |
| } | |
| async function ensurePlaybackContext() { | |
| playbackContext ||= new AudioContext({sampleRate: 24000}); | |
| if (playbackContext.state === "suspended") await playbackContext.resume(); | |
| } | |
| async function playPcm(buffer) { | |
| await ensurePlaybackContext(); | |
| heardLiveAudioThisTurn = true; | |
| window.speechSynthesis?.cancel(); | |
| clearTimeout(fallbackSpeechTimer); | |
| fallbackSpeechBuffer = ""; | |
| stopFallbackLipSync(); | |
| const pcm = new Int16Array(buffer); | |
| const samples = new Float32Array(pcm.length); | |
| let sum = 0; | |
| for (let index = 0; index < pcm.length; index++) { | |
| samples[index] = pcm[index] / 32768; | |
| sum += samples[index] * samples[index]; | |
| } | |
| const rms = Math.sqrt(sum / Math.max(samples.length, 1)); | |
| const talkLevel = Math.min(1, rms * 8); | |
| panel.style.setProperty("--talk-level", talkLevel.toFixed(2)); | |
| setAvatarMouth(talkLevel); | |
| const audio = playbackContext.createBuffer(1, samples.length, 24000); | |
| audio.copyToChannel(samples, 0); | |
| const source = playbackContext.createBufferSource(); | |
| source.buffer = audio; | |
| source.connect(playbackContext.destination); | |
| nextPlayTime = Math.max(nextPlayTime, playbackContext.currentTime + 0.04); | |
| source.start(nextPlayTime); | |
| nextPlayTime += audio.duration; | |
| playbackSources.add(source); | |
| activeSources = playbackSources.size; | |
| setState("speaking", "Gargi is speaking"); | |
| source.onended = () => { | |
| playbackSources.delete(source); | |
| activeSources = playbackSources.size; | |
| if (activeSources <= 0 && !recording) { | |
| panel.style.setProperty("--talk-level", "0"); | |
| setAvatarMouth(0); | |
| setState("ready", "Gargi is ready"); | |
| } | |
| }; | |
| } | |
| async function startMicrophone() { | |
| if (startingMicrophone || recording) return; | |
| if (!socketReady) return showToast("The live classroom is still connecting."); | |
| if (!navigator.mediaDevices?.getUserMedia) return showToast("This browser does not support microphone capture."); | |
| startingMicrophone = true; | |
| try { | |
| await ensurePlaybackContext(); | |
| stopPlayback(false); | |
| mediaStream = await navigator.mediaDevices.getUserMedia({ | |
| audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true}, | |
| }); | |
| captureContext = new AudioContext(); | |
| await captureContext.audioWorklet.addModule("/static/mic-processor.js"); | |
| micSource = captureContext.createMediaStreamSource(mediaStream); | |
| micNode = new AudioWorkletNode(captureContext, "gargi-mic-processor"); | |
| const silentGain = captureContext.createGain(); | |
| silentGain.gain.value = 0; | |
| micNode.port.onmessage = (event) => { | |
| if (recording && socket?.readyState === WebSocket.OPEN) socket.send(event.data); | |
| }; | |
| micSource.connect(micNode); | |
| micNode.connect(silentGain).connect(captureContext.destination); | |
| heardLiveAudioThisTurn = false; | |
| fallbackSpeechText = ""; | |
| fallbackSpeechBuffer = ""; | |
| clearTimeout(fallbackSpeechTimer); | |
| recording = true; | |
| micButton.classList.add("recording"); | |
| setState("listening", "Listening"); | |
| } catch (error) { | |
| showToast(error.name === "NotAllowedError" ? "Microphone permission was denied." : "The microphone could not start."); | |
| await stopMicrophone(false); | |
| } finally { | |
| startingMicrophone = false; | |
| } | |
| } | |
| function stopPlayback(updateState = true) { | |
| window.speechSynthesis?.cancel(); | |
| clearTimeout(fallbackSpeechTimer); | |
| fallbackSpeechBuffer = ""; | |
| stopFallbackLipSync(); | |
| playbackSources.forEach((source) => { | |
| source.onended = null; | |
| try { source.stop(); } catch {} | |
| try { source.disconnect(); } catch {} | |
| }); | |
| playbackSources.clear(); | |
| activeSources = 0; | |
| nextPlayTime = playbackContext?.currentTime || 0; | |
| panel.style.setProperty("--talk-level", "0"); | |
| setAvatarMouth(0); | |
| if (updateState && !recording) setState("ready", "Gargi is ready"); | |
| } | |
| function speakBrowserFallback(text, cancelExisting = false) { | |
| if (!window.speechSynthesis || !text.trim()) return; | |
| if (cancelExisting) window.speechSynthesis.cancel(); | |
| const utterance = new SpeechSynthesisUtterance(text.trim()); | |
| utterance.lang = "en-US"; | |
| utterance.rate = 0.95; | |
| utterance.pitch = 1.08; | |
| const voices = window.speechSynthesis.getVoices(); | |
| utterance.voice = | |
| voices.find((voice) => /Jenny|Aria|Zira|Samantha|Female/i.test(voice.name)) || | |
| voices.find((voice) => voice.lang?.startsWith("en")) || | |
| null; | |
| utterance.onstart = () => { | |
| startFallbackLipSync(); | |
| setState("speaking", "Gargi is speaking"); | |
| }; | |
| utterance.onend = () => { | |
| if (!window.speechSynthesis.speaking) stopFallbackLipSync(); | |
| if (!recording) setState("ready", "Ask a follow-up"); | |
| }; | |
| utterance.onerror = stopFallbackLipSync; | |
| window.speechSynthesis.speak(utterance); | |
| } | |
| function flushFallbackSpeech(force = false) { | |
| if (heardLiveAudioThisTurn || !fallbackSpeechBuffer.trim()) return; | |
| const source = fallbackSpeechBuffer.trimStart(); | |
| let textToSpeak = ""; | |
| let remaining = source; | |
| if (force) { | |
| textToSpeak = source; | |
| remaining = ""; | |
| } else { | |
| const sentenceEnd = Math.max(source.lastIndexOf("."), source.lastIndexOf("?"), source.lastIndexOf("!")); | |
| if (sentenceEnd >= 0) { | |
| textToSpeak = source.slice(0, sentenceEnd + 1); | |
| remaining = source.slice(sentenceEnd + 1); | |
| } else if (source.length > 130) { | |
| const splitAt = Math.max(source.lastIndexOf(","), source.lastIndexOf(" ")); | |
| textToSpeak = source.slice(0, splitAt > 30 ? splitAt : source.length); | |
| remaining = source.slice(textToSpeak.length); | |
| } | |
| } | |
| fallbackSpeechBuffer = remaining.trimStart(); | |
| if (textToSpeak.trim()) speakBrowserFallback(textToSpeak, false); | |
| } | |
| function scheduleRealtimeFallbackSpeech() { | |
| if (heardLiveAudioThisTurn) return; | |
| clearTimeout(fallbackSpeechTimer); | |
| const delay = /[.!?]\s*$/.test(fallbackSpeechBuffer.trim()) ? 80 : 520; | |
| fallbackSpeechTimer = setTimeout(() => flushFallbackSpeech(false), delay); | |
| } | |
| async function stopMicrophone(sendEnd = true) { | |
| if (!recording && !mediaStream && !captureContext) return; | |
| const contextToClose = captureContext; | |
| recording = false; | |
| micButton.classList.remove("recording"); | |
| if (sendEnd && socket?.readyState === WebSocket.OPEN) { | |
| socket.send(JSON.stringify({type: "audio_end"})); | |
| setState("thinking", "Gargi is thinking"); | |
| } else { | |
| setState("ready", "Gargi is ready"); | |
| } | |
| if (micNode?.port) micNode.port.onmessage = null; | |
| micNode?.disconnect(); | |
| micSource?.disconnect(); | |
| mediaStream?.getTracks().forEach((track) => track.stop()); | |
| mediaStream = captureContext = micNode = micSource = null; | |
| if (contextToClose && contextToClose.state !== "closed") { | |
| contextToClose.close().catch(() => {}); | |
| } | |
| } | |
| socket = new WebSocket(`${protocol}//${location.host}${API}/sessions/${id}/live`); | |
| socket.binaryType = "arraybuffer"; | |
| socket.onopen = () => setState("connecting", "Connecting"); | |
| socket.onmessage = async (event) => { | |
| if (event.data instanceof ArrayBuffer) { | |
| if (recording) return; | |
| return playPcm(event.data); | |
| } | |
| const message = JSON.parse(event.data); | |
| if (message.type === "ready") { | |
| socketReady = true; | |
| setState("ready", "Gargi is ready"); | |
| } else if (message.type === "input_transcription") { | |
| const text = message.text.trim(); | |
| if (pendingTyped && text.toLowerCase() === pendingTyped.toLowerCase()) pendingTyped = ""; | |
| else if (text) appendMessage("student", text, true); | |
| } else if (message.type === "output_transcription") { | |
| fallbackSpeechText += message.text; | |
| fallbackSpeechBuffer += message.text; | |
| scheduleRealtimeFallbackSpeech(); | |
| appendMessage("teacher", message.text, true); | |
| if (!recording) setState("speaking", "Gargi is speaking"); | |
| } else if (message.type === "lesson_payload") { | |
| currentBoardBlocks = message.whiteboard_blocks || []; | |
| currentQuiz = message.quiz || []; | |
| board.innerHTML = renderBoard(message.whiteboard_blocks); | |
| boardStatus.textContent = "Updated just now"; | |
| quizLink.classList.remove("hidden"); | |
| showToast(message.fallback ? (message.notice || "Fallback whiteboard and quiz are ready.") : "Whiteboard and quiz are ready."); | |
| } else if (message.type === "interrupted") { | |
| stopPlayback(false); | |
| setState(recording ? "listening" : "ready", recording ? "Listening" : "Gargi is ready"); | |
| } else if (message.type === "turn_complete") { | |
| if (!heardLiveAudioThisTurn && fallbackSpeechText.trim()) { | |
| clearTimeout(fallbackSpeechTimer); | |
| flushFallbackSpeech(true); | |
| } | |
| heardLiveAudioThisTurn = false; | |
| fallbackSpeechText = ""; | |
| fallbackSpeechBuffer = ""; | |
| if (activeSources <= 0) setState("ready", "Ask a follow-up"); | |
| } else if (message.type === "error") { | |
| const retry = message.retry_delay ? ` Try again in ${message.retry_delay}.` : ""; | |
| showToast(`${message.message || "The live classroom hit a problem."}${retry}`); | |
| setState("error", "Connection problem"); | |
| } | |
| }; | |
| socket.onclose = () => { | |
| socketReady = false; | |
| setState("error", "Voice disconnected"); | |
| }; | |
| socket.onerror = () => showToast("Could not connect to the live classroom."); | |
| initializeLive2DAvatar(); | |
| quizLink?.addEventListener("click", (event) => { | |
| event.preventDefault(); | |
| if (quizLink.textContent === "Whiteboard") showWhiteboard(); | |
| else showInlineQuiz(); | |
| }); | |
| micButton.addEventListener("click", () => recording ? stopMicrophone(true) : startMicrophone()); | |
| form.addEventListener("submit", async (event) => { | |
| event.preventDefault(); | |
| const input = document.querySelector("#ask-input"); | |
| const text = input.value.trim(); | |
| if (!text) return; | |
| if (!socketReady) return showToast("The live classroom is still connecting."); | |
| await ensurePlaybackContext(); | |
| heardLiveAudioThisTurn = false; | |
| fallbackSpeechText = ""; | |
| fallbackSpeechBuffer = ""; | |
| clearTimeout(fallbackSpeechTimer); | |
| pendingTyped = text; | |
| appendMessage("student", text); | |
| socket.send(JSON.stringify({type: "text", text})); | |
| input.value = ""; | |
| setState("thinking", "Gargi is thinking"); | |
| }); | |
| window.addEventListener("beforeunload", () => { | |
| if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({type: "close"})); | |
| stopPlayback(false); | |
| stopFallbackLipSync(); | |
| live2dApp?.destroy(true); | |
| mediaStream?.getTracks().forEach((track) => track.stop()); | |
| }); | |
| } | |
| render(); | |