Files
language-llm/frontend/js/chat.js
Dennis Thiessen 84cd052ded
All checks were successful
Deploy FluentGerman.ai / deploy (push) Successful in 53s
bugfix speech-to-text and implement avatar and personal greeting
2026-02-16 20:11:09 +01:00

204 lines
7.3 KiB
JavaScript

/* FluentGerman.ai — Chat interface logic */
// Configure marked for safe rendering
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub-flavored markdown
});
/**
* Render markdown text to sanitized HTML.
*/
function renderMarkdown(text) {
const raw = marked.parse(text);
return DOMPurify.sanitize(raw);
}
/**
* Format a date as a friendly relative time string.
*/
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;
const messagesEl = document.getElementById('chat-messages');
const inputEl = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
const voiceBtn = document.getElementById('voice-btn');
let history = [];
// ── Personalised welcome ──────────────────────────────────────────
const greetingEl = document.getElementById('welcome-greeting');
const subtitleEl = document.getElementById('welcome-subtitle');
const metaEl = document.getElementById('welcome-meta');
// Immediately show the username we already have from localStorage
greetingEl.textContent = `Hallo, ${displayName}! 👋`;
// Fetch dashboard info for latest instruction data
try {
const resp = await api('/chat/dashboard');
if (resp?.ok) {
const data = await resp.json();
// Use server username (authoritative)
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 info:', e);
}
// ── Voice ─────────────────────────────────────────────────────────
const voice = new VoiceManager();
await voice.init();
voice.onResult = (text) => {
inputEl.value = text;
voice.lastInputWasVoice = true;
sendMessage();
};
voice.onStateChange = (recording) => {
voiceBtn.classList.toggle('recording', recording);
voiceBtn.textContent = recording ? '⏹' : '🎤';
};
voiceBtn.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;
// Capture whether this was a voice input BEFORE clearing
const wasVoice = voice.lastInputWasVoice;
voice.lastInputWasVoice = false;
inputEl.value = '';
sendBtn.disabled = true;
appendMessage('user', text);
history.push({ role: 'user', content: text });
// Create assistant message placeholder
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;
// Live-render markdown as tokens stream in
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 response if the user used voice input
if (wasVoice) {
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);
});