/* 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' }); } // Avatar — DiceBear "avataaars" style for friendly illustrated headshots const AVATAR_SEEDS = ['Felix', 'Lena', 'Hans', 'Sophie', 'Klaus', 'Marta', 'Otto', 'Emma']; document.addEventListener('DOMContentLoaded', async () => { if (!requireAuth()) return; const user = getUser(); const displayName = user?.username || 'User'; document.getElementById('user-name').textContent = displayName; // Random avatar from DiceBear const avatarImg = document.getElementById('avatar-img'); const seed = AVATAR_SEEDS[Math.floor(Math.random() * AVATAR_SEEDS.length)]; avatarImg.src = `https://api.dicebear.com/9.x/avataaars/svg?seed=${seed}&backgroundColor=b6e3f4,c0aede,d1d4f9`; 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); }; micBtn.addEventListener('click', () => voice.toggleRecording()); // ── Chat ────────────────────────────────────────────────────────── function appendMessage(role, content) { const div = document.createElement('div'); div.className = `message message-${role}`; if (role === 'assistant') { 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; const wasVoice = voice.lastInputWasVoice; voice.lastInputWasVoice = false; inputEl.value = ''; sendBtn.disabled = true; appendMessage('user', text); history.push({ role: 'user', content: text }); const assistantEl = appendMessage('assistant', ''); 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(); 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 }); // Auto-speak if voice mode is ON (regardless of input method) if (voiceModeOn) { await voice.speak(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); });