// ── DOM Helpers ── const $ = (sel) => document.querySelector(sel); const chatEl = () => $('#chat'); const metaEl = () => $('#meta'); // ── Markdown ── marked.setOptions({ breaks: true, gfm: true }); function renderMarkdown(text) { if (!text) return ''; return marked.parse(text.trim()); } function scrollDown() { const chat = chatEl(); chat.scrollTop = chat.scrollHeight; } function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function truncate(s, max) { return s && s.length > max ? s.substring(0, max) + '...' : (s || ''); } // ── Message Bubbles ── function addUserBubble(text, streamed = false) { const div = document.createElement('div'); div.className = 'msg user'; if (streamed) div.classList.add('streamed'); div.textContent = (text || '').trim(); chatEl().appendChild(div); scrollDown(); } function addAssistantBubble(text) { const div = document.createElement('div'); div.className = 'msg assistant markdown'; div.innerHTML = renderMarkdown(text); chatEl().appendChild(div); scrollDown(); return div; } function addErrorBubble(text, streamed = false) { const div = document.createElement('div'); div.className = 'msg system-info'; if (streamed) div.classList.add('streamed'); div.style.color = 'var(--dangerous)'; div.textContent = (text || '').trim(); chatEl().appendChild(div); scrollDown(); } function addWarningBubble(text, streamed = false) { const div = document.createElement('div'); div.className = 'msg system-info'; if (streamed) div.classList.add('streamed'); div.style.color = 'var(--warning)'; div.textContent = (text || '').trim(); chatEl().appendChild(div); scrollDown(); } // ── Streaming Bubble ── let _streamDiv = null; let _streamText = ''; function startStreamBubble() { _streamDiv = document.createElement('div'); _streamDiv.className = 'msg assistant markdown streamed'; _streamDiv.innerHTML = ''; _streamText = ''; chatEl().appendChild(_streamDiv); scrollDown(); } function appendToStream(text) { if (!_streamDiv) startStreamBubble(); _streamText += text; _streamDiv.innerHTML = renderMarkdown(_streamText); const cursor = document.createElement('span'); cursor.className = 'cursor'; _streamDiv.appendChild(cursor); scrollDown(); } function endStreamBubble() { if (_streamDiv) { const cursor = _streamDiv.querySelector('.cursor'); if (cursor) cursor.remove(); if (!_streamText.trim()) _streamDiv.remove(); else _streamDiv.innerHTML = renderMarkdown(_streamText); } _streamDiv = null; _streamText = ''; } // ── Tool Boxes ── function addToolBoxLoading(name) { const div = document.createElement('div'); div.className = 'tool-box streamed'; div.innerHTML = '' + esc(name) + ' ⏳'; chatEl().appendChild(div); scrollDown(); return div; } function renderToolBoxContent(div, name, inputJson, result, isError) { let parsedInput = null; try { parsedInput = JSON.parse(inputJson); } catch {} const header = document.createElement('div'); header.className = 'tool-box-header'; const nameSpan = document.createElement('span'); nameSpan.className = 'tool-name'; nameSpan.textContent = name; header.appendChild(nameSpan); if (isError) { const errSpan = document.createElement('span'); errSpan.className = 'tool-error-badge'; errSpan.textContent = 'error'; header.appendChild(errSpan); } div.innerHTML = ''; div.appendChild(header); let foldTarget = null; if (name === 'edit_file' && parsedInput && parsedInput.path) { const pathSpan = document.createElement('span'); pathSpan.className = 'tool-input'; pathSpan.textContent = parsedInput.path; header.appendChild(pathSpan); const diff = buildEditDiff(parsedInput.old_text || '', parsedInput.new_text || ''); diff.hidden = true; div.appendChild(diff); foldTarget = diff; } else if (name === 'write_file' && parsedInput && parsedInput.path) { const pathSpan = document.createElement('span'); pathSpan.className = 'tool-input'; pathSpan.textContent = parsedInput.path; header.appendChild(pathSpan); const content = parsedInput.content || ''; const lines = content.split('\n'); const lineCount = lines.length; const charCount = content.length; const meta = document.createElement('span'); meta.className = 'tool-write-meta'; meta.textContent = lineCount + ' Zeilen, ' + charCount + ' Zeichen'; header.appendChild(meta); const preview = buildWritePreview(content); preview.hidden = true; div.appendChild(preview); foldTarget = preview; } else { // Standard: path/command als input anzeigen let inputLabel = ''; if (parsedInput) { inputLabel = parsedInput.path || parsedInput.command || JSON.stringify(parsedInput).substring(0, 120); } else { inputLabel = truncate(inputJson, 120); } if (inputLabel) { const inputSpan = document.createElement('span'); inputSpan.className = 'tool-input'; inputSpan.textContent = inputLabel; header.appendChild(inputSpan); } const resultDiv = document.createElement('div'); resultDiv.className = 'tool-result' + (isError ? ' tool-error' : ''); resultDiv.textContent = result ? truncate(result, 300) : ''; resultDiv.hidden = true; div.appendChild(resultDiv); foldTarget = resultDiv; } if (foldTarget) { header.style.cursor = 'pointer'; header.addEventListener('click', () => { foldTarget.hidden = !foldTarget.hidden; }); } } function buildEditDiff(oldText, newText) { const oldLines = oldText.split('\n'); const newLines = newText.split('\n'); const container = document.createElement('div'); container.className = 'tool-diff'; // Entfernte Zeilen (-) for (const line of oldLines) { const row = document.createElement('div'); row.className = 'diff-line diff-removed'; const marker = document.createElement('span'); marker.className = 'diff-marker'; marker.textContent = '-'; const content = document.createElement('span'); content.className = 'diff-content'; content.textContent = line; row.appendChild(marker); row.appendChild(content); container.appendChild(row); } // Hinzugefügte Zeilen (+) for (const line of newLines) { const row = document.createElement('div'); row.className = 'diff-line diff-added'; const marker = document.createElement('span'); marker.className = 'diff-marker'; marker.textContent = '+'; const content = document.createElement('span'); content.className = 'diff-content'; content.textContent = line; row.appendChild(marker); row.appendChild(content); container.appendChild(row); } return container; } function buildWritePreview(content) { const lines = content.split('\n'); const container = document.createElement('div'); container.className = 'tool-diff'; for (const line of lines) { const row = document.createElement('div'); row.className = 'diff-line diff-added'; const marker = document.createElement('span'); marker.className = 'diff-marker'; marker.textContent = '+'; const contentSpan = document.createElement('span'); contentSpan.className = 'diff-content'; contentSpan.textContent = line; row.appendChild(marker); row.appendChild(contentSpan); container.appendChild(row); } return container; } function finishToolBox(div, data) { if (!div) return; renderToolBoxContent(div, data.name, data.input, data.result, data.isError); scrollDown(); } function addToolBoxStatic(name, input, result, isError) { const div = document.createElement('div'); div.className = 'tool-box'; renderToolBoxContent(div, name, input, result, isError); chatEl().appendChild(div); scrollDown(); } function appendToolResult(result, isError) { // Letzten tool-box im Chat finden und Result in die bereits vorhandene .tool-result-Div schreiben const boxes = chatEl().querySelectorAll('.tool-box'); const lastBox = boxes[boxes.length - 1]; if (!lastBox) return; // Wenn Diff vorhanden (edit_file/write_file) → kein Result anhängen if (lastBox.querySelector('.tool-diff')) return; const existing = lastBox.querySelector('.tool-result'); if (existing) { existing.textContent = truncate(result, 300); if (isError) existing.classList.add('tool-error'); } else { const resultDiv = document.createElement('div'); resultDiv.className = 'tool-result' + (isError ? ' tool-error' : ''); resultDiv.textContent = truncate(result, 300); resultDiv.hidden = true; lastBox.appendChild(resultDiv); } } // ── Streamed Elements ── function removeStreamedElements() { chatEl().querySelectorAll('.streamed').forEach(el => el.remove()); } // ── Attachment Indicator ── function addAttachmentIndicator(messageId, count, namesJson) { const chat = chatEl(); const lastMsg = chat.lastElementChild; if (!lastMsg) return; let names = []; try { if (namesJson) names = JSON.parse(namesJson); } catch {} const indicator = document.createElement('div'); indicator.className = 'attachment-indicator'; const nameList = names.filter(n => n).join(', '); indicator.textContent = `📎 ${count} Datei${count > 1 ? 'en' : ''}` + (nameList ? `: ${nameList}` : ''); indicator.style.cursor = 'pointer'; indicator.title = 'Klicken zum Anzeigen'; indicator.addEventListener('click', () => loadAndShowAttachments(messageId, indicator)); lastMsg.appendChild(indicator); } async function loadAndShowAttachments(messageId, indicator) { if (indicator.dataset.loaded) return; indicator.dataset.loaded = '1'; indicator.textContent = '📎 Laden...'; try { const resp = await fetch(`/api/attachments/${messageId}`); const attachments = await resp.json(); const container = document.createElement('div'); container.className = 'attachment-container'; for (const att of attachments) { if (att.type === 'image') { const img = document.createElement('img'); img.src = `data:${att.mediaType};base64,${att.data}`; img.className = 'attachment-image'; img.title = att.fileName || 'Bild'; container.appendChild(img); } else { const link = document.createElement('a'); link.href = `data:${att.mediaType};base64,${att.data}`; link.download = att.fileName || 'datei'; link.className = 'attachment-file'; link.textContent = `📄 ${att.fileName || 'Dokument'}`; container.appendChild(link); } } indicator.textContent = `📎 ${attachments.length} Datei${attachments.length > 1 ? 'en' : ''}`; indicator.parentElement.appendChild(container); } catch (e) { indicator.textContent = '📎 Fehler beim Laden'; indicator.dataset.loaded = ''; } } // ── Meta Bar ── function setMeta(text) { metaEl().textContent = text; } function setMetaHtml(html) { metaEl().innerHTML = html; } // ── Archive Button ── function showArchiveLoadButton() { const existing = chatEl().querySelector('.archive-load-btn'); if (existing) existing.remove(); const btn = document.createElement('button'); btn.className = 'archive-load-btn'; btn.textContent = '📦 Archivierte Nachrichten laden'; btn.addEventListener('click', loadLatestArchive); // Immer ganz oben im Chat chatEl().prepend(btn); } function removeArchiveLoadButton() { const btn = chatEl().querySelector('.archive-load-btn'); if (btn) btn.remove(); }