// ── Helpers ── function buildMetaBadges(d) { const badges = []; badges.push(d.model || '?'); badges.push('In: ' + (d.inputTokens || 0)); badges.push('Out: ' + (d.outputTokens || 0)); if (d.cacheCreationTokens > 0) badges.push('Cache-Write: ' + d.cacheCreationTokens); if (d.cacheReadTokens > 0) badges.push('Cache-Read: ' + d.cacheReadTokens); if ((d.iterations || 0) > 1) badges.push('Iterations: ' + 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; const badges = buildMetaBadges(d); const div = document.createElement('div'); div.className = 'chain-meta'; if (streamed) div.classList.add('streamed'); div.innerHTML = badges.map(b => '').join(''); chatEl().appendChild(div); scrollDown(); } // ── Chat State ── const messages = []; let isStreaming = false; let pendingFiles = []; // [{ name, type, base64 }] let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event async function clearChat() { const archive = await apiArchive(getCurrentSessionId()); if (!archive) return; messages.length = 0; chatEl().innerHTML = ''; lastKnownMessageId = 0; loadedArchiveCount = 0; setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`); showArchiveLoadButton(); } // ── 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; // Refs in den Text einbauen let msgText = text; if (pendingRefs.length > 0) { const refTags = pendingRefs.map(r => { if (r.from && r.to) return `[Datei: ${r.path}:${r.from}-${r.to}]`; return `[Datei: ${r.path}]`; }); msgText = refTags.join('\n') + (text ? '\n' + text : ''); clearPendingRefs(); } 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 }); 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; } messages.push({ role: 'user', content }); addUserBubble(bubbleText, true); input.value = ''; input.dispatchEvent(new Event('input')) btn.disabled = true; isStreaming = true; setMetaHtml(' Streaming...'); let fullText = ''; let currentToolBox = null; try { const resp = await apiChat(messages, getCurrentSessionId()); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let eventType = null; while (true) { const { done, value } = await reader.read(); if (done) 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; } switch (eventType) { case 'text_start': endStreamBubble(); startStreamBubble(); break; case 'text_delta': appendToStream(data.text); fullText += data.text; break; case 'text_end': endStreamBubble(); break; case 'tool_start': endStreamBubble(); currentToolBox = addToolBoxLoading(data.name); break; case 'tool_end': finishToolBox(currentToolBox, data); currentToolBox = null; break; case 'error': endStreamBubble(); addErrorBubble(data.error, true); // Fehlgeschlagene Message aus dem Array entfernen // damit das kaputte Bild nicht endlos mitgeschickt wird if (messages.length > 0 && messages[messages.length - 1].role === 'user') { messages.pop(); } break; case 'done': endStreamBubble(); if (data.stopReason === 'max_tokens') { addWarningBubble('Antwort wurde abgeschnitten (max_tokens erreicht). Bitte frage nach dem Rest.', true); } // Chain-Meta als streamed markieren (wird im finally ersetzt) _lastDoneChainData = null; if (data.model) { _lastDoneChainData = data; addChainMetaBubble(data, true); } else if (data.chainId) { try { const chain = await apiGetChain(data.chainId); if (chain) { _lastDoneChainData = chain; addChainMetaBubble(chain, true); } } catch (_) {} } setMeta('Bereit'); break; } eventType = null; } } } } catch (e) { 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(' Streaming... (Verbindung wiederhergestellt)'); startStreamingPoll(getCurrentSessionId(), btn, status.chainId); return; } } catch (_) {} addErrorBubble('Netzwerk-Fehler: ' + e.message); } finally { if (!isStreaming || !_streamingPollInterval) { btn.disabled = false; isStreaming = false; } endStreamBubble(); // Gestreamte Elemente entfernen, dann aus DB laden, dann Chain-Meta anhängen 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 `