Files
MelekiandClaude Fable 5 483aa471a1 Fix duplicate image display when sending multiple same-named images
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>
2026-06-11 17:14:21 +02:00

339 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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 = '';
}
}