// ── 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 '
';
}
// 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 = '';
}
}