409 lines
15 KiB
JavaScript
409 lines
15 KiB
JavaScript
/* 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 = `<span class="meta-dot"></span> Lessons last updated <strong>${ago}</strong>`;
|
|
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 = '<span class="thinking-dots">Thinking<span>.</span><span>.</span><span>.</span></span>';
|
|
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();
|
|
});
|