Ursache: WriteSseEvent in ProcessStream warf OperationCanceledException bei Client-Disconnect (Page-Refresh) → sseCt war gelinkt mit dem ASP.NET cancellationToken → Exception wurde im catch-Block als 'stopped' behandelt → Agent brach ab obwohl nur der Browser refreshed wurde. Fixes: - ProcessStream: Alle WriteSseEvent durch TryWriteSse ersetzt SSE-Writes bei Client-Disconnect werden jetzt zu stillen No-Ops statt den gesamten Agent-Flow per Exception zu killen - catch-Blöcke: OperationCanceledException nur bei workCt/hardCt als 'stopped' behandeln, neuer catch für Client-Disconnect als 'client_disconnected' (Fallback, sollte nicht mehr auftreten) - done-Event nach Stop: sseCt → CancellationToken.None damit das done-Event trotz gecancelltem sseCt noch gesendet wird - Frontend: done-Handler rendert Chain-Badge direkt im apiGetChain- Callback statt Race-Condition mit finally-Block - Frontend: 'stopped' Badge wieder angezeigt (war nur fälschlich bei Page-Refresh, jetzt korrekt nur bei echtem Stop)
369 lines
11 KiB
JavaScript
369 lines
11 KiB
JavaScript
// ── Helpers ──
|
||
|
||
function buildMetaBadges(d) {
|
||
const badges = [];
|
||
if (d.planMode) badges.push('📋 Plan-Modus');
|
||
badges.push(prettyModelName(d.model));
|
||
badges.push('Tokens: ' + (d.inputTokens || 0) + ' / ' + (d.outputTokens || 0));
|
||
if (d.cacheCreationTokens > 0 || d.cacheReadTokens > 0) {
|
||
badges.push('Cache: ' + (d.cacheReadTokens || 0) + ' / ' + (d.cacheCreationTokens || 0));
|
||
}
|
||
if ((d.iterations || 0) > 1) badges.push('Iterationen: ' + d.iterations);
|
||
if (d.durationMs) badges.push((d.durationMs / 1000).toFixed(1) + 's');
|
||
if (d.stopReason && d.stopReason !== 'end_turn' && d.stopReason !== 'tool_use') badges.push('Stop: ' + d.stopReason);
|
||
return badges;
|
||
}
|
||
|
||
function addChainMetaBubble(d, streamed) {
|
||
if (!d || !d.model) return;
|
||
// Chain noch nicht finalisiert (läuft noch) → nicht anzeigen
|
||
if (!d.inputTokens && !d.outputTokens && !d.durationMs) return;
|
||
if (!isChainMetaVisible(d)) return;
|
||
const div = _createChainMetaDiv(d, streamed ? 'streamed' : null);
|
||
chatEl().appendChild(div);
|
||
scrollDown();
|
||
}
|
||
|
||
// ── Stream Event Handler (shared between sendMessage + handleChoiceClick) ──
|
||
|
||
function handleStreamEvent(eventType, data) {
|
||
switch (eventType) {
|
||
case 'text_start':
|
||
if (!_streamDiv) startStreamBubble();
|
||
break;
|
||
|
||
case 'text_delta':
|
||
appendToStream(data.text);
|
||
break;
|
||
|
||
case 'text_end':
|
||
pauseStreamBubble();
|
||
break;
|
||
|
||
case 'tool_start':
|
||
endStreamBubble();
|
||
if (isToolVisible(data.name)) {
|
||
_currentToolBox = addToolBoxLoading(data.name);
|
||
} else {
|
||
_currentToolBox = null;
|
||
}
|
||
break;
|
||
|
||
case 'tool_end':
|
||
if (_currentToolBox) {
|
||
finishToolBox(_currentToolBox, data);
|
||
}
|
||
_currentToolBox = null;
|
||
break;
|
||
|
||
case 'web_search_start':
|
||
pauseStreamBubble();
|
||
_currentToolBox = addToolBoxLoading('web_search');
|
||
break;
|
||
|
||
case 'web_search_end':
|
||
if (_currentToolBox) {
|
||
finishToolBox(_currentToolBox, { name: 'web_search', result: 'Suche abgeschlossen', isError: false, sources: data.sources });
|
||
}
|
||
_currentToolBox = null;
|
||
break;
|
||
|
||
case 'user_choice':
|
||
endStreamBubble();
|
||
addChoiceBubble(data.toolCallId, data.question, data.choices);
|
||
break;
|
||
|
||
// Preparer / Review / Finalizer events (shared pattern)
|
||
case 'preparer_start': case 'review_start': case 'finalizer_start':
|
||
endStreamBubble();
|
||
addReviewBubble('start', data, eventType.split('_')[0] === 'review' ? undefined : eventType.split('_')[0]);
|
||
break;
|
||
|
||
case 'preparer_text': case 'review_text': case 'finalizer_text':
|
||
appendToReviewText(data.text);
|
||
break;
|
||
|
||
case 'preparer_tool_use': case 'review_tool_use': case 'finalizer_tool_use':
|
||
addReviewToolUse(data);
|
||
break;
|
||
|
||
case 'preparer_tool_end': case 'review_tool_end': case 'finalizer_tool_end':
|
||
finishReviewToolBox(data);
|
||
break;
|
||
|
||
case 'preparer_done':
|
||
finalizeReviewBubble(data.forwarded ? 'forwarded' : 'query', data, 'preparer');
|
||
break;
|
||
|
||
case 'review_feedback':
|
||
finalizeReviewBubble('feedback', data);
|
||
break;
|
||
|
||
case 'review_approved':
|
||
finalizeReviewBubble('approved', data);
|
||
break;
|
||
|
||
case 'finalizer_done':
|
||
finalizeReviewBubble('done', data, 'finalizer');
|
||
break;
|
||
|
||
case 'error':
|
||
endStreamBubble();
|
||
addErrorBubble(data.error, true);
|
||
break;
|
||
|
||
case 'done':
|
||
endStreamBubble();
|
||
if (data.stopReason === 'max_tokens') {
|
||
addWarningBubble('Antwort wurde abgeschnitten (max_tokens erreicht). Bitte frage nach dem Rest.', true);
|
||
}
|
||
_lastDoneChainData = null;
|
||
if (data.model) {
|
||
_lastDoneChainData = data;
|
||
} else if (data.chainId) {
|
||
// Fallback: Chain-Daten vom Server nachladen und Badge direkt rendern
|
||
// (async – _lastDoneChainData wäre im finally schon gelesen)
|
||
apiGetChain(data.chainId).then(chain => {
|
||
if (chain) addChainMetaBubble(chain, false);
|
||
}).catch(() => {});
|
||
}
|
||
setMeta(data.stopReason === 'stopped' ? 'Gestoppt' : 'Bereit');
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── Plan Mode ──
|
||
|
||
let _planModeActive = false;
|
||
|
||
function togglePlanMode() {
|
||
_planModeActive = !_planModeActive;
|
||
var btn = $('#planModeBtn');
|
||
if (btn) {
|
||
btn.classList.toggle('plan-mode-active', _planModeActive);
|
||
btn.title = _planModeActive ? 'Plan-Modus deaktivieren' : 'Plan-Modus aktivieren';
|
||
}
|
||
var input = $('#input');
|
||
if (input) {
|
||
input.placeholder = _planModeActive ? 'Plannen...' : 'Nachricht...';
|
||
}
|
||
}
|
||
|
||
// ── Chat State ──
|
||
|
||
let isStreaming = false;
|
||
let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event
|
||
let _currentToolBox = null; // Aktuell laufende Tool-Box (für Stream-Events)
|
||
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());
|
||
if (!archive) return;
|
||
|
||
chatEl().innerHTML = '';
|
||
lastKnownMessageId = 0;
|
||
loadedArchiveCount = 0;
|
||
_oldestLoadedSortKey = 0;
|
||
_hasMoreOlderMessages = false;
|
||
clearReply();
|
||
|
||
setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`);
|
||
showArchiveLoadButton();
|
||
}
|
||
|
||
let _stopClickCount = 0;
|
||
|
||
function stopStreaming() {
|
||
_stopClickCount++;
|
||
|
||
if (_stopClickCount >= 2) {
|
||
// Hard-Stop: Sofortiger Abbruch (Doppel-Klick)
|
||
var btn = $('#stop');
|
||
if (btn) {
|
||
btn.classList.add('stop-hard');
|
||
btn.title = 'Hard-Stop gesendet!';
|
||
}
|
||
// SSE-Stream clientseitig sofort abbrechen
|
||
if (_abortController) _abortController.abort();
|
||
}
|
||
|
||
// Backend explizit stoppen (graceful beim 1. Klick, hard beim 2.)
|
||
var sid = getCurrentSessionId();
|
||
if (sid) apiStopChat(sid).catch(function() {});
|
||
}
|
||
|
||
// ── SSE Stream Runner (shared between sendMessage + handleChoiceClick) ──
|
||
|
||
async function _runStream(fetchPromise) {
|
||
const sendBtn = $('#send');
|
||
const stopBtn = $('#stop');
|
||
const sid = getCurrentSessionId();
|
||
|
||
sendBtn.disabled = true;
|
||
sendBtn.hidden = true;
|
||
stopBtn.hidden = false;
|
||
isStreaming = true;
|
||
_abortController = new AbortController();
|
||
_currentToolBox = null;
|
||
_chatAbandoned = false;
|
||
_stopClickCount = 0;
|
||
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
|
||
|
||
try {
|
||
const resp = await fetchPromise(_abortController.signal);
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let eventType = null;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done || _chatAbandoned) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('event: ')) {
|
||
eventType = line.substring(7).trim();
|
||
} else if (line.startsWith('data: ') && eventType) {
|
||
let data;
|
||
try { data = JSON.parse(line.substring(6)); } catch { continue; }
|
||
handleStreamEvent(eventType, data);
|
||
eventType = null;
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (_chatAbandoned) return;
|
||
endStreamBubble();
|
||
if (e.name === 'AbortError') {
|
||
setMeta('Gestoppt');
|
||
} else {
|
||
try {
|
||
const status = await apiIsStreaming(sid);
|
||
if (status.streaming) {
|
||
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming... (Verbindung wiederhergestellt)</span>');
|
||
startStreamingPoll(sid, sendBtn, status.chainId);
|
||
return;
|
||
}
|
||
} catch (_) {}
|
||
addErrorBubble('Netzwerk-Fehler: ' + e.message);
|
||
}
|
||
} finally {
|
||
if (_chatAbandoned) return;
|
||
if (!isStreaming || !_streamingPollInterval) {
|
||
sendBtn.disabled = false;
|
||
sendBtn.hidden = false;
|
||
stopBtn.hidden = true;
|
||
stopBtn.classList.remove('stop-hard');
|
||
stopBtn.title = 'Antwort abbrechen';
|
||
isStreaming = false;
|
||
_abortController = null;
|
||
// Meta zurücksetzen falls kein done-Event kam (z.B. nach Stop)
|
||
var metaEl = $('#meta');
|
||
if (metaEl && metaEl.textContent.includes('Streaming')) {
|
||
setMeta(_stopClickCount > 0 ? 'Gestoppt' : 'Bereit');
|
||
}
|
||
}
|
||
endStreamBubble();
|
||
removeStreamedElements();
|
||
await pollHistory();
|
||
if (_lastDoneChainData) {
|
||
addChainMetaBubble(_lastDoneChainData, false);
|
||
_lastDoneChainData = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Send Message ──
|
||
|
||
async function sendMessage() {
|
||
const input = $('#input');
|
||
const text = input.value.trim();
|
||
if (!text && pendingFiles.length === 0 && pendingRefs.length === 0) return;
|
||
|
||
// Content bauen: string oder array mit file-blocks
|
||
let content;
|
||
let bubbleText = text;
|
||
|
||
// Reply-Zitat einbauen
|
||
let msgText = text;
|
||
if (pendingReply) {
|
||
const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant';
|
||
const timeLabel = formatTimestamp(pendingReply.timestamp) || 'Jetzt';
|
||
const quoteText = pendingReply.text.split('\n').map(l => '> ' + l).join('\n');
|
||
const quoteHeader = `> [Zitat: ${timeLabel}, ${roleLabel}]:`;
|
||
msgText = quoteHeader + '\n' + quoteText + '\n\n' + (text || '');
|
||
clearReply();
|
||
}
|
||
|
||
// Refs in den Text einbauen
|
||
if (pendingRefs.length > 0) {
|
||
const refLines = pendingRefs.map(r => {
|
||
if (r.from && r.to && r.from !== r.to) return `[Datei: ${r.path}:${r.from}-${r.to}]`;
|
||
if (r.from) return `[Datei: ${r.path}:${r.from}]`;
|
||
return `[Datei: ${r.path}]`;
|
||
});
|
||
msgText = refLines.join(' ') + '\n' + msgText;
|
||
if (pendingRefs.some(r => r.from)) {
|
||
if (pendingRefs.length > 1)
|
||
msgText += `\n<hint>Lies referenzierte Dateien mit \`cat -n\`.</hint>`;
|
||
else
|
||
msgText += `\n<hint>Lies die referenzierte Datei mit \`cat -n\`.</hint>`;
|
||
}
|
||
clearPendingRefs();
|
||
}
|
||
|
||
let inlineImages = []; // Bilder für Inline-Anzeige
|
||
if (pendingFiles.length > 0) {
|
||
content = [];
|
||
const labels = [];
|
||
for (const f of pendingFiles) {
|
||
const blockType = f.type.startsWith('image/') ? 'image' : 'document';
|
||
content.push({
|
||
type: blockType,
|
||
source: { type: 'base64', media_type: f.type, data: f.base64 },
|
||
_file_name: f.name
|
||
});
|
||
if (blockType === 'image') {
|
||
inlineImages.push({ name: f.name, dataUrl: 'data:' + f.type + ';base64,' + f.base64 });
|
||
}
|
||
labels.push('[' + (blockType === 'image' ? 'Bild' : 'Datei') + ': ' + f.name + ']');
|
||
}
|
||
if (msgText) content.push({ type: 'text', text: msgText });
|
||
bubbleText = labels.join('\n') + (msgText ? '\n' + msgText : '');
|
||
clearPendingFiles();
|
||
} else {
|
||
content = msgText;
|
||
bubbleText = msgText;
|
||
}
|
||
|
||
addUserBubble(bubbleText, true, '', null, false, null, inlineImages);
|
||
|
||
input.value = '';
|
||
input.dispatchEvent(new Event('input'));
|
||
|
||
await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive));
|
||
}
|
||
|
||
// ── Choice Click Handler ──
|
||
|
||
async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
|
||
// Buttons deaktivieren und gewählte Option markieren
|
||
const buttons = bubbleDiv.querySelectorAll('.choice-btn');
|
||
buttons.forEach(btn => {
|
||
btn.disabled = true;
|
||
if (btn.textContent === choice) {
|
||
btn.classList.add('choice-selected');
|
||
} else {
|
||
btn.classList.add('choice-unselected');
|
||
}
|
||
});
|
||
bubbleDiv.classList.add('choice-answered');
|
||
|
||
await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive));
|
||
}
|