Files
tutor-service/internal/webapp/static/app.js

524 lines
16 KiB
JavaScript

const state = {
session: null,
selectedQuestion: null,
lastAnswer: null,
progress: null,
ontology: null,
assetPrompt: null,
};
const els = {
loginView: document.querySelector("#login-view"),
workspaceView: document.querySelector("#workspace-view"),
loginError: document.querySelector("#login-error"),
sessionForm: document.querySelector("#session-form"),
answerForm: document.querySelector("#answer-form"),
answerText: document.querySelector("#answer-text"),
answerButton: document.querySelector("#answer-button"),
questions: document.querySelector("#questions"),
feedback: document.querySelector("#feedback"),
progress: document.querySelector("#progress"),
refreshProgress: document.querySelector("#refresh-progress"),
materialForm: document.querySelector("#material-form"),
assetForm: document.querySelector("#asset-form"),
ontology: document.querySelector("#ontology"),
assetOutput: document.querySelector("#asset-output"),
assetConcept: document.querySelector("#asset-concept"),
assetButton: document.querySelector("#asset-button"),
status: document.querySelector("#status-line"),
error: document.querySelector("#error-line"),
title: document.querySelector("#session-title"),
userInfo: document.querySelector("#user-info"),
logoutButton: document.querySelector("#logout-button"),
};
const readinessClassMap = {
unknown: "pill-neutral",
fragile: "pill-weak",
improving: "pill-warn",
interview_ready: "pill-good",
strong_signal: "pill-strong",
};
const reviewClassMap = {
candidate: "pill-neutral",
reviewed: "pill-good",
};
const gradeClassMap = {
miss: "grade-miss",
partial: "grade-partial",
solid: "grade-solid",
strong: "grade-strong",
};
function setButtonLoading(button, loadingText) {
button.disabled = true;
button.classList.add("is-loading");
const textEl = button.querySelector(".btn-text");
if (textEl && loadingText) {
textEl.dataset.originalText = textEl.textContent;
textEl.textContent = loadingText;
}
}
function clearButtonLoading(button) {
button.disabled = false;
button.classList.remove("is-loading");
const textEl = button.querySelector(".btn-text");
if (textEl && textEl.dataset.originalText) {
textEl.textContent = textEl.dataset.originalText;
delete textEl.dataset.originalText;
}
}
els.sessionForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
setStatus(t("creatingSession"), true);
setButtonLoading(
event.submitter || document.querySelector("#start-button"),
t("starting")
);
const payload = {
user_id: value("#user-id"),
target_role: value("#target-role"),
stack: value("#stack")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
interview_timeline: value("#timeline"),
};
try {
const session = await request("/api/v1/diagnostic-sessions", {
method: "POST",
body: JSON.stringify(payload),
});
state.session = session;
state.selectedQuestion = session.questions[0] || null;
state.lastAnswer = null;
renderSession();
renderFeedback();
renderProgress();
setStatus(t("sessionReady", session.id));
} catch (error) {
showError(error.message);
setStatus(t("ready"));
} finally {
clearButtonLoading(document.querySelector("#start-button"));
}
});
els.refreshProgress.addEventListener("click", async () => {
clearError();
await refreshProgress();
});
els.answerForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
if (!state.session || !state.selectedQuestion) return;
setStatus(t("submittingAnswer"), true);
setButtonLoading(event.submitter || els.answerButton, t("grading"));
try {
const answer = await request(
`/api/v1/diagnostic-sessions/${state.session.id}/answers`,
{
method: "POST",
body: JSON.stringify({
question_id: state.selectedQuestion.id,
answer_text: els.answerText.value,
}),
}
);
state.lastAnswer = answer;
renderFeedback();
await refreshProgress();
setStatus(t("answerGraded", answer.grade.overall));
} catch (error) {
showError(error.message);
setStatus(t("sessionReadyShort"));
} finally {
clearButtonLoading(els.answerButton);
els.answerButton.disabled = !state.selectedQuestion;
}
});
els.materialForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
setStatus(t("ingestingMaterial"), true);
setButtonLoading(
event.submitter || document.querySelector("#material-button"),
t("ingesting")
);
try {
const result = await request("/api/v1/materials", {
method: "POST",
body: JSON.stringify({
title: value("#material-title"),
source_type: value("#material-source"),
body: value("#material-body"),
}),
});
state.ontology = result.snapshot;
renderOntology();
setStatus(t("materialIngested", result.material.id));
} catch (error) {
showError(error.message);
setStatus(t("contentReady"));
} finally {
clearButtonLoading(document.querySelector("#material-button"));
}
});
els.assetForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
setStatus(t("generatingPrompt"), true);
setButtonLoading(event.submitter || els.assetButton, t("generating"));
try {
const prompt = await request("/api/v1/teaching-assets/prompts", {
method: "POST",
body: JSON.stringify({
concept_id: els.assetConcept.value,
asset_type: value("#asset-type"),
}),
});
state.assetPrompt = prompt;
renderAssetPrompt();
setStatus(t("promptGenerated", prompt.id));
} catch (error) {
showError(error.message);
setStatus(t("contentReady"));
} finally {
clearButtonLoading(els.assetButton);
}
});
function renderSession() {
if (!state.session) return;
els.title.textContent = `${state.session.target_role}${state.session.questions.length} ${t("questionsSuffix")}`;
els.questions.className = "question-list";
els.questions.innerHTML = "";
state.session.questions.forEach((question) => {
const button = document.createElement("button");
button.type = "button";
button.className = "question-button";
button.setAttribute(
"aria-pressed",
String(state.selectedQuestion?.id === question.id)
);
button.innerHTML = `<span class="question-id">${escapeHTML(question.id)}</span>${escapeHTML(question.prompt)}`;
button.addEventListener("click", () => {
state.selectedQuestion = question;
els.answerText.value = "";
renderSession();
setStatus(t("selected", question.id));
});
els.questions.append(button);
});
els.answerButton.disabled = !state.selectedQuestion;
}
async function refreshProgress() {
if (!state.session) return;
setStatus(t("refreshingProgress"), true);
try {
const userID = encodeURIComponent(state.session.user_id);
const [memory, readiness, challenge] = await Promise.all([
request(`/api/v1/learners/${userID}/memory`),
request(`/api/v1/learners/${userID}/readiness-map`),
request(`/api/v1/learners/${userID}/next-challenge`),
]);
state.progress = { memory, readiness, challenge };
renderProgress();
setStatus(t("progressUpdated"));
} catch (error) {
showError(error.message);
renderProgress();
}
}
function renderProgress() {
els.refreshProgress.disabled = !state.session;
if (!state.progress) {
els.progress.className = "feedback empty-state";
els.progress.innerHTML = `<span class="empty-hint">${t("emptyProgress")}</span>`;
return;
}
const { memory, readiness, challenge } = state.progress;
const mastery = memory.mastery || [];
els.progress.className = "feedback";
els.progress.innerHTML = `
<section>
<div class="readiness-value">${readiness.readiness_percentage}%</div>
<p class="status-line">${escapeHTML(memory.profile.target_role)} ${t("readiness")}</p>
</section>
<section>
<h2>${t("conceptMemory")}</h2>
<div>${mastery
.map((item) => {
const cls = readinessClassMap[item.state] || "pill-neutral";
return `<span class="concept-pill ${cls}">${escapeHTML(item.concept.label)}${escapeHTML(item.state)}</span>`;
})
.join("")}</div>
</section>
<section>
<h2>${t("nextChallenge")}</h2>
<p class="status-line">${escapeHTML(challenge.concept.label)}${escapeHTML(challenge.ladder_level)}</p>
<p>${escapeHTML(challenge.question)}</p>
</section>
`;
}
function renderOntology() {
if (!state.ontology) {
els.ontology.className = "ontology-view empty-state";
els.ontology.innerHTML = `<span class="empty-hint">${t("emptyOntology")}</span>`;
return;
}
const concepts = state.ontology.concepts || [];
els.ontology.className = "ontology-view";
els.ontology.innerHTML = `
<div class="summary-strip">
<span class="summary-chip">${concepts.length} ${t("conceptsSuffix")}</span>
<span class="summary-chip">${(state.ontology.edges || []).length} ${t("edgesSuffix")}</span>
<span class="summary-chip">${(state.ontology.gaps || []).length} ${t("gapsSuffix")}</span>
</div>
<section>
<h2>${t("candidateConcepts")}</h2>
<div>${concepts
.map((item) => {
const cls = reviewClassMap[item.review_state] || "pill-neutral";
return `<span class="concept-pill ${cls}">${escapeHTML(item.concept.label)}${escapeHTML(item.review_state)}</span>`;
})
.join("") || t("noCandidates")}</div>
</section>
`;
els.assetConcept.innerHTML = concepts
.map(
(item) =>
`<option value="${escapeHTML(item.concept.id)}">${escapeHTML(item.concept.label)}</option>`
)
.join("");
els.assetConcept.disabled = concepts.length === 0;
els.assetButton.disabled = concepts.length === 0;
}
function renderAssetPrompt() {
if (!state.assetPrompt) {
els.assetOutput.className = "ontology-view empty-state";
els.assetOutput.innerHTML = `<span class="empty-hint">${t("emptyAsset")}</span>`;
return;
}
const prompt = state.assetPrompt;
els.assetOutput.className = "ontology-view";
els.assetOutput.innerHTML = `
<div class="summary-strip">
<span class="summary-chip">${escapeHTML(prompt.model_key)}</span>
<span class="summary-chip">${escapeHTML(prompt.review_state)}</span>
<span class="summary-chip">${t("verifyModelId")}: ${prompt.requires_model_id_verification ? t("yes") : t("no")}</span>
</div>
<pre class="prompt-text">${escapeHTML(prompt.prompt)}</pre>
${evidenceBlock(prompt.source_evidence)}
`;
}
function renderFeedback() {
if (!state.lastAnswer) {
els.feedback.className = "feedback empty-state";
els.feedback.innerHTML = `<span class="empty-hint">${t("emptyFeedback")}</span>`;
return;
}
const grade = state.lastAnswer.grade;
const gradeClass = gradeClassMap[grade.overall] || "";
els.feedback.className = "feedback";
els.feedback.innerHTML = `
<div>
<div class="grade ${gradeClass}">${escapeHTML(grade.overall)}</div>
<p class="status-line">${escapeHTML(grade.strengths?.[0] || t("answerWasGraded"))}</p>
</div>
${scoreRows(grade.scores)}
${listBlock(t("gaps"), grade.gaps)}
${followUpBlock(grade.follow_up)}
${evidenceBlock(grade.evidence)}
`;
}
function scoreRows(scores) {
return Object.entries(scores || {})
.map(
([label, score]) => `
<div class="metric-row">
<span>${escapeHTML(label.replaceAll("_", " "))}</span>
<strong>${score}/4</strong>
</div>
`
)
.join("");
}
function listBlock(title, items = []) {
if (!items.length) return "";
return `<section><h2>${title}</h2><ul class="small-list">${items.map((item) => `<li>${escapeHTML(item)}</li>`).join("")}</ul></section>`;
}
function followUpBlock(followUp) {
if (!followUp?.needed) return "";
return `<section><h2>${t("followUp")}</h2><p class="status-line">${escapeHTML(followUp.question)}</p></section>`;
}
function evidenceBlock(evidence = []) {
if (!evidence.length) return "";
return `<section><h2>${t("evidence")}</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
}
window._tutorGoogleCallback = async (response) => {
console.log("[auth] Google callback fired");
try {
const res = await request("/api/v1/auth/google", {
method: "POST",
body: JSON.stringify({ id_token: response.credential }),
});
console.log("[auth] Backend login success", res.user?.email);
localStorage.setItem("tutor_token", res.token);
localStorage.setItem("tutor_user", JSON.stringify(res.user));
if (els.loginError) {
els.loginError.textContent = "";
els.loginError.classList.remove("visible");
}
renderAuth();
} catch (err) {
console.error("[auth] Backend login failed", err);
if (els.loginError) {
els.loginError.textContent = err.message;
els.loginError.classList.add("visible");
}
}
};
if (window._tutorPendingGoogleResponse) {
window._tutorGoogleCallback(window._tutorPendingGoogleResponse);
window._tutorPendingGoogleResponse = null;
}
function renderAuth() {
const user = JSON.parse(localStorage.getItem("tutor_user") || "null");
const token = localStorage.getItem("tutor_token");
console.log("[auth] renderAuth", { hasUser: !!user, hasToken: !!token });
if (user && token) {
els.loginView.style.display = "none";
els.workspaceView.style.display = "grid";
els.userInfo.textContent = user.email || user.name || "User";
setStatus(t("signedInAs", user.email || user.name));
const userIdInput = document.querySelector("#user-id");
if (userIdInput) userIdInput.value = user.email || user.id || "";
if (els.loginError) els.loginError.classList.remove("visible");
} else {
els.loginView.style.display = "flex";
els.workspaceView.style.display = "none";
}
}
function setLanguage(lang) {
localStorage.setItem("tutor_lang", lang);
document.documentElement.lang = lang;
updateStaticText();
document.querySelectorAll(".lang-btn").forEach((btn) => {
btn.classList.toggle("is-active", btn.dataset.lang === lang);
});
if (state.session) renderSession();
renderFeedback();
renderProgress();
renderOntology();
renderAssetPrompt();
const user = JSON.parse(localStorage.getItem("tutor_user") || "null");
const token = localStorage.getItem("tutor_token");
if (user && token) {
setStatus(t("signedInAs", user.email || user.name));
} else {
setStatus(t("ready"));
}
}
els.logoutButton.addEventListener("click", () => {
localStorage.removeItem("tutor_token");
localStorage.removeItem("tutor_user");
renderAuth();
setStatus(t("signedOut"));
});
async function request(url, options = {}) {
const token = localStorage.getItem("tutor_token");
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
const response = await fetch(url, { headers, ...options });
const body = await response.json();
if (!response.ok) {
throw new Error(body.error || `Request failed: ${response.status}`);
}
return body;
}
function value(selector) {
return document.querySelector(selector).value.trim();
}
function setStatus(message, busy = false) {
const textEl = els.status.querySelector(".status-text");
if (textEl) textEl.textContent = message;
else els.status.textContent = message;
els.status.classList.toggle("is-busy", busy);
}
function showError(message) {
els.error.textContent = message;
}
function clearError() {
els.error.textContent = "";
}
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
document.querySelectorAll(".lang-switch").forEach((group) => {
group.addEventListener("click", (e) => {
if (!e.target.dataset.lang) return;
setLanguage(e.target.dataset.lang);
});
});
if (!localStorage.getItem("tutor_lang")) {
const browserLang = navigator.language || navigator.userLanguage || "";
const lang = browserLang.toLowerCase().startsWith("ko") ? "ko" : "en";
localStorage.setItem("tutor_lang", lang);
document.documentElement.lang = lang;
}
updateStaticText();
window.renderAuth = renderAuth;
window.setLanguage = setLanguage;
renderAuth();