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(); promptEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); // verhindert normalen Zeilenumbruch sendBtn.click(); // löst vorhandene Send-Logik aus } }); 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.'); }); });