document.addEventListener('DOMContentLoaded', () => {
const chatEl = document.getElementById('chat');
const promptEl = document.getElementById('prompt');
const sendBtn = document.getElementById('send');
const abortBtn = document.getElementById('abort');
const clearBtn = document.getElementById('clear');
let abort = false;
marked.setOptions({ breaks: true });
function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text));
}
function addMessage(role, html = '', extra = '') {
const msg = document.createElement('div');
msg.className = 'message ' + role;
const bubble = document.createElement('div');
bubble.className = 'bubble ' + extra;
bubble.innerHTML = html;
msg.appendChild(bubble);
chatEl.appendChild(msg);
chatEl.scrollTop = chatEl.scrollHeight;
return bubble;
}
function addLoader() {
return addMessage('assistant', 'AI is thinking…', 'loader');
}
async function loadHistory() {
try {
const res = await fetch('/history');
if (!res.ok) return;
const messages = await res.json();
messages.forEach(m =>
addMessage(m.role, renderMarkdown(m.text))
);
} catch {}
}
loadHistory();
sendBtn.addEventListener('click', async () => {
const prompt = promptEl.value.trim();
if (!prompt) return;
addMessage('user', renderMarkdown(prompt));
promptEl.value = '';
const bubble = addLoader();
let raw = '';
let firstChunk = true;
let renderTimer = null;
// 🔥 LÖSUNG: Throttled Rendering - maximal alle 100ms
function scheduleRender() {
if (renderTimer) return;
renderTimer = setTimeout(() => {
// Der StreamChunker sendet bereits korrekt strukturierte Chunks
bubble.innerHTML = renderMarkdown(raw);
chatEl.scrollTop = chatEl.scrollHeight;
renderTimer = null;
}, 100);
}
abort = false;
sendBtn.disabled = true;
abortBtn.disabled = false;
clearBtn.disabled = true;
try {
document.getElementById('ai-cloud')?.classList.remove('d-none');
const res = await fetch('/ask-sse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let sseBuffer = '';
while (!abort) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
sseBuffer += chunk;
// Parse SSE Events (können mehrere data:-Zeilen haben)
const events = sseBuffer.split('\n\n');
sseBuffer = events.pop() || ''; // Letzten unvollständigen Event behalten
events.forEach(event => {
if (!event.trim()) return;
// Sammle alle "data:"-Zeilen und fĂĽge \n wieder ein
const dataLines = event
.split('\n')
.filter(line => line.startsWith('data: '))
.map(line => line.slice(6));
if (dataLines.length === 0) return;
// Verbinde mit \n (so wie es vom Backend kam)
const text = dataLines.join('\n');
if (text === '[DONE]') {
// 🔥 Final render flush
if (renderTimer) {
clearTimeout(renderTimer);
renderTimer = null;
}
bubble.innerHTML = renderMarkdown(raw);
chatEl.scrollTop = chatEl.scrollHeight;
abort = true;
return;
}
if (firstChunk) {
bubble.classList.remove('loader');
bubble.innerHTML = '';
firstChunk = false;
document.getElementById('ai-cloud')?.classList.add('d-none');
}
// Text sammeln und verzögert rendern
raw += text;
scheduleRender();
});
}
} catch {
bubble.innerHTML += '
Error occurred.';
} finally {
if (renderTimer) clearTimeout(renderTimer);
sendBtn.disabled = false;
abortBtn.disabled = true;
clearBtn.disabled = false;
}
});
abortBtn.addEventListener('click', () => {
abort = true;
addMessage('assistant', '[aborted]');
});
clearBtn.addEventListener('click', async () => {
await fetch('/history/delete', { method: 'POST' });
chatEl.innerHTML = '';
addMessage('assistant', 'History cleared.');
});
});