// ── 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); _lastFinishedToolBox = _currentToolBox; } _currentToolBox = null; break; case 'tool_result': if (_lastFinishedToolBox && data.result) { appendToolResult(_lastFinishedToolBox, data.result, data.isError); } _lastFinishedToolBox = 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, true, data.viaCli); 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 'info': addWarningBubble(data.message, true); 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 _lastFinishedToolBox = null; // Letzte fertige Tool-Box (für tool_result Zuordnung) let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft let _abortController = null; // AbortController für laufenden Stream // ── Typing Indicator ── let _currentAgentName = null; let _typingInterval = null; function setCurrentAgentName(name) { _currentAgentName = name; } function _randomTypingPhrase() { return TYPING_PHRASES[Math.floor(Math.random() * TYPING_PHRASES.length)]; } function _buildTypingHtml(phrase) { var name = _currentAgentName || 'Agent'; return ' ' + esc(name) + ' ' + esc(phrase) + '...'; } function startTypingIndicator() { setMetaHtml(_buildTypingHtml(_randomTypingPhrase())); _typingInterval = setInterval(function() { setMetaHtml(_buildTypingHtml(_randomTypingPhrase())); }, 3000); } function stopTypingIndicator() { if (_typingInterval) { clearInterval(_typingInterval); _typingInterval = null; } } async function clearChat() { const archive = await apiArchive(getCurrentSessionId()); if (!archive) return; chatEl().innerHTML = ''; lastKnownMessageId = 0; loadedArchiveCount = 0; _oldestLoadedSortKey = 0; _hasMoreOlderMessages = false; _autoScroll = true; 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; startTypingIndicator(); 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) { startTypingIndicator(); startStreamingPoll(sid, sendBtn, status.chainId); return; } } catch (_) {} addErrorBubble('Netzwerk-Fehler: ' + e.message); } } finally { if (_chatAbandoned) return; stopTypingIndicator(); 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') || metaEl.textContent.includes('...'))) { setMeta(_stopClickCount > 0 ? 'Gestoppt' : 'Bereit'); } } endStreamBubble(); removeStreamedElements(); await pollHistory(); if (_lastDoneChainData) { addChainMetaBubble(_lastDoneChainData, false); _lastDoneChainData = null; } } } // ── Send Message ── async function sendMessage() { if (isStreaming) return; 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 += `\nLies referenzierte Dateien mit \`cat -n\`.`; else msgText += `\nLies die referenzierte Datei mit \`cat -n\`.`; } clearPendingRefs(); } if (_planModeActive) { msgText += `\nPlan-Modus ist aktiv. KEINE Dateien ändern, KEINE Shell-Befehle (außer read-only), KEINE Commits. Nur analysieren, Rückfragen stellen und Plan erstellen.`; } 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); scrollDownForce(); // Beim Senden immer nach unten input.value = ''; input.dispatchEvent(new Event('input')); await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive)); } // ── Direct Message (für Inline-Choice-Buttons) ── async function sendMessageDirect(text) { if (isStreaming || !text) return; addUserBubble(text, true); scrollDownForce(); await _runStream((signal) => apiChat(text, 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'); // CLI-Modus: Antwort über internen Endpoint an MCP-Server weiterleiten (CLI läuft weiter) if (bubbleDiv.dataset.viaCli === 'true') { try { await apiChoiceRespond(getCurrentSessionId(), choice); } catch (e) { console.error('Choice respond failed:', e); } return; } // API-Modus: Normaler Chat-Flow (neue Chain) await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive)); } // ── Inline Choice Blocks (Fallback aus Markdown-Rendering) ── // Event-Delegation für .inline-choice-btn Buttons die von renderMarkdown() erzeugt wurden document.addEventListener('click', function(e) { var btn = e.target.closest('.inline-choice-btn'); if (!btn) return; var block = btn.closest('.inline-choice-block'); if (!block || block.classList.contains('choice-answered')) return; var choice = btn.dataset.choice; // Buttons deaktivieren + markieren block.querySelectorAll('.inline-choice-btn').forEach(function(b) { b.disabled = true; b.classList.add(b === btn ? 'choice-selected' : 'choice-unselected'); }); block.classList.add('choice-answered'); // Inline-Choice als normale User-Nachricht senden (kein tool_use in DB vorhanden) sendMessageDirect(choice); });