Improve deployment safety and chat reliability
Deploy FluentGerman.ai / deploy (push) Successful in 1m25s
Deploy FluentGerman.ai / deploy (push) Successful in 1m25s
This commit is contained in:
+191
-65
@@ -58,6 +58,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
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');
|
||||
@@ -156,14 +165,123 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
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 = '';
|
||||
sendBtn.disabled = true;
|
||||
|
||||
appendMessage('user', text);
|
||||
history.push({ role: 'user', content: text });
|
||||
@@ -175,6 +293,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const response = await api('/chat/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message: text, history: history.slice(-20) }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response?.ok) {
|
||||
@@ -182,94 +301,96 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
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
|
||||
// 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;
|
||||
});
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
assistantEl.classList.remove('message-thinking');
|
||||
|
||||
// Text complete. Now fetch audio.
|
||||
if (fullResponse) {
|
||||
history.push({ role: 'assistant', content: fullResponse });
|
||||
saveHistory();
|
||||
|
||||
// Keep "Thinking..." until audio is ready or failed
|
||||
const audioUrl = await voice.fetchAudio(fullResponse);
|
||||
// Same signal as the chat request, so Stop cancels TTS too
|
||||
const audioUrl = await voice.fetchAudio(fullResponse, controller.signal);
|
||||
if (!isCurrent()) return;
|
||||
|
||||
// Visual update: Remove thinking, show text
|
||||
assistantEl.classList.remove('message-thinking');
|
||||
// The reply itself is complete — show it even when Stop
|
||||
// cancelled the audio
|
||||
assistantEl.innerHTML = renderMarkdown(fullResponse);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
|
||||
if (audioUrl) {
|
||||
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 {
|
||||
// Normal Text Mode: Stream directly to UI
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// Text mode: stream straight into the bubble
|
||||
await readSSE(response, (token) => {
|
||||
fullResponse += token;
|
||||
assistantEl.innerHTML = renderMarkdown(fullResponse);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
});
|
||||
|
||||
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 (!isCurrent()) return;
|
||||
|
||||
if (fullResponse) {
|
||||
history.push({ role: 'assistant', content: fullResponse });
|
||||
saveHistory();
|
||||
} else {
|
||||
assistantEl.textContent = 'No response received. Please try again.';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
assistantEl.textContent = 'Sorry, something went wrong. Please try again.';
|
||||
showToast(e.message, 'error');
|
||||
console.error('[Chat] Error:', 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.disabled = false;
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', sendMessage);
|
||||
sendBtn.addEventListener('click', () => {
|
||||
if (sending) {
|
||||
abortController?.abort();
|
||||
return;
|
||||
}
|
||||
sendMessage();
|
||||
});
|
||||
inputEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -277,6 +398,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user