// ── User Content Rendering ── function renderUserContent(container, text, inlineImages) { const raw = (text || '').trim(); // Reply-Zitat erkennen und als visuellen Block rendern // Format: > [Zitat: HH:MM, Role]:\n> text...\n\nrest const quoteMatch = raw.match(/^> \[Zitat: ([^\]]+)\]:\n((?:> .*\n?)*)\n*([\s\S]*)$/); if (quoteMatch) { const quoteMeta = quoteMatch[1]; // z.B. "14:32, Assistant" const quoteBody = quoteMatch[2].replace(/^> /gm, '').trim(); const restText = quoteMatch[3].trim(); const quoteEl = document.createElement('div'); quoteEl.className = 'reply-quote'; const metaEl = document.createElement('div'); metaEl.className = 'reply-quote-meta'; metaEl.innerHTML = ' ' + esc(quoteMeta); quoteEl.appendChild(metaEl); const bodyEl = document.createElement('div'); bodyEl.className = 'reply-quote-text'; bodyEl.textContent = truncate(quoteBody, 200); quoteEl.appendChild(bodyEl); container.appendChild(quoteEl); // Rest als normalen Text weiterverarbeiten if (restText) { _renderUserTextContent(container, restText, inlineImages); } return; } _renderUserTextContent(container, raw, inlineImages); } function _renderUserTextContent(container, raw, inlineImages) { // [Bild: name] durch Platzhalter ersetzen const imagePattern = /\[Bild: ([^\]]+)\]/g; const imageRefs = []; let mdText = raw.replace(imagePattern, function(m, name) { imageRefs.push(name); return '%%IMGREF' + (imageRefs.length - 1) + '%%'; }); // [Datei: path] oder [Datei: path:1-10] durch Platzhalter ersetzen (vor Markdown-Parsing) const pattern = /\[Datei: ([^\]]+)\]/g; const fileRefs = []; mdText = mdText.replace(pattern, function(m, ref) { fileRefs.push(ref); return '%%FILEREF' + (fileRefs.length - 1) + '%%'; }); // Markdown rendern let html = renderMarkdown(mdText); // Bild-Platzhalter durch Inline-Bilder ersetzen html = html.replace(/%%IMGREF(\d+)%%/g, function(m, idx) { const name = imageRefs[parseInt(idx)]; // Wenn Inline-Bild-Daten vorhanden (beim Senden), direkt anzeigen const img = inlineImages && inlineImages.find(function(i) { return i.name === name; }); if (img) { return '' + esc(name) + ''; } // Sonst: Platzhalter mit data-Attribut für späteres Laden return '' + ' ' + esc(name) + ''; }); // Datei-Platzhalter durch File-Ref-Chips ersetzen (als HTML) html = html.replace(/%%FILEREF(\d+)%%/g, function(m, idx) { const ref = fileRefs[parseInt(idx)]; const filePath = ref.replace(/:\d+(-\d+)?$/, ''); const fileName = filePath.includes('/') ? filePath.substring(filePath.lastIndexOf('/') + 1) : filePath; const lineInfo = ref.replace(filePath, '').trim(); return '' + ' ' + esc(fileName + lineInfo) + ''; }); // In ein Wrapper-Div rendern (damit vorherige Elemente wie Reply-Quotes erhalten bleiben) const wrap = document.createElement('div'); wrap.className = 'user-text-content'; wrap.innerHTML = html; container.appendChild(wrap); // Click-Handler für File-Ref-Chips nachträglich binden wrap.querySelectorAll('.file-ref-chip[data-filepath]').forEach(function(chip) { chip.addEventListener('click', function() { openFileExplorer({ sessionId: getCurrentSessionId(), initialFile: chip.dataset.filepath }); }); }); // Click-Handler für Inline-Bilder (Vollbild) wrap.querySelectorAll('.inline-user-image').forEach(function(img) { img.addEventListener('click', function() { showKreaImageModal(img.alt || 'Bild', img.src); }); }); // Fluent Emoji DOM-Parsing parseFluentEmojis(wrap); } // ── Message Bubble Helpers ── /** * Creates a message bubble div with common setup (className, dataset, deletedOn, action buttons). * @param {object} opts - { role, extraClass, timestamp, messageId, isMarked, deletedOn, streamed, archived } */ function _createMessageBubble(opts) { const div = document.createElement('div'); const classes = ['msg', opts.extraClass || opts.role, 'markdown']; if (opts.isMarked) classes.push('marked'); if (opts.archived) classes.push('archived'); if (opts.streamed) classes.push('streamed'); div.className = classes.join(' '); if (opts.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); div.dataset.role = opts.role || ''; div.dataset.timestamp = opts.timestamp || new Date().toISOString(); if (opts.messageId) div.dataset.messageId = opts.messageId; return div; } /** * Adds standard action buttons (timestamp, reply, delete, mark, share) to a bubble. */ function _addBubbleActions(div, opts) { _addTimestamp(div, opts.timestamp); _addReplyButton(div); _addDeleteButton(div, opts.messageId, opts.deletedOn); _addMarkButton(div, opts.messageId, opts.isMarked); _addShareButton(div, opts.messageId); } /** * Creates an attachment indicator element for a message. */ function createAttachmentIndicator(messageId, count, namesJson) { let names = []; try { if (namesJson) names = JSON.parse(namesJson); } catch {} const nameList = names.filter(n => n).join(', '); const ind = document.createElement('div'); ind.className = 'attachment-indicator'; ind.innerHTML = ` ${count} Datei${count > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : ''); ind.style.cursor = 'pointer'; ind.title = 'Klicken zum Anzeigen'; ind.addEventListener('click', () => loadAndShowAttachments(messageId, ind)); return ind; } // ── Message Bubbles ── function addUserBubble(text, streamed = false, timestamp = '', messageId = null, isMarked = false, deletedOn = null, inlineImages = null) { const div = _createMessageBubble({ role: 'user', timestamp, messageId, isMarked, deletedOn, streamed }); renderUserContent(div, text, inlineImages); _addBubbleActions(div, { timestamp, messageId, deletedOn, isMarked }); chatEl().appendChild(div); scrollDown(); } function _addSystemBubble(text, icon, color, streamed) { const div = document.createElement('div'); div.className = 'msg system-info'; if (streamed) div.classList.add('streamed'); div.style.color = color; div.innerHTML = ' ' + esc((text || '').trim()); chatEl().appendChild(div); scrollDown(); return div; } function addErrorBubble(text, streamed = false) { return _addSystemBubble(text, 'fa-times-circle', 'var(--dangerous)', streamed); } function addWarningBubble(text, streamed = false) { return _addSystemBubble(text, 'fa-exclamation-triangle', 'var(--warning)', streamed); } // ── Choice Bubbles (ask_user_choice) ── /** * Creates a choice bubble element (for both live stream and history rendering). * @param {object} opts - { toolCallId, question, choices, selectedChoice, timestamp, messageId, streamed } */ function _createChoiceBubble(opts) { if (!Array.isArray(opts.choices)) opts.choices = [String(opts.choices)]; const answered = !!opts.selectedChoice; const div = document.createElement('div'); div.className = 'msg choice-bubble' + (answered ? ' choice-answered' : ''); if (opts.streamed) div.classList.add('streamed'); div.dataset.toolCallId = opts.toolCallId || ''; if (opts.messageId) div.dataset.messageId = opts.messageId; const questionDiv = document.createElement('div'); questionDiv.className = 'choice-question'; questionDiv.innerHTML = renderMarkdown(opts.question); parseFluentEmojis(questionDiv); div.appendChild(questionDiv); const buttonsDiv = document.createElement('div'); buttonsDiv.className = 'choice-buttons'; for (const choice of opts.choices) { const btn = document.createElement('button'); btn.className = 'choice-btn'; btn.textContent = choice; if (answered) { btn.disabled = true; btn.classList.add(choice === opts.selectedChoice ? 'choice-selected' : 'choice-unselected'); } else { btn.addEventListener('click', () => handleChoiceClick(opts.toolCallId, choice, div)); } buttonsDiv.appendChild(btn); } div.appendChild(buttonsDiv); if (opts.timestamp) _addTimestamp(div, opts.timestamp); return div; } // Wird live aus dem SSE-Stream aufgerufen function addChoiceBubble(toolCallId, question, choices, streamed = true) { const div = _createChoiceBubble({ toolCallId, question, choices, streamed }); chatEl().appendChild(div); scrollDown(); return div; } async function loadAndShowAttachments(messageId, indicator) { if (indicator.dataset.loaded) return; indicator.dataset.loaded = '1'; indicator.innerHTML = ' 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.innerHTML = ` ${esc(att.fileName || 'Dokument')}`; container.appendChild(link); } } indicator.innerHTML = ` ${attachments.length} Datei${attachments.length > 1 ? 'en' : ''}`; indicator.parentElement.appendChild(container); } catch (e) { indicator.innerHTML = ' Fehler beim Laden'; indicator.dataset.loaded = ''; } }