From 4bf500ad47a7a84529ae354a155ad3e084f4f39b Mon Sep 17 00:00:00 2001 From: Meleki Date: Mon, 2 Mar 2026 06:52:26 +0100 Subject: [PATCH] feat: Stream-Abbruch per Stop-Button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- TODO.md | 13 +++++++--- wwwroot/css/chat.css | 8 +++++++ wwwroot/css/placeholder.css | 8 ++++++- wwwroot/css/theme.css | 2 +- wwwroot/index.html | 1 + wwwroot/js/api.js | 5 ++-- wwwroot/js/chat.js | 45 +++++++++++++++++++++++++---------- wwwroot/js/pages/chat-page.js | 13 +++++++--- 8 files changed, 73 insertions(+), 22 deletions(-) diff --git a/TODO.md b/TODO.md index d39eb9d..16fb6b7 100644 --- a/TODO.md +++ b/TODO.md @@ -1,13 +1,15 @@ # TODOs ## Features -- [ ] Skills: In Datenbank anlegen können, mit Pfad zu Skill-Dateien; im Frontend am Dashboard dargestellt. Zuweisung an Agent und/oder Session. +- [x] Skills: In Datenbank anlegen können, mit Pfad zu Skill-Dateien; im Frontend am Dashboard dargestellt. Zuweisung an Agent und/oder Session. - [x] System-Prompt: Nachrichten einer Session als Referenz und somit in den System-Prompt aufnehmen -- [ ] Memory: Embedding-Modell für semantische Suche über Memory-Dateien (Vektor-basiert + BM25 Hybrid Search) -- [ ] Subagents: Möglichkeit Subagents zu starten und zu orchestrieren - [x] Im Chat: Menüpunkt "Datenbank sichern" entfernen und stattdessen in den Einstellungen platzieren. Außerdem soll nun nicht mehr nur die Datenbank gesichert werden, sondern der gesamte data-Ordner (der Unterordner backup muss natürlich exkludiert werden) - [x] Agents-Formular (nur beim Bearbeiten von vorhandenen Agent): Button der das File-Explorer Modal öffnet bei dem ich den Agent-Ordner sehe. - [x] Chat: Maximal 500 Nachrichten laden. Dauert sonst zu lange und der Browser wird langsam +- [ ] User-Management: Tabelle in Datenbank mit username / password. Wenn kein User vorhanden: dann admin mit password "admin" anlegen +- [ ] Neuer Message-Tag: `[canvas::<height (optional)>]...[/canvas]` +- [ ] Websearch Tool? +- [ ] Anfrage/Stream abbrechen ## Bugs - [x] Wenn gerade gestreamt wird und man refreshed die Seite, dann verliert das Frontend den Status "Streaming..." und ich kann eine Nachricht schreiben, obwohl das nicht möglich sein sollte @@ -21,3 +23,8 @@ - [x] Chat: User-Nachrichten mit dem Tag [Datei: pfat/zur/datei.xyz:1-10] wird nicht als HTML geparsed - [x] Chat: Wird zwar erkannt und ist klickbar, aber es öffnet sich einfach nur der File Explorer, ohne dass sich die Datei öffnet und die Darstellung in Monospace ist auch nicht schön - [x] Chat: Es werden momentan alle Tool-Calls angezeigt - die Anzeige soll individuell in den Einstellungen gemacht werden können. Auswahl aus drei Stufen: "Alle anzeigen" / "Nur Modifikationen" (file änderungen, memory änderungen, etc.) / "Verstecken" +- [ ] Browser: "Zurück"-Taste soll auf den vorherigen location.hash wechseln + +## Ideas / Backlog +- [ ] Memory: Embedding-Modell für semantische Suche über Memory-Dateien (Vektor-basiert + BM25 Hybrid Search) +- [ ] Subagents: Möglichkeit Subagents zu starten und zu orchestrieren diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index dafc8c3..8eef8e8 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -240,6 +240,14 @@ +.stop-btn { + color: var(--dangerous); +} + +.stop-btn:hover { + background: rgb(from var(--dangerous) r g b / 0.15); +} + .input-area textarea { flex: 1; min-height: 0; diff --git a/wwwroot/css/placeholder.css b/wwwroot/css/placeholder.css index c508de1..7894262 100644 --- a/wwwroot/css/placeholder.css +++ b/wwwroot/css/placeholder.css @@ -8,8 +8,13 @@ gap: var(--page-spacing); } +.settings-section { + display: flex; + flex-direction: column; + gap: var(--badge-spacing); +} + .settings-section h2 { - margin-bottom: var(--box-spacing); padding-bottom: var(--badge-spacing); border-bottom: var(--border-width) solid var(--f2-border); } @@ -73,4 +78,5 @@ .settings-reset { border-top: var(--border-width) solid var(--f2-border); padding-top: var(--box-spacing); + flex-direction: row; } diff --git a/wwwroot/css/theme.css b/wwwroot/css/theme.css index 521906f..f0ec73c 100644 --- a/wwwroot/css/theme.css +++ b/wwwroot/css/theme.css @@ -22,7 +22,7 @@ --page-padding: 2em; --page-spacing: 2em; --box-padding: .75em 1em; - --box-spacing: 1em; + --box-spacing: 1.5em; --input-padding: .45em .7em; --button-padding: .45em .8em; --badge-padding: .2em .6em; diff --git a/wwwroot/index.html b/wwwroot/index.html index 87bcd5e..747cb67 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -186,6 +186,7 @@ <button id="explorerBtn" class="icon-btn" title="Workspace durchsuchen"><i class="fa fa-folder"></i></button> <textarea id="input" placeholder="Nachricht..." rows="1"></textarea> <button id="send" class="icon-btn"><i class="fa fa-paper-plane"></i></button> + <button id="stop" class="icon-btn stop-btn" hidden title="Antwort abbrechen"><i class="fa fa-stop"></i></button> </div> </div> </div> diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 13bbed2..d28d1e7 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -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 }); } diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 3cf74f1..12ec9db 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -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(); diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index beff8a1..57128bb 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -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); }