/* FluentGerman.ai — Chat interface logic */ // Configure marked for safe rendering marked.setOptions({ breaks: true, gfm: true, }); function renderMarkdown(text) { const raw = marked.parse(text); return DOMPurify.sanitize(raw); } function relativeTime(dateStr) { if (!dateStr) return null; const date = new Date(dateStr); const now = new Date(); const diffMs = now - date; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return 'just now'; if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; if (diffDays === 1) return 'yesterday'; if (diffDays < 30) return `${diffDays} days ago`; return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); } document.addEventListener('DOMContentLoaded', async () => { if (!requireAuth()) return; const user = getUser(); const displayName = user?.username || 'User'; document.getElementById('user-name').textContent = displayName; // Deterministic avatar based on username (tutor1.jpg - tutor5.jpg) const avatarImg = document.getElementById('avatar-img'); // Simple hash function for username let hash = 0; for (let i = 0; i < displayName.length; i++) { hash = displayName.charCodeAt(i) + ((hash << 5) - hash); } // Map hash to index 1-5 const avatarIndex = (Math.abs(hash) % 5) + 1; avatarImg.src = `/img/tutor${avatarIndex}.jpg`; const messagesEl = document.getElementById('chat-messages'); const inputEl = document.getElementById('chat-input'); const sendBtn = document.getElementById('send-btn'); const micBtn = document.getElementById('mic-btn'); const voiceToggle = document.getElementById('voice-toggle-input'); let history = []; let voiceModeOn = false; // ── Personalised welcome ────────────────────────────────────────── const greetingEl = document.getElementById('welcome-greeting'); const subtitleEl = document.getElementById('welcome-subtitle'); const metaEl = document.getElementById('welcome-meta'); greetingEl.textContent = `Hallo, ${displayName}! 👋`; try { const resp = await api('/chat/dashboard'); if (resp?.ok) { const data = await resp.json(); greetingEl.textContent = `Hallo, ${data.username}! 👋`; if (data.latest_instruction_at) { const ago = relativeTime(data.latest_instruction_at); metaEl.innerHTML = ` Lessons last updated ${ago}`; metaEl.classList.add('visible'); } else { metaEl.textContent = 'No custom lessons configured yet'; metaEl.classList.add('visible'); } } } catch (e) { console.warn('[Chat] Could not fetch dashboard:', e); } // ── Voice ───────────────────────────────────────────────────────── const voice = new VoiceManager(); await voice.init(); // Voice toggle handler voiceToggle.addEventListener('change', () => { voiceModeOn = voiceToggle.checked; if (voiceModeOn) { if (voice.isDisabled) { showToast('Voice requires Chrome or Edge (HTTPS).', 'error'); voiceToggle.checked = false; voiceModeOn = false; return; } micBtn.classList.remove('hidden'); inputEl.placeholder = 'Voice mode ON — click the mic to speak...'; } else { micBtn.classList.add('hidden'); inputEl.placeholder = 'Type your message...'; // Stop any active recording if (voice.isRecording) voice.stopRecording(); } }); voice.onResult = (text) => { inputEl.value = text; voice.lastInputWasVoice = true; sendMessage(); }; voice.onStateChange = (recording) => { micBtn.classList.toggle('recording', recording); }; // Show "Transcribing..." state voice.onProcessing = (processing) => { if (processing) { inputEl.placeholder = 'Transcribing...'; inputEl.disabled = true; } else { inputEl.placeholder = voiceModeOn ? 'Voice mode ON — click the mic to speak...' : 'Type your message...'; inputEl.disabled = false; inputEl.focus(); } }; micBtn.addEventListener('click', () => voice.toggleRecording()); // ── Chat ────────────────────────────────────────────────────────── function appendMessage(role, content) { const div = document.createElement('div'); div.className = `message message-${role}`; if (role === 'assistant') { // content might be empty initially for thinking state if (content === 'Thinking...') { div.innerHTML = 'Thinking...'; div.classList.add('message-thinking'); } else { div.innerHTML = renderMarkdown(content); } } else { div.textContent = content; } messagesEl.appendChild(div); messagesEl.scrollTop = messagesEl.scrollHeight; return div; } async function sendMessage() { const text = inputEl.value.trim(); if (!text) return; voice.lastInputWasVoice = false; inputEl.value = ''; sendBtn.disabled = true; appendMessage('user', text); history.push({ role: 'user', content: text }); const assistantEl = appendMessage('assistant', voiceModeOn ? 'Thinking...' : ''); let fullResponse = ''; try { const response = await api('/chat/', { method: 'POST', body: JSON.stringify({ message: text, history: history.slice(-20) }), }); if (!response?.ok) { const errData = await response?.json().catch(() => ({})); throw new Error(errData.detail || `Chat failed (${response?.status})`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); // Special handling for Voice Mode: Buffer text, wait for TTS, then show & play if (voiceModeOn) { // "Thinking..." is already shown from appendMessage above while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6).trim(); if (data === '[DONE]') break; try { const parsed = JSON.parse(data); if (parsed.token) fullResponse += parsed.token; if (parsed.error) showToast(parsed.error, 'error'); } catch (e) { } } } } // Text complete. Now fetch audio. if (fullResponse) { history.push({ role: 'assistant', content: fullResponse }); // Keep "Thinking..." until audio is ready or failed const audioUrl = await voice.fetchAudio(fullResponse); // Visual update: Remove thinking, show text assistantEl.classList.remove('message-thinking'); assistantEl.innerHTML = renderMarkdown(fullResponse); messagesEl.scrollTop = messagesEl.scrollHeight; if (audioUrl) { await voice.playAudio(audioUrl, assistantEl); } } } else { // Normal Text Mode: Stream directly to UI while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6).trim(); if (data === '[DONE]') break; try { const parsed = JSON.parse(data); if (parsed.token) { fullResponse += parsed.token; assistantEl.innerHTML = renderMarkdown(fullResponse); messagesEl.scrollTop = messagesEl.scrollHeight; } if (parsed.error) { showToast(parsed.error, 'error'); } } catch (e) { // skip unparseable chunks } } } } if (fullResponse) { history.push({ role: 'assistant', content: fullResponse }); } } } catch (e) { assistantEl.textContent = 'Sorry, something went wrong. Please try again.'; showToast(e.message, 'error'); console.error('[Chat] Error:', e); } sendBtn.disabled = false; inputEl.focus(); } sendBtn.addEventListener('click', sendMessage); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); // Logout document.getElementById('logout-btn').addEventListener('click', logout); });