/* Was nur Bifröst braucht: Streaming der Antworten per SSE (→ Konzept 4.4). Kein Inline-Skript (strenge CSP); Texte nur über textContent, nie als HTML. */ var STATUS_TEXT = { queued: 'wartet', running: 'läuft', completed: 'fertig', failed: 'fehlgeschlagen', cancelled: 'abgebrochen', waiting_approval: 'wartet auf Freigabe', waiting_input: 'wartet auf Antwort', blocked_by_guardrail: 'vom Gateway blockiert', needs_reconciliation: 'Abgleich nötig' }; //
: Antwort live anzeigen. // EventSource verbindet sich selbst neu und schickt dabei Last-Event-ID; der Server liefert ab dort. function attachStreams(root) { root.querySelectorAll('[data-stream]:not([data-attached])').forEach(function (card) { card.setAttribute('data-attached', ''); var text = card.querySelector('[data-text]'); var badge = card.querySelector('[data-status]'); var source = new EventSource(card.dataset.stream); function payload(e) { return JSON.parse(e.data); } function showStatus(status) { badge.textContent = STATUS_TEXT[status] || status; } function done() { source.close(); htmx.ajax('GET', card.dataset.answer, { target: card, swap: 'outerHTML' }); // z. B. die Ansicht „Änderungen“ nach neuen Commits aktualisieren document.body.dispatchEvent(new CustomEvent('aiw:run-done')); } var toolList = card.querySelector('[data-tools]'); var TOOL_STATUS = { running: '…', ok: '✓', error: 'Fehler', denied: 'abgewiesen', pending: 'wartet auf Freigabe', approved: 'freigegeben' }; source.addEventListener('token', function (e) { text.textContent += payload(e).text; }); // Tool-Schritte: Name und Kurzform der Argumente, Status wird aktualisiert source.addEventListener('tool', function (e) { var data = payload(e); var item = toolList.querySelector('[data-call="' + CSS.escape(data.call_id) + '"]'); if (!item) { item = document.createElement('li'); item.setAttribute('data-call', data.call_id); var name = document.createElement('code'); name.textContent = data.name; var state = document.createElement('span'); state.className = 'aiw-tool-state'; item.append(name, ' ', document.createTextNode(data.args || ''), ' ', state); toolList.append(item); toolList.hidden = false; } if (data.name && !item.querySelector('code').textContent) item.querySelector('code').textContent = data.name; item.setAttribute('data-status', data.status); item.querySelector('.aiw-tool-state').textContent = TOOL_STATUS[data.status] || data.status; }); source.addEventListener('status', function (e) { var data = payload(e); showStatus(data.status); // Neuer Versuch nach einem Fehler: die Teilantwort des alten Versuchs verwerfen if (data.status === 'queued' && data.reason) { text.textContent = ''; toolList.replaceChildren(); toolList.hidden = true; } }); source.addEventListener('message', done); source.addEventListener('end', function (e) { showStatus(payload(e).status); done(); }); }); } document.addEventListener('DOMContentLoaded', function () { attachStreams(document); }); document.addEventListener('htmx:afterSettle', function (e) { attachStreams(e.target); }); // Chat: Verlauf beim Öffnen unten zeigen; neue Inhalte mitscrollen, solange man unten ist function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 160; } function toBottom() { window.scrollTo(0, document.documentElement.scrollHeight); } document.addEventListener('DOMContentLoaded', function () { var chat = document.querySelector('[data-chat]'); if (!chat) return; if (!location.hash || location.hash === '#messages-end') toBottom(); var follow = true; window.addEventListener('scroll', function () { follow = nearBottom(); }, { passive: true }); new MutationObserver(function () { if (follow) toBottom(); }) .observe(document.getElementById('messages'), { childList: true, subtree: true, characterData: true }); }); //
: nach erfolgreichem Absenden leeren document.addEventListener('htmx:afterRequest', function (e) { var form = e.detail.elt; if (form.matches && form.matches('form[data-reset-on-success]') && e.detail.successful && e.detail.xhr.status < 300) { form.reset(); } }); // bzw.