aim / static /app.js
Claude
Make companion respond in the selected language (EN/FR)
60bcd69 unverified
/**
* AIM Learning Companion - Frontend
* Stateless: no localStorage, no cookies, no persistence.
*/
(function () {
"use strict";
/* ===== i18n ===== */
var LANG = {
en: {
subtitle: "Socratic Learning Companion",
topicLabel: "Exploration topic",
topicPlaceholder: "E.g.: Artificial intelligence in professional training",
docsLabel: "Reference documents (optional)",
uploadText: "Drag your files here or click to select",
uploadHint: "PDF, PPTX, TXT or ZIP \u2014 multiple files allowed",
modeLabel: "Mode",
modeTutor: "Tutor",
modeTutorDesc: "Supportive guidance, open-ended questions",
modeCritic: "Critic",
modeCriticDesc: "Devil's advocate, tests logical weaknesses",
btnStart: "Start session",
btnEnd: "End session",
btnRestart: "Restart",
chatPlaceholder: "Type your thoughts here...",
btnSend: "Send",
reportTitle: "Session Report",
summaryTitle: "Summary",
strengthsTitle: "Key strengths",
weaknessesTitle: "Areas for improvement",
rhythmTitle: "Pace",
rhythmText: "Responses under 8 seconds:",
btnExport: "Export JSON",
btnNewSession: "New session",
modeBadgeTutor: "Tutor",
modeBadgeCritic: "Critic",
errorEmpty: "No response from server. Check API configuration.",
errorConnection: "Connection error. Please try again.",
errorNoExchange: "No exchanges to analyze.",
errorAnalysis: "Analysis error. Please try again.",
startMessage: "Hello, I would like to explore the topic: ",
scoreLabels: ["Reasoning", "Clarity", "Skepticism", "Process", "Reflection", "Integrity"],
noSummary: "No summary available.",
langBtn: "FR"
},
fr: {
subtitle: "Compagnon socratique d'apprentissage",
topicLabel: "Sujet d'exploration",
topicPlaceholder: "Ex : L'intelligence artificielle en formation professionnelle",
docsLabel: "Documents de reference (optionnel)",
uploadText: "Glisse tes fichiers ici ou clique pour selectionner",
uploadHint: "PDF, PPTX, TXT ou ZIP \u2014 plusieurs fichiers possibles",
modeLabel: "Mode",
modeTutor: "Tuteur",
modeTutorDesc: "Accompagnement bienveillant, questions ouvertes",
modeCritic: "Critique",
modeCriticDesc: "Avocat du diable, teste les failles logiques",
btnStart: "Commencer la session",
btnEnd: "Terminer la session",
btnRestart: "Recommencer",
chatPlaceholder: "Tape ta reflexion ici...",
btnSend: "Envoyer",
reportTitle: "Rapport de session",
summaryTitle: "Bilan",
strengthsTitle: "Points forts",
weaknessesTitle: "Axes d'amelioration",
rhythmTitle: "Rythme",
rhythmText: "Reponses en moins de 8 secondes :",
btnExport: "Exporter JSON",
btnNewSession: "Nouvelle session",
modeBadgeTutor: "Tuteur",
modeBadgeCritic: "Critique",
errorEmpty: "Reponse vide du serveur. Verifiez la configuration API.",
errorConnection: "Erreur de connexion. Veuillez reessayer.",
errorNoExchange: "Aucun echange a analyser.",
errorAnalysis: "Erreur lors de l'analyse. Veuillez reessayer.",
startMessage: "Bonjour, je souhaite explorer le sujet : ",
scoreLabels: ["Raisonnement", "Clarte", "Scepticisme", "Processus", "Reflexion", "Integrite"],
noSummary: "Aucun bilan disponible.",
langBtn: "EN"
}
};
var currentLang = "en";
function t(key) {
return LANG[currentLang][key] || key;
}
function applyLanguage() {
document.querySelectorAll("[data-i18n]").forEach(function (el) {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll("[data-i18n-placeholder]").forEach(function (el) {
el.placeholder = t(el.dataset.i18nPlaceholder);
});
document.getElementById("btn-lang").textContent = t("langBtn");
document.documentElement.lang = currentLang === "fr" ? "fr" : "en";
document.title = currentLang === "en" ? "AIM - Learning Companion" : "AIM - Compagnon d'apprentissage";
}
document.getElementById("btn-lang").addEventListener("click", function () {
currentLang = currentLang === "en" ? "fr" : "en";
applyLanguage();
});
/* ===== State (in-memory only, lost on tab close) ===== */
var state = {
mode: "TUTOR",
topic: "",
phase: 0,
phaseTurns: 0,
history: [], // {role, content}
timestamps: [], // epoch ms for every message (user & assistant alternating)
analysisResult: null,
uploadedDocs: [] // filenames uploaded this session
};
var PHASE_NAMES = [
"Ciblage",
"Clarification",
"Mecanisme",
"Verification",
"Stress-test"
];
/* ===== DOM refs ===== */
var setupScreen = document.getElementById("setup-screen");
var chatScreen = document.getElementById("chat-screen");
var analysisScreen = document.getElementById("analysis-screen");
var topicInput = document.getElementById("topic-input");
var btnStart = document.getElementById("btn-start");
var modeBtns = document.querySelectorAll(".mode-btn");
var modeBadge = document.getElementById("mode-badge");
var topicBadge = document.getElementById("topic-badge");
var docsBadge = document.getElementById("docs-badge");
var phaseDots = document.getElementById("phase-dots");
var phaseLabels = document.getElementById("phase-labels");
var messagesEl = document.getElementById("messages");
var typingEl = document.getElementById("typing");
var chatInput = document.getElementById("chat-input");
var btnSend = document.getElementById("btn-send");
var btnEnd = document.getElementById("btn-end-session");
var btnReset = document.getElementById("btn-reset");
var scoresGrid = document.getElementById("scores-grid");
var summaryEl = document.getElementById("analysis-summary");
var strengthsEl = document.getElementById("analysis-strengths");
var weaknessesEl = document.getElementById("analysis-weaknesses");
var rhythmCount = document.getElementById("rhythm-count");
var btnExport = document.getElementById("btn-export");
var btnNewSession = document.getElementById("btn-new-session");
// Upload refs
var uploadZone = document.getElementById("upload-zone");
var fileInput = document.getElementById("file-input");
var uploadList = document.getElementById("upload-list");
var uploadStatus = document.getElementById("upload-status");
/* ===== Screen navigation ===== */
function showScreen(screen) {
setupScreen.classList.remove("active");
chatScreen.classList.remove("active");
analysisScreen.classList.remove("active");
screen.classList.add("active");
}
/* ===== Phase indicator ===== */
function renderPhaseIndicator() {
phaseDots.innerHTML = "";
phaseLabels.innerHTML = "";
for (var i = 0; i < 5; i++) {
if (i > 0) {
var conn = document.createElement("div");
conn.className = "phase-connector" + (i <= state.phase ? " done" : "");
phaseDots.appendChild(conn);
}
var dot = document.createElement("div");
dot.className = "phase-dot";
if (i === state.phase) dot.className += " active";
else if (i < state.phase) dot.className += " done";
dot.textContent = i;
phaseDots.appendChild(dot);
var lbl = document.createElement("div");
lbl.className = "phase-label-text" + (i === state.phase ? " active" : "");
lbl.textContent = PHASE_NAMES[i];
phaseLabels.appendChild(lbl);
}
}
/* ===== Messages ===== */
function stripPhaseMarker(text) {
// Remove "---\nPhase: ..." block from the end of assistant messages
var idx = text.indexOf("\n---");
if (idx === -1) idx = text.indexOf("---\nPhase");
return idx >= 0 ? text.substring(0, idx).trim() : text;
}
function addMessage(role, content) {
var div = document.createElement("div");
div.className = "message " + role;
div.textContent = role === "assistant" ? stripPhaseMarker(content) : content;
messagesEl.insertBefore(div, typingEl);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function setTyping(on) {
typingEl.style.display = on ? "block" : "none";
if (on) messagesEl.scrollTop = messagesEl.scrollHeight;
}
/* ===== File Upload ===== */
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + " o";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " Ko";
return (bytes / (1024 * 1024)).toFixed(1) + " Mo";
}
function renderUploadList() {
uploadList.innerHTML = "";
state.uploadedDocs.forEach(function (doc) {
var item = document.createElement("div");
item.className = "upload-item";
var icon = doc.filename.toLowerCase().endsWith(".pdf") ? "PDF" :
doc.filename.toLowerCase().endsWith(".pptx") ? "PPT" :
doc.filename.toLowerCase().endsWith(".ppt") ? "PPT" : "TXT";
item.innerHTML =
'<span class="upload-item-icon">' + icon + '</span>' +
'<span class="upload-item-name">' + doc.filename + '</span>' +
'<span class="upload-item-chunks">' + doc.chunks + ' chunks</span>' +
'<button class="upload-item-delete" data-filename="' + doc.filename + '">X</button>';
uploadList.appendChild(item);
});
// Bind delete buttons
uploadList.querySelectorAll(".upload-item-delete").forEach(function (btn) {
btn.addEventListener("click", function () {
deleteDoc(btn.dataset.filename);
});
});
}
function uploadFiles(fileList) {
if (!fileList || fileList.length === 0) return;
var formData = new FormData();
for (var i = 0; i < fileList.length; i++) {
formData.append("files", fileList[i]);
}
uploadStatus.textContent = "Upload en cours...";
uploadStatus.className = "upload-status uploading";
uploadZone.classList.add("uploading");
fetch("/api/upload", {
method: "POST",
body: formData
})
.then(function (res) { return res.json(); })
.then(function (data) {
uploadZone.classList.remove("uploading");
var ok = 0;
var errors = [];
(data.results || []).forEach(function (r) {
if (r.status === "ok") {
ok++;
state.uploadedDocs.push({ filename: r.filename, chunks: r.chunks });
} else {
errors.push(r.filename + ": " + (r.message || "erreur"));
}
});
(data.skipped || []).forEach(function (s) {
errors.push(s.filename + ": " + s.reason);
});
if (ok > 0 && errors.length === 0) {
uploadStatus.textContent = ok + " fichier(s) ajoute(s) au corpus";
uploadStatus.className = "upload-status success";
} else if (ok > 0 && errors.length > 0) {
uploadStatus.textContent = ok + " OK, " + errors.length + " erreur(s): " + errors.join("; ");
uploadStatus.className = "upload-status warning";
} else {
uploadStatus.textContent = "Erreur: " + errors.join("; ");
uploadStatus.className = "upload-status error";
}
renderUploadList();
})
.catch(function () {
uploadZone.classList.remove("uploading");
uploadStatus.textContent = "Erreur de connexion. Reessaye.";
uploadStatus.className = "upload-status error";
});
}
function deleteDoc(filename) {
fetch("/api/documents/" + encodeURIComponent(filename), { method: "DELETE" })
.then(function (res) { return res.json(); })
.then(function () {
state.uploadedDocs = state.uploadedDocs.filter(function (d) {
return d.filename !== filename;
});
renderUploadList();
uploadStatus.textContent = filename + " supprime";
uploadStatus.className = "upload-status success";
});
}
// Upload zone events
uploadZone.addEventListener("click", function () {
fileInput.click();
});
fileInput.addEventListener("change", function () {
uploadFiles(fileInput.files);
fileInput.value = "";
});
uploadZone.addEventListener("dragover", function (e) {
e.preventDefault();
uploadZone.classList.add("dragover");
});
uploadZone.addEventListener("dragleave", function () {
uploadZone.classList.remove("dragover");
});
uploadZone.addEventListener("drop", function (e) {
e.preventDefault();
uploadZone.classList.remove("dragover");
uploadFiles(e.dataTransfer.files);
});
/* ===== API calls ===== */
function sendMessage(text) {
state.history.push({ role: "user", content: text });
state.timestamps.push(Date.now());
addMessage("user", text);
chatInput.value = "";
btnSend.disabled = true;
setTyping(true);
fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: text,
mode: state.mode,
topic: state.topic,
phase: state.phase,
phase_turns: state.phaseTurns,
lang: currentLang,
history: state.history.slice(0, -1)
})
})
.then(function (res) {
if (!res.ok) {
return res.json().then(function (err) {
throw new Error(err.error || "Erreur serveur " + res.status);
});
}
return res.json();
})
.then(function (data) {
setTyping(false);
if (!data.reply) {
addMessage("assistant", t("errorEmpty"));
btnSend.disabled = false;
return;
}
state.phase = data.phase;
state.phaseTurns = data.phase_turns || 0;
state.history.push({ role: "assistant", content: data.reply });
state.timestamps.push(Date.now());
addMessage("assistant", data.reply);
renderPhaseIndicator();
btnSend.disabled = false;
chatInput.focus();
})
.catch(function (err) {
setTyping(false);
console.error("sendMessage error:", err);
addMessage("assistant", err.message || t("errorConnection"));
btnSend.disabled = false;
});
}
function requestAnalysis() {
btnEnd.disabled = true;
setTyping(true);
fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
history: state.history,
timestamps: state.timestamps
})
})
.then(function (res) { return res.json(); })
.then(function (data) {
setTyping(false);
state.analysisResult = data;
renderAnalysis(data);
showScreen(analysisScreen);
})
.catch(function () {
setTyping(false);
btnEnd.disabled = false;
alert(t("errorAnalysis"));
});
}
/* ===== Analysis rendering ===== */
function renderAnalysis(data) {
var scoreKeys = ["reasoningScore", "clarityScore", "skepticismScore", "processScore", "reflectionScore", "integrityScore"];
var labels = t("scoreLabels");
var scores = scoreKeys.map(function (key, i) {
return { key: key, label: labels[i] };
});
scoresGrid.innerHTML = "";
scores.forEach(function (s) {
var val = data[s.key] || 0;
var card = document.createElement("div");
card.className = "score-card";
card.innerHTML =
'<div class="score-value">' + val + '</div>' +
'<div class="score-label">' + s.label + '</div>' +
'<div class="score-bar"><div class="score-bar-fill" style="width:' + val + '%"></div></div>';
scoresGrid.appendChild(card);
});
summaryEl.textContent = data.summary || t("noSummary");
strengthsEl.innerHTML = "";
(data.keyStrengths || []).forEach(function (s) {
var li = document.createElement("li");
li.textContent = s;
strengthsEl.appendChild(li);
});
weaknessesEl.innerHTML = "";
(data.weaknesses || []).forEach(function (w) {
var li = document.createElement("li");
li.textContent = w;
weaknessesEl.appendChild(li);
});
rhythmCount.textContent = data.rhythmBreakCount || 0;
}
/* ===== JSON export ===== */
function exportJSON() {
var payload = {
mode: state.mode,
topic: state.topic,
messages: state.history,
timestamps: state.timestamps,
scores: state.analysisResult
};
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "aim-session-" + new Date().toISOString().slice(0, 10) + ".json";
a.click();
URL.revokeObjectURL(url);
}
/* ===== Reset ===== */
function resetSession() {
state.mode = "TUTOR";
state.topic = "";
state.phase = 0;
state.phaseTurns = 0;
state.history = [];
state.timestamps = [];
state.analysisResult = null;
state.uploadedDocs = [];
topicInput.value = "";
chatInput.value = "";
messagesEl.querySelectorAll(".message").forEach(function (el) { el.remove(); });
uploadList.innerHTML = "";
uploadStatus.textContent = "";
modeBtns.forEach(function (btn) {
btn.classList.toggle("selected", btn.dataset.mode === "TUTOR");
});
btnStart.disabled = true;
btnEnd.disabled = false;
btnSend.disabled = false;
// Load existing documents
loadDocumentList();
showScreen(setupScreen);
}
/* ===== Load existing documents on page load ===== */
function loadDocumentList() {
fetch("/api/documents")
.then(function (res) { return res.json(); })
.then(function (data) {
state.uploadedDocs = (data.documents || []).map(function (d) {
return { filename: d.filename, chunks: "?" };
});
renderUploadList();
})
.catch(function () {});
}
/* ===== Event listeners ===== */
// Mode selection
modeBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
modeBtns.forEach(function (b) { b.classList.remove("selected"); });
btn.classList.add("selected");
state.mode = btn.dataset.mode;
});
});
// Topic input enables start button
topicInput.addEventListener("input", function () {
btnStart.disabled = !topicInput.value.trim();
});
// Start session
btnStart.addEventListener("click", function () {
var topic = topicInput.value.trim();
if (!topic) return;
state.topic = topic;
state.phase = 0;
state.phaseTurns = 0;
state.history = [];
state.timestamps = [];
modeBadge.textContent = state.mode === "TUTOR" ? t("modeBadgeTutor") : t("modeBadgeCritic");
topicBadge.textContent = topic;
// Show doc count badge
if (state.uploadedDocs.length > 0) {
docsBadge.textContent = state.uploadedDocs.length + " doc(s)";
docsBadge.style.display = "inline-block";
} else {
docsBadge.style.display = "none";
}
renderPhaseIndicator();
showScreen(chatScreen);
chatInput.focus();
// Auto-send first message to get the companion's opening question
startSession();
});
function startSession() {
setTyping(true);
btnSend.disabled = true;
fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: t("startMessage") + state.topic,
mode: state.mode,
topic: state.topic,
phase: state.phase,
phase_turns: state.phaseTurns,
lang: currentLang,
history: []
})
})
.then(function (res) {
if (!res.ok) {
return res.json().then(function (err) {
throw new Error(err.error || "Erreur serveur " + res.status);
});
}
return res.json();
})
.then(function (data) {
setTyping(false);
if (!data.reply) {
addMessage("assistant", t("errorEmpty"));
btnSend.disabled = false;
return;
}
state.phase = data.phase;
state.phaseTurns = data.phase_turns || 0;
state.history.push({ role: "assistant", content: data.reply });
state.timestamps.push(Date.now());
addMessage("assistant", data.reply);
renderPhaseIndicator();
btnSend.disabled = false;
chatInput.focus();
})
.catch(function (err) {
setTyping(false);
console.error("startSession error:", err);
addMessage("assistant", err.message || t("errorConnection"));
btnSend.disabled = false;
});
}
// Send message
btnSend.addEventListener("click", function () {
var text = chatInput.value.trim();
if (text) sendMessage(text);
});
chatInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
var text = chatInput.value.trim();
if (text) sendMessage(text);
}
});
// End session -> analysis
btnEnd.addEventListener("click", function () {
if (state.history.length === 0) {
alert(t("errorNoExchange"));
return;
}
requestAnalysis();
});
// Reset
btnReset.addEventListener("click", resetSession);
// Export JSON
btnExport.addEventListener("click", exportJSON);
// New session from analysis screen
btnNewSession.addEventListener("click", resetSession);
// Load existing docs on startup
loadDocumentList();
// Apply default language
applyLanguage();
})();