// ── 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 badges = buildMetaBadges(d); const div = document.createElement('div'); div.className = 'chain-meta'; if (streamed) div.classList.add('streamed'); div.innerHTML = badges.map(b => '' + esc(b) + '').join(''); 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; case 'review_start': endStreamBubble(); addReviewBubble('start', data); break; case 'review_text': appendToReviewText(data.text); break; case 'review_tool_use': addReviewToolUse(data); break; case 'review_feedback': finalizeReviewBubble('feedback', data); break; case 'review_approved': finalizeReviewBubble('approved', data); break; case 'finalizer_start': endStreamBubble(); addReviewBubble('start', data, 'finalizer'); break; case 'finalizer_text': appendToReviewText(data.text); break; case 'finalizer_tool_use': addReviewToolUse(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) { apiGetChain(data.chainId).then(chain => { if (chain) _lastDoneChainData = chain; }).catch(() => {}); } setMeta('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 pendingFiles = []; // [{ name, type, base64 }] 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 _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; _oldestLoadedMessageId = 0; _hasMoreOlderMessages = false; clearReply(); setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`); showArchiveLoadButton(); } function stopStreaming() { if (_abortController) { _abortController.abort(); } // Backend explizit stoppen (kills laufende Tools/API-Calls) var sid = getCurrentSessionId(); if (sid) apiStopChat(sid).catch(function() {}); } // ── Send Message ── async function sendMessage() { const input = $('#input'); const btn = $('#send'); 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 = _formatReplyTimestamp(pendingReply.timestamp); 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(); } 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')) btn.disabled = true; btn.hidden = true; const stopBtn = $('#stop'); stopBtn.hidden = false; isStreaming = true; _abortController = new AbortController(); setMetaHtml(' Streaming...'); let fullText = ''; _currentToolBox = null; _chatAbandoned = false; try { const resp = await apiChat(content, getCurrentSessionId(), _abortController.signal, _planModeActive); 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) { // Bei Navigation nichts tun – der neue Page-Init übernimmt if (_chatAbandoned) return; endStreamBubble(); // 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(' Streaming... (Verbindung wiederhergestellt)'); 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(); // Gestreamte Elemente entfernen, dann aus DB laden, dann Chain-Meta anhängen removeStreamedElements(); await pollHistory(); if (_lastDoneChainData) { addChainMetaBubble(_lastDoneChainData, false); _lastDoneChainData = null; } } } // ── 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'); // Stream starten mit Choice-Response const sid = getCurrentSessionId(); const sendBtn = $('#send'); const stopBtn = $('#stop'); sendBtn.disabled = true; sendBtn.hidden = true; stopBtn.hidden = false; isStreaming = true; _abortController = new AbortController(); _currentToolBox = null; setMetaHtml(' Streaming...'); _chatAbandoned = false; try { const resp = await apiChatChoice(sid, toolCallId, choice, _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('Abgebrochen'); await new Promise(r => setTimeout(r, 500)); } else { try { const status = await apiIsStreaming(sid); if (status.streaming) { setMetaHtml(' Streaming... (Verbindung wiederhergestellt)'); 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; isStreaming = false; _abortController = null; } endStreamBubble(); removeStreamedElements(); await pollHistory(); if (_lastDoneChainData) { addChainMetaBubble(_lastDoneChainData, false); _lastDoneChainData = null; } } } // ── File Handling ── function handleFileSelect(file) { if (!file) return; const maxSize = 20 * 1024 * 1024; // 20MB if (file.size > maxSize) { alert('Datei zu groß (max. 20MB)'); return; } const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; if (!allowed.includes(file.type)) { alert('Nicht unterstützter Dateityp. Erlaubt: JPEG, PNG, GIF, WebP, PDF'); return; } const reader = new FileReader(); reader.onload = () => { const base64 = reader.result.split(',')[1]; pendingFiles.push({ name: file.name, type: file.type, base64 }); renderFilePreview(); }; reader.readAsDataURL(file); } function handleMultipleFiles(files) { for (const file of files) handleFileSelect(file); } function renderFilePreview() { const preview = $('#filePreview'); if (!preview || pendingFiles.length === 0) { if (preview) { preview.innerHTML = ''; preview.hidden = true; } return; } preview.innerHTML = pendingFiles.map((f, i) => { const isImage = f.type.startsWith('image/'); return `
${isImage ? '' : ''} ${esc(f.name)}
`; }).join(''); preview.hidden = false; } function removePendingFile(index) { pendingFiles.splice(index, 1); renderFilePreview(); } // ── Workspace Refs ── function addPendingRef(ref) { // ref = "path", "path:line" oder "path:from-to" const rangeMatch = ref.match(/^(.+?):(\d+)-(\d+)$/); const lineMatch = !rangeMatch && ref.match(/^(.+?):(\d+)$/); if (rangeMatch) { pendingRefs.push({ path: rangeMatch[1], from: parseInt(rangeMatch[2]), to: parseInt(rangeMatch[3]) }); } else if (lineMatch) { pendingRefs.push({ path: lineMatch[1], from: parseInt(lineMatch[2]), to: parseInt(lineMatch[2]) }); } else { pendingRefs.push({ path: ref, from: null, to: null }); } renderRefPreview(); } function removePendingRef(index) { pendingRefs.splice(index, 1); renderRefPreview(); } function clearPendingRefs() { pendingRefs.length = 0; renderRefPreview(); } function renderRefPreview() { const container = $('#refPreview'); if (!container) return; if (pendingRefs.length === 0) { container.innerHTML = ''; container.hidden = true; return; } container.innerHTML = pendingRefs.map((r, i) => { const label = r.from && r.to && r.from !== r.to ? `${r.path}:${r.from}-${r.to}` : r.from ? `${r.path}:${r.from}` : r.path; return `
${esc(label)}
`; }).join(''); container.hidden = false; } function clearPendingFiles() { pendingFiles.length = 0; const preview = $('#filePreview'); if (preview) { preview.innerHTML = ''; preview.hidden = true; } const fileInput = $('#fileInput'); if (fileInput) fileInput.value = ''; } // ── Reply / Quote ── function _formatReplyTimestamp(ts) { if (!ts) return 'Jetzt'; try { const d = new Date(ts); const now = new Date(); const hh = d.getHours().toString().padStart(2, '0'); const mm = d.getMinutes().toString().padStart(2, '0'); // Gleiches Datum → nur Uhrzeit, sonst Datum + Uhrzeit if (d.toDateString() === now.toDateString()) { return hh + ':' + mm; } const dd = d.getDate().toString().padStart(2, '0'); const mo = (d.getMonth() + 1).toString().padStart(2, '0'); return dd + '.' + mo + '. ' + hh + ':' + mm; } catch { return 'Jetzt'; } } function setReply(reply) { pendingReply = reply; renderReplyPreview(); document.getElementById('input')?.focus(); } function clearReply() { pendingReply = null; renderReplyPreview(); } function renderReplyPreview() { const container = $('#replyPreview'); if (!container) return; if (!pendingReply) { container.innerHTML = ''; container.hidden = true; return; } const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant'; const timeLabel = _formatReplyTimestamp(pendingReply.timestamp); const preview = truncate(pendingReply.text, 150); container.innerHTML = `
${esc(timeLabel)}, ${esc(roleLabel)} ${esc(preview)}
`; container.hidden = false; }