feat: Stream-Abbruch per Stop-Button

- Neuer Stop-Button (#stop) neben Send-Button, erscheint nur während Stream
- AbortController in chat.js: abort() schließt SSE-Verbindung → Backend CancellationToken triggered
- apiChat() akzeptiert optionales AbortSignal
- AbortError-Handling: zeigt 'Abgebrochen' statt Fehlermeldung
- Button-Toggle in sendMessage(), stopStreaming(), startStreamingPoll(), Page-Load-Recovery
- CSS: .stop-btn mit --dangerous Farbe
- Diverse kleine UI-Fixes (TODO.md, theme.css box-spacing, settings-section Layout)
This commit is contained in:
Meleki committed 2026-03-02 06:52:26 +01:00
1 parent 190a0d0a8b
commit 4bf500ad47
8 files changed
+73 -22

No files matched your search

+3 -2
View File
@@ -1,10 +1,11 @@
// ── API Calls ──
async function apiChat(messages, sessionId) {
async function apiChat(messages, sessionId, signal) {
return fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, sessionId })
body: JSON.stringify({ messages, sessionId }),
signal
});
}
+33 -12
View File
@@ -53,6 +53,7 @@ let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen
let pendingReply = null; // { index, role, text } - Zitierte Nachricht
let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event
let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft
let _abortController = null; // AbortController für laufenden Stream
async function clearChat() {
const archive = await apiArchive(getCurrentSessionId());
@@ -70,6 +71,12 @@ async function clearChat() {
showArchiveLoadButton();
}
function stopStreaming() {
if (_abortController) {
_abortController.abort();
}
}
// ── Send Message ──
async function sendMessage() {
@@ -129,7 +136,11 @@ async function sendMessage() {
input.value = '';
input.dispatchEvent(new Event('input'))
btn.disabled = true;
btn.hidden = true;
const stopBtn = $('#stop');
stopBtn.hidden = false;
isStreaming = true;
_abortController = new AbortController();
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
let fullText = '';
@@ -138,7 +149,7 @@ async function sendMessage() {
_chatAbandoned = false;
try {
const resp = await apiChat(messages, getCurrentSessionId());
const resp = await apiChat(messages, getCurrentSessionId(), _abortController.signal);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@@ -228,23 +239,33 @@ async function sendMessage() {
// Bei Navigation nichts tun – der neue Page-Init übernimmt
if (_chatAbandoned) return;
endStreamBubble();
// Prüfen ob Backend noch streamt (z.B. bei Netzwerkfehler)
try {
const status = await apiIsStreaming(getCurrentSessionId());
if (status.streaming) {
// Backend streamt noch weiter – Recovery-Poll starten
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming... (Verbindung wiederhergestellt)</span>');
startStreamingPoll(getCurrentSessionId(), btn, status.chainId);
return;
}
} catch (_) {}
addErrorBubble('Netzwerk-Fehler: ' + e.message);
// User hat Stream abgebrochen
if (e.name === 'AbortError') {
setMeta('Abgebrochen');
// Kurz warten damit Backend den Disconnect merkt
await new Promise(r => setTimeout(r, 500));
} else {
// Prüfen ob Backend noch streamt (z.B. bei Netzwerkfehler)
try {
const status = await apiIsStreaming(getCurrentSessionId());
if (status.streaming) {
// Backend streamt noch weiter – Recovery-Poll starten
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming... (Verbindung wiederhergestellt)</span>');
startStreamingPoll(getCurrentSessionId(), btn, status.chainId);
return;
}
} catch (_) {}
addErrorBubble('Netzwerk-Fehler: ' + e.message);
}
} finally {
// Bei Navigation nichts aufräumen – der neue Page-Init übernimmt
if (_chatAbandoned) return;
if (!isStreaming || !_streamingPollInterval) {
btn.disabled = false;
btn.hidden = false;
stopBtn.hidden = true;
isStreaming = false;
_abortController = null;
}
endStreamBubble();
+10 -3
View File
@@ -18,7 +18,10 @@ function startStreamingPoll(sessionId, sendBtn, chainId) {
clearInterval(_streamingPollInterval);
_streamingPollInterval = null;
isStreaming = false;
if (sendBtn) sendBtn.disabled = false;
_abortController = null;
if (sendBtn) { sendBtn.disabled = false; sendBtn.hidden = false; }
const stopEl = document.getElementById('stop');
if (stopEl) stopEl.hidden = true;
// Finale Nachrichten nochmal laden
await pollHistory(true);
@@ -166,9 +169,11 @@ registerPage('chat', {
backBtn.addEventListener('click', () => navigateTo('dashboard'));
}
// Send
// Send / Stop
const sendBtn = document.getElementById('send');
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
const stopBtn = document.getElementById('stop');
if (stopBtn) stopBtn.addEventListener('click', stopStreaming);
const inputEl = document.getElementById('input');
if (inputEl) {
@@ -255,7 +260,9 @@ registerPage('chat', {
const status = await apiIsStreaming(sessionId);
if (status.streaming) {
isStreaming = true;
if (sendBtn) sendBtn.disabled = true;
if (sendBtn) { sendBtn.disabled = true; sendBtn.hidden = true; }
const stopEl = document.getElementById('stop');
if (stopEl) stopEl.hidden = false;
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
startStreamingPoll(sessionId, sendBtn, status.chainId);
}