// ── Helpers ── function buildMetaBadges(d) { const badges = []; if (d.planMode) badges.push('📋 Plan-Modus'); badges.push(prettyModelName(d.model)); if (d.effort) badges.push('Effort: ' + d.effort); 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 'thinking_start': startThinkingBubble(); break; case 'thinking_delta': appendToThinking(data.text); break; case 'thinking_end': endThinkingBubble(); break; 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; // Multi-Modell-Panel (Fusion/MoA): mehrere Member streamen parallel LIVE case 'panel_start': startPanel(data); break; // MoA: Runden-Trenner (nur bei mehreren Runden sichtbar) case 'panel_layer_start': startPanelLayer(data); break; case 'panel_layer_done': break; case 'panel_member_start': addPanelMemberBubble(data); break; case 'panel_member_text': appendPanelMemberText(data); break; case 'panel_member_tool_use': addPanelMemberToolUse(data); break; // tool_end = CLI (offene Box füllen); tool = Anthropic-Loop (kombiniert → fertige Box) case 'panel_member_tool_end': case 'panel_member_tool': finishPanelMemberTool(data); break; case 'panel_member_error': appendPanelMemberText({ index: data.index, text: '\n\n_[Fehler: ' + (data.error || 'unbekannt') + ']_' }); break; case 'panel_member_done': finalizePanelMember(data); break; case 'panel_done': endPanel(data); break; case 'auto_context': addAutoContextBubble(data.context); break; case 'auto_continue': addWarningBubble(`Auto-Continue (${data.attempt}/${data.maxAttempts}): Modell wird fortgesetzt…`, true); break; case 'steer_injected': // Das Modell hat den Einwurf an dieser Stelle erhalten. Die Anzeige übernimmt // bereits die optimistische Bubble aus sendSteer(); am Stream-Ende ersetzt // pollHistory() sie durch die persistierte DB-Version (Type=steer). break; case 'warning': 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); } // Panel-Member-Tokens (Fusion/MoA) sind server-seitig bereits in die Worker-Usage gefaltet // (seedUsage) → das done-Event trägt die Gesamtkosten, und FinalizeChain persistiert sie // (überlebt F5). Hier nichts mehr zu addieren. _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; } } // ── Unified Modus-Menü (Aus / Plan / Ultracode / Fusion / MoA) ── // // Der Button (#planModeBtn) öffnet ein Dropdown-Menü (Muster wie der Emoji-Picker), aus dem // der Betriebsmodus direkt gewählt wird. Der Button zeigt weiterhin das Symbol des aktiven // Modus; im Menü ist der aktive Eintrag markiert. // // Plan-Modus und Ultracode sind transient (pro Request, nicht in der Session persistiert) und // laufen ohne Panel → beim Wählen wird multiModelMode der Session auf 'off' gesetzt, damit kein // zuvor gewähltes Fusion/MoA-Panel nebenherläuft (das Menü erlaubt direkte Sprünge, anders als // der frühere Zyklus, der immer durch 'off' lief). Fusion/MoA werden in der Session persistiert // und server-seitig aus ctx.MultiModelMode gelesen. // // _planModeActive / _ultracodeActive bleiben als Compat-Variablen (sendMessage + modals.js lesen // sie direkt). // Metadaten je Modus – eine Quelle für Button-Render UND Menü-Aufbau: Reihenfolge im Menü, // Icon (Button + Menü), Label, Button-Aktiv-Klasse, Input-Placeholder, kurze Beschreibung. const _COMPOSITE_MODES = [ { mode: 'off', icon: 'fa-ban', label: 'Aus', btnClass: null, placeholder: 'Nachricht...', desc: 'Normaler Einzel-Agent' }, { mode: 'plan', icon: 'fa-map', label: 'Plan-Modus', btnClass: 'plan-mode-active', placeholder: 'Planen...', desc: 'Nur analysieren & planen (read-only)' }, { mode: 'ultracode', icon: 'fa-sitemap', label: 'Ultracode', btnClass: 'ultracode-active', placeholder: 'Ultracode – als Workflow...', desc: 'Dynamischer Workflow (mehrere Subagenten)' }, { mode: 'fusion', icon: 'fa-users', label: 'Fusion', btnClass: 'panel-mode-fusion', placeholder: 'Nachricht...', desc: 'Panel: Antworten fusionieren' }, { mode: 'moa', icon: 'fa-clone', label: 'MoA', btnClass: 'panel-mode-moa', placeholder: 'Nachricht...', desc: 'Panel: Mixture-of-Agents' } ]; let _compositeMode = 'off'; // 'off' | 'plan' | 'ultracode' | 'fusion' | 'moa' let _compositeHasAgents = false; // Compat-Variablen – werden durch setCompositeMode / initCompositeMode synchron gehalten let _planModeActive = false; // true genau wenn _compositeMode === 'plan' let _ultracodeActive = false; // true genau wenn _compositeMode === 'ultracode' (transient, pro Request) // Offenes Modus-Dropdown + Outside-Click-Handler (wie beim Emoji-Picker). let _compositeMenuEl = null; let _compositeMenuCloseHandler = null; // Serialisierte Modus-Persistenz. Schnell hintereinander gewählte Modi feuern sonst ÜBERLAPPENDE // PATCHes, deren Commit-Reihenfolge nicht garantiert ist → ein Zwischenwert kann den letzten // überschreiben. Und ein direkt danach gesendeter Chat-Request lädt die Session, BEVOR der Modus // committed ist → das Backend liest den alten Modus. Die Kette garantiert (a) Reihenfolge (letzte // Wahl gewinnt) und (b) dass sendMessage das offene PATCH abwarten kann. Sie rejected nie (Fehler // werden hier geschluckt). let _modePersist = Promise.resolve(); function _compositeMeta(mode) { for (var i = 0; i < _COMPOSITE_MODES.length; i++) { if (_COMPOSITE_MODES[i].mode === mode) return _COMPOSITE_MODES[i]; } return _COMPOSITE_MODES[0]; } function _renderCompositeBtn() { var btn = $('#planModeBtn'); if (!btn) return; btn.classList.remove('plan-mode-active', 'ultracode-active', 'panel-mode-fusion', 'panel-mode-moa'); var meta = _compositeMeta(_compositeMode); var icon = btn.querySelector('i'); if (icon) icon.className = 'fa ' + meta.icon; if (meta.btnClass) btn.classList.add(meta.btnClass); btn.title = _compositeMode === 'off' ? 'Modus wählen (aktuell: Aus)' : 'Modus: ' + meta.label + ' · klicken zum Wechseln'; var input = $('#input'); if (input) input.placeholder = meta.placeholder; } // Initialisiert den Button aus der geladenen Session (bei jedem Chat-Wechsel aufrufen). // Plan-Modus wird immer auf 'off' zurückgesetzt (transient). function initCompositeMode(panelMode, hasAgents) { _compositeHasAgents = !!hasAgents; _compositeMode = (panelMode === 'fusion' || panelMode === 'moa') ? panelMode : 'off'; _planModeActive = false; _ultracodeActive = false; _renderCompositeBtn(); } // Setzt den Modus direkt (aus dem Menü gewählt). // Persistiert das EFFEKTIVE Panel der Session (off/fusion/moa): für plan/ultracode also 'off', // damit kein zuvor gewähltes Panel nebenherläuft. Plan/Ultracode selbst sind transient (Request-Flags). async function setCompositeMode(mode) { if (mode === _compositeMode) return; // schon aktiv → nichts tun if (_compositeMeta(mode).mode !== mode) return; // unbekannter Modus → ignorieren _compositeMode = mode; _planModeActive = (mode === 'plan'); _ultracodeActive = (mode === 'ultracode'); _renderCompositeBtn(); // Effektiver Panel-Modus: nur Fusion/MoA aktivieren ein Panel, alles andere 'off'. // Auf die serialisierte Kette legen, damit aufeinanderfolgende Wahlen in Reihenfolge committen // (letzte gewinnt) und sendMessage das offene PATCH abwarten kann. var panelMode = (mode === 'fusion' || mode === 'moa') ? mode : 'off'; var sid = getCurrentSessionId(); if (sid) { _modePersist = _modePersist .then(function () { return apiUpdateSession(sid, { multiModelMode: panelMode }); }) .catch(function (e) { console.error('Modus speichern fehlgeschlagen:', e); if (typeof showToast === 'function') showToast('Modus konnte nicht gespeichert werden', 'error'); }); } if (typeof showToast === 'function') { if (mode === 'off') { showToast('Modus: Aus', 'info'); } else if (mode === 'plan') { showToast('Plan-Modus aktiv', 'info'); } else if (mode === 'ultracode') { showToast('Ultracode aktiv – diese Anfrage läuft als dynamischer Workflow (mehr Tokens/Zeit)', 'info'); } else if (!_compositeHasAgents) { showToast('Panel: ' + (mode === 'moa' ? 'MoA' : 'Fusion') + ' aktiv – aber keine Panel-Agenten konfiguriert (im Session-Bearbeiten festlegen)', 'info'); } else { showToast('Panel: ' + (mode === 'moa' ? 'MoA' : 'Fusion') + ' aktiv', 'success'); } } } // ── Modus-Dropdown-Menü (Muster wie der Emoji-Picker) ── function _buildCompositeMenu() { var menu = document.createElement('div'); menu.className = 'composite-menu'; _COMPOSITE_MODES.forEach(function (m) { var active = (m.mode === _compositeMode); var item = document.createElement('button'); item.className = 'composite-menu-item' + (active ? ' active' : ''); item.innerHTML = '' + '' + '' + m.label + '' + '' + m.desc + '' + '' + (active ? '' : ''); item.addEventListener('click', function (e) { e.stopPropagation(); closeCompositeMenu(); setCompositeMode(m.mode); }); menu.appendChild(item); }); return menu; } function toggleCompositeMenu(anchor) { if (_compositeMenuEl) { closeCompositeMenu(); return; } var menu = _buildCompositeMenu(); document.body.appendChild(menu); _compositeMenuEl = menu; // Positionierung: über dem Button (Input-Leiste ist unten), linksbündig zum Button. var rect = anchor.getBoundingClientRect(); var h = menu.offsetHeight; var w = menu.offsetWidth; var top = rect.top - h - 6; var left = rect.left; if (top < 8) top = rect.bottom + 6; // kein Platz oben → unten if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8; // rechts abgeschnitten if (left < 8) left = 8; menu.style.top = top + 'px'; menu.style.left = left + 'px'; _compositeMenuCloseHandler = function (e) { if (!menu.contains(e.target) && e.target !== anchor && !anchor.contains(e.target)) closeCompositeMenu(); }; setTimeout(function () { document.addEventListener('click', _compositeMenuCloseHandler); }, 0); } function closeCompositeMenu() { if (_compositeMenuEl) { _compositeMenuEl.remove(); _compositeMenuEl = null; } if (_compositeMenuCloseHandler) { document.removeEventListener('click', _compositeMenuCloseHandler); _compositeMenuCloseHandler = null; } } // ── 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(); // Eine Live-SSE-Antwort rendert ALLEIN. Einen evtl. noch laufenden Recovery-Poll // (_streamingPollInterval – gestartet beim Betreten des Chats WAEHREND eine Chain // lief) hier stoppen. Sonst rendert dessen pollHistory(true) dieselben DB-Nachrichten // ein zweites Mal NEBEN den Live-Bubbles (die lastKnownMessageId nicht vorruecken) // => Doppel-Nachrichten nach Eintritt in eine laufende Chain. if (_streamingPollInterval) { clearInterval(_streamingPollInterval); _streamingPollInterval = null; } _currentToolBox = null; _chatAbandoned = false; _stopClickCount = 0; startTypingIndicator(); // Während des Streams wird die Eingabe zum Steering-Einwurf (Enter wirft ein). const _steerInputEl = $('#input'); const _prevPlaceholder = _steerInputEl ? _steerInputEl.placeholder : ''; if (_steerInputEl) { _steerInputEl.placeholder = 'Einwurf...'; _steerInputEl.classList.add('steer-mode'); } 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; // Regulaeres History-Polling idempotent wieder sicherstellen: Falls beim Eintritt // in eine laufende Chain auf den Recovery-Poll umgeschaltet (und History-Polling // gestoppt) wurde, ist es nach dem Live-Stream sonst aus. startHistoryPolling() // hat selbst einen Guard (if (historyPollInterval) return) => kein Doppel-Intervall. startHistoryPolling(); // Steering-Hinweis zurücksetzen (Eingabe ist wieder normales Senden) if (_steerInputEl) { _steerInputEl.placeholder = _prevPlaceholder; _steerInputEl.classList.remove('steer-mode'); } // 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() { const input = $('#input'); if (isStreaming) { // Steering: Während eine Chain läuft, wird die Nachricht als Einwurf in die // laufende Arbeit eingespeist (statt wie früher hart blockiert zu werden). const steerText = input.value.trim(); if (steerText) sendSteer(steerText); return; } 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.`; } if (_ultracodeActive) { msgText += `\nUltracode ist aktiv: Bearbeite diese Anfrage als dynamischen Workflow – use a workflow. Fächere die Arbeit über mehrere Subagenten parallel auf, lass Befunde adversarial gegenprüfen und synthetisiere am Ende. Nutze dafür das Workflow-Tool.`; } 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')); // Ein evtl. noch offenes Modus-PATCH (Fusion/MoA/Aus aus dem Toggle) MUSS committed sein, // bevor der Chat-Request die Session lädt – sonst läuft die Chain mit dem alten Modus // (z.B. MoA → nur 1 Runde, weil das Backend noch 'fusion' liest). await _modePersist; await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive, _ultracodeActive)); } // ── Steering (Einwurf in die laufende Chain) ── async function sendSteer(text) { const input = $('#input'); // Eingabe leeren + optimistische Bubble (wird am Stream-Ende durch die DB-Version ersetzt) input.value = ''; input.dispatchEvent(new Event('input')); const bubble = addSteerBubble(text, true); scrollDownForce(); let res; try { res = await apiChatSteer(getCurrentSessionId(), text); } catch (e) { if (bubble) bubble.remove(); input.value = text; input.dispatchEvent(new Event('input')); addWarningBubble('Einwurf fehlgeschlagen: ' + (e.message || e)); return; } if (!res || !res.injected) { // Race: die laufende Antwort ist gerade fertig geworden → Text zurücklegen, normal erneut senden if (bubble) bubble.remove(); input.value = text; input.dispatchEvent(new Event('input')); addWarningBubble('Die laufende Antwort ist bereits beendet – bitte erneut senden.'); } } // ── Direct Message (für Inline-Choice-Buttons) ── async function sendMessageDirect(text) { if (isStreaming || !text) return; addUserBubble(text, true); scrollDownForce(); await _modePersist; // offenes Modus-PATCH abwarten (s. sendMessage) 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'); var matchedButton = false; buttons.forEach(btn => { btn.disabled = true; if (btn.textContent === choice) { btn.classList.add('choice-selected'); matchedButton = true; } else { btn.classList.add('choice-unselected'); } }); // Freitext-Antwort: Keine der Buttons matched → Antwort als Text anzeigen if (!matchedButton) { var customDiv = bubbleDiv.querySelector('.choice-custom'); if (customDiv) { customDiv.innerHTML = ''; customDiv.classList.add('has-answer'); var answerSpan = document.createElement('span'); answerSpan.className = 'choice-custom-answer'; answerSpan.textContent = '↳ ' + choice; customDiv.appendChild(answerSpan); } } 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); });