/* 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; let sending = false; let abortController = null; // Bumped by "New chat" — a send started before the bump must not write // its late results into the conversation that replaced it. let chatGeneration = 0; // Conversations survive a reload but not a closed tab, and are scoped to // the logged-in user so a shared device never shows someone else's lesson. const CHAT_KEY = `fg_chat_${user?.id ?? 'anon'}`; // ── 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; } /** * Read an SSE stream to completion, calling onToken for every token. * * Buffers across reads: a chunk boundary can fall anywhere, splitting a * multi-byte character (ä, ö, ü, ß) or a whole event across two reads. */ function saveHistory() { try { sessionStorage.setItem(CHAT_KEY, JSON.stringify(history.slice(-40))); } catch (e) { console.warn('[Chat] Could not save conversation:', e); } } function restoreHistory() { let saved = []; try { saved = JSON.parse(sessionStorage.getItem(CHAT_KEY) || '[]'); } catch (e) { return; } if (!Array.isArray(saved) || saved.length === 0) return; history = saved; for (const msg of saved) { if (msg?.role && typeof msg.content === 'string') { appendMessage(msg.role, msg.content); } } } function startNewChat() { chatGeneration++; abortController?.abort(); voice.stopPlayback(); abortController = null; setSending(false); history = []; try { sessionStorage.removeItem(CHAT_KEY); } catch (e) { /* storage may be blocked */ } messagesEl.querySelectorAll('.message').forEach(el => el.remove()); inputEl.focus(); } /** Toggle the composer between "Send" and "Stop". */ function setSending(on) { sending = on; sendBtn.textContent = on ? 'Stop' : 'Send'; sendBtn.classList.toggle('btn-danger', on); sendBtn.classList.toggle('btn-primary', !on); } async function readSSE(response, onToken) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let finished = false; const handleEvent = (event) => { for (const line of event.split('\n')) { if (!line.startsWith('data: ')) continue; const data = line.slice(6).trim(); if (data === '[DONE]') { finished = true; return; } try { const parsed = JSON.parse(data); if (parsed.token) onToken(parsed.token); if (parsed.error) showToast(parsed.error, 'error'); } catch (err) { console.warn('[Chat] Unparseable SSE data:', data); } } }; while (!finished) { const { done, value } = await reader.read(); if (done) { buffer += decoder.decode(); // flush any pending bytes if (buffer.trim()) handleEvent(buffer); break; } buffer += decoder.decode(value, { stream: true }); // Events are separated by a blank line — keep the trailing fragment. const events = buffer.split('\n\n'); buffer = events.pop(); for (const event of events) { handleEvent(event); if (finished) break; } } try { await reader.cancel(); } catch (err) { /* already closed */ } } async function sendMessage() { if (sending) return; const text = inputEl.value.trim(); if (!text) return; setSending(true); const controller = new AbortController(); abortController = controller; const generation = chatGeneration; const isCurrent = () => generation === chatGeneration; voice.lastInputWasVoice = false; inputEl.value = ''; 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) }), signal: controller.signal, }); if (!response?.ok) { const errData = await response?.json().catch(() => ({})); throw new Error(errData.detail || `Chat failed (${response?.status})`); } if (voiceModeOn) { // Buffer the whole reply, fetch its audio, then reveal text and // player together — "Thinking..." stays up until audio is ready. await readSSE(response, (token) => { fullResponse += token; }); if (!isCurrent()) return; assistantEl.classList.remove('message-thinking'); if (fullResponse) { history.push({ role: 'assistant', content: fullResponse }); saveHistory(); // Same signal as the chat request, so Stop cancels TTS too const audioUrl = await voice.fetchAudio(fullResponse, controller.signal); if (!isCurrent()) return; // The reply itself is complete — show it even when Stop // cancelled the audio assistantEl.innerHTML = renderMarkdown(fullResponse); messagesEl.scrollTop = messagesEl.scrollHeight; if (audioUrl && !controller.signal.aborted) { // Re-enable sending before playback: playAudio() only // settles on ended/error, so a paused player would // otherwise keep chat locked until a reload. setSending(false); await voice.playAudio(audioUrl, assistantEl); } } else { assistantEl.textContent = 'No response received. Please try again.'; } } else { // Text mode: stream straight into the bubble await readSSE(response, (token) => { fullResponse += token; assistantEl.innerHTML = renderMarkdown(fullResponse); messagesEl.scrollTop = messagesEl.scrollHeight; }); if (!isCurrent()) return; if (fullResponse) { history.push({ role: 'assistant', content: fullResponse }); saveHistory(); } else { assistantEl.textContent = 'No response received. Please try again.'; } } } catch (e) { if (e.name === 'AbortError') { // Aborted by "New chat": that conversation is gone, discard this if (!isCurrent()) { assistantEl.remove(); return; } // User pressed Stop — keep the partial reply, drop an empty one assistantEl.classList.remove('message-thinking'); if (fullResponse) { assistantEl.innerHTML = renderMarkdown(fullResponse); history.push({ role: 'assistant', content: fullResponse }); saveHistory(); } else { assistantEl.remove(); } } else { assistantEl.textContent = 'Sorry, something went wrong. Please try again.'; showToast(e.message, 'error'); console.error('[Chat] Error:', e); } } finally { // In voice mode this runs after playback, by which time a newer // send may already own the composer — don't clobber its state. if (abortController === controller) { abortController = null; setSending(false); } if (isCurrent()) saveHistory(); inputEl.focus(); } } sendBtn.addEventListener('click', () => { if (sending) { abortController?.abort(); return; } sendMessage(); }); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); document.getElementById('new-chat-btn')?.addEventListener('click', startNewChat); // Logout document.getElementById('logout-btn').addEventListener('click', logout); // Bring back this session's conversation, if there is one restoreHistory(); });