Pasted screenshots all share the same filename (e.g. "image.png"), so the placeholder substitution in _renderUserTextContent resolved every placeholder to the FIRST matching inline image via find() - showing the first image N times. Data itself was always correct (DB/agent received all images; a refresh rendered them properly). Match images consumingly (findIndex + splice on a copy) instead: labels and inlineImages are built in the same order, so same-named images now map one-to-one. Also fix the same latent issue in the legacy attachment path in history.js (_autoLoadImageAttachments), keeping the shift() fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
// ── 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 = '<i class="fa fa-reply"></i> ' + 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.
|
||
// WICHTIG: Gematchte Bilder werden VERBRAUCHT (splice) statt nur per find() gesucht –
|
||
// bei mehreren Bildern mit identischem Dateinamen (z.B. eingefügte Screenshots heißen
|
||
// alle "image.png") würde find() sonst für jeden Platzhalter immer das ERSTE Bild
|
||
// liefern → erstes Bild N-fach angezeigt. Labels und inlineImages entstehen in
|
||
// derselben Reihenfolge, daher ordnet das Verbrauchen sie korrekt der Reihe nach zu.
|
||
const remainingInline = inlineImages ? inlineImages.slice() : null;
|
||
html = html.replace(/%%IMGREF(\d+)%%/g, function(m, idx) {
|
||
const name = imageRefs[parseInt(idx)];
|
||
// Wenn Inline-Bild-Daten vorhanden (beim Senden), direkt anzeigen
|
||
let img = null;
|
||
if (remainingInline) {
|
||
const i = remainingInline.findIndex(function(x) { return x.name === name; });
|
||
if (i !== -1) img = remainingInline.splice(i, 1)[0];
|
||
}
|
||
if (img) {
|
||
return '<img class="inline-user-image" src="' + esc(img.dataUrl) + '" alt="' + esc(name) + '" title="' + esc(name) + '">';
|
||
}
|
||
// Sonst: Platzhalter mit data-Attribut für späteres Laden
|
||
return '<span class="inline-image-placeholder" data-filename="' + esc(name) + '">'
|
||
+ '<i class="fa fa-image"></i> ' + esc(name) + '</span>';
|
||
});
|
||
|
||
// 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 '<span class="file-ref-chip clickable" data-filepath="' + esc(filePath) + '" title="' + esc(ref) + '">'
|
||
+ '<i class="fa fa-file-text-o"></i> ' + esc(fileName + lineInfo) + '</span>';
|
||
});
|
||
|
||
// 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 = `<i class="fa fa-paperclip"></i> ${count} Datei${count > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : '');
|
||
ind.classList.add('clickable');
|
||
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();
|
||
}
|
||
|
||
// ── Steering Bubble (Live-Einwurf während eine Chain läuft) ──
|
||
|
||
/** Baut eine Steering-Bubble (User-Stil + Einwurf-Label). Returns den div – für Live + History. */
|
||
function _buildSteerBubble(opts) {
|
||
const div = _createMessageBubble({ role: 'user', extraClass: 'user steer', timestamp: opts.timestamp, messageId: opts.messageId, isMarked: opts.isMarked, deletedOn: opts.deletedOn, archived: opts.archived, streamed: opts.streamed });
|
||
const label = document.createElement('div');
|
||
label.className = 'steer-label';
|
||
label.innerHTML = '<i class="fa fa-bolt"></i> Einwurf';
|
||
div.appendChild(label);
|
||
renderUserContent(div, opts.text);
|
||
_addBubbleActions(div, { timestamp: opts.timestamp, messageId: opts.messageId, deletedOn: opts.deletedOn, isMarked: opts.isMarked });
|
||
return div;
|
||
}
|
||
|
||
function addSteerBubble(text, streamed = false) {
|
||
const div = _buildSteerBubble({ text, streamed });
|
||
chatEl().appendChild(div);
|
||
scrollDown();
|
||
return div;
|
||
}
|
||
|
||
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 = '<i class="fa ' + icon + '"></i> ' + 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, viaCli }
|
||
*/
|
||
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');
|
||
if (opts.viaCli) div.dataset.viaCli = 'true';
|
||
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);
|
||
|
||
// Freitext-Option (nur wenn noch nicht beantwortet)
|
||
if (!answered) {
|
||
const customDiv = document.createElement('div');
|
||
customDiv.className = 'choice-custom';
|
||
|
||
const toggleLink = document.createElement('a');
|
||
toggleLink.className = 'choice-custom-toggle';
|
||
toggleLink.href = '#';
|
||
toggleLink.textContent = 'Eigene Antwort…';
|
||
toggleLink.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
customDiv.classList.add('choice-custom-open');
|
||
customDiv.querySelector('.choice-custom-input').focus();
|
||
});
|
||
customDiv.appendChild(toggleLink);
|
||
|
||
const inputRow = document.createElement('div');
|
||
inputRow.className = 'choice-custom-row';
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'choice-custom-input';
|
||
input.placeholder = 'Eigene Antwort eingeben…';
|
||
|
||
var sendCustom = function() {
|
||
var text = input.value.trim();
|
||
if (!text) return;
|
||
handleChoiceClick(opts.toolCallId, text, div);
|
||
};
|
||
input.addEventListener('keydown', function(e) { if (e.key === 'Enter') sendCustom(); });
|
||
|
||
var sendBtn = document.createElement('button');
|
||
sendBtn.className = 'choice-custom-send';
|
||
sendBtn.textContent = '↵';
|
||
sendBtn.addEventListener('click', sendCustom);
|
||
|
||
inputRow.appendChild(input);
|
||
inputRow.appendChild(sendBtn);
|
||
customDiv.appendChild(inputRow);
|
||
div.appendChild(customDiv);
|
||
}
|
||
|
||
if (opts.timestamp) _addTimestamp(div, opts.timestamp);
|
||
return div;
|
||
}
|
||
|
||
// Wird live aus dem SSE-Stream aufgerufen
|
||
function addChoiceBubble(toolCallId, question, choices, streamed = true, viaCli = false) {
|
||
const div = _createChoiceBubble({ toolCallId, question, choices, streamed, viaCli });
|
||
chatEl().appendChild(div);
|
||
scrollDown();
|
||
return div;
|
||
}
|
||
|
||
async function loadAndShowAttachments(messageId, indicator) {
|
||
if (indicator.dataset.loaded) return;
|
||
indicator.dataset.loaded = '1';
|
||
indicator.innerHTML = '<i class="fa fa-paperclip"></i> 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 = `<i class="fa fa-file-text-o"></i> ${esc(att.fileName || 'Dokument')}`;
|
||
container.appendChild(link);
|
||
}
|
||
}
|
||
|
||
indicator.innerHTML = `<i class="fa fa-paperclip"></i> ${attachments.length} Datei${attachments.length > 1 ? 'en' : ''}`;
|
||
indicator.parentElement.appendChild(container);
|
||
} catch (e) {
|
||
indicator.innerHTML = '<i class="fa fa-paperclip"></i> Fehler beim Laden';
|
||
indicator.dataset.loaded = '';
|
||
}
|
||
}
|