Gargi_AI / static /app-v2.js
Sameer Singh
Added
5aaf5ba
Raw
History Blame Contribute Delete
42.7 kB
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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;",
})[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&mdash;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>&rarr;</span></a>
<a class="btn btn-secondary" href="#how-it-works">See how it works <span>&darr;</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&mdash;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>&#10022;</i><span>Adapts to your level</span></div>
<div class="float-card float-two"><i>&#9673;</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">&#9673;</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">&#10022;</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">&#10003;</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>&rarr;</span></button>
</div>
</form>
</div>
</section>`;
}
function renderBoard(blocks = []) {
if (!blocks.length) {
return `<div class="empty-board"><div class="sketch">&#10022;</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)} &middot; ${escapeHtml(lesson.language)} &middot; 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 &middot; 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">&#9673;</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">&#10148;</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)} &middot; ${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 &rarr;</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 &rarr;</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>&rarr;</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"} &rarr;</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"} &rarr;</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();