refactor: Chat-Payload vereinfacht + Inline-Bilder statt Platzhalter

Backend:
- ChatEndpoint empfängt nur noch content + sessionId (nicht mehr die gesamte History)
- History wird komplett aus DB aufgebaut via BuildHistoryFromDb
- Gelöschte Nachrichten (deleted_on) werden aus History gefiltert
- Leere Nachrichten werden übersprungen (kein API 400 mehr)
- BuildHistoryFromDb optimiert: nur Text für ältere Chains, Tool-Calls nur für aktuelle Chain
- GetChainMessages: includeDeleted-Parameter (Default: false)
- Archiv-Export nutzt includeDeleted: true

Frontend:
- messages[]-Array komplett entfernt (kein Client-seitiger History-State mehr)
- apiChat() sendet nur content + sessionId
- Inline-Bilder: [Bild: name] wird durch <img> ersetzt (live mit DataURL, History per API-Nachladen)
- _autoLoadImageAttachments() für History/Archiv/ältere Nachrichten
- Klick auf Inline-Bild öffnet Vollbild-Modal
This commit is contained in:
Meleki committed 2026-03-04 14:29:33 +01:00
1 parent 51db01752f
commit 8fc29c82d9
12 files changed
+222 -106

No files matched your search

+13
View File
@@ -165,6 +165,7 @@
.tool-error-badge {
color: var(--dangerous);
border: var(--border-width) solid var(--dangerous);
border-radius: var(--border-radius);
padding: var(--badge-padding);
}
@@ -636,6 +637,14 @@
background: var(--f3-bg);
}
/* Inline User Images (statt [Bild: ...] Platzhalter) */
.inline-user-image {
max-width: 300px;
max-height: 200px;
object-fit: contain;
}
/* Attachments in History */
.attachment-indicator {
@@ -654,6 +663,10 @@
gap: var(--badge-spacing);
}
.attachment-container img {
border-radius: var(--border-radius);
}
.attachment-image {
max-width: 300px;
max-height: 200px;
+2 -2
View File
@@ -41,11 +41,11 @@ async function apiMe() {
// ── API Calls ──
async function apiChat(messages, sessionId, signal) {
async function apiChat(content, sessionId, signal) {
return fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, sessionId }),
body: JSON.stringify({ content, sessionId }),
signal
});
}
+6 -10
View File
@@ -29,7 +29,6 @@ function addChainMetaBubble(d, streamed) {
// ── Chat State ──
const messages = [];
let isStreaming = false;
let pendingFiles = []; // [{ name, type, base64 }]
let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen
@@ -42,7 +41,6 @@ async function clearChat() {
const archive = await apiArchive(getCurrentSessionId());
if (!archive) return;
messages.length = 0;
chatEl().innerHTML = '';
lastKnownMessageId = 0;
loadedArchiveCount = 0;
@@ -100,6 +98,7 @@ async function sendMessage() {
clearPendingRefs();
}
let inlineImages = []; // Bilder für Inline-Anzeige
if (pendingFiles.length > 0) {
content = [];
const labels = [];
@@ -110,6 +109,9 @@ async function sendMessage() {
source: { type: 'base64', media_type: f.type, data: f.base64 },
_file_name: f.name
});
if (blockType === 'image') {
inlineImages.push({ name: f.name, dataUrl: 'data:' + f.type + ';base64,' + f.base64 });
}
labels.push('[' + (blockType === 'image' ? 'Bild' : 'Datei') + ': ' + f.name + ']');
}
if (msgText) content.push({ type: 'text', text: msgText });
@@ -120,8 +122,7 @@ async function sendMessage() {
bubbleText = msgText;
}
messages.push({ role: 'user', content });
addUserBubble(bubbleText, true);
addUserBubble(bubbleText, true, '', null, false, null, inlineImages);
input.value = '';
input.dispatchEvent(new Event('input'))
@@ -139,7 +140,7 @@ async function sendMessage() {
_chatAbandoned = false;
try {
const resp = await apiChat(messages, getCurrentSessionId(), _abortController.signal);
const resp = await apiChat(content, getCurrentSessionId(), _abortController.signal);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@@ -206,11 +207,6 @@ async function sendMessage() {
case 'error':
endStreamBubble();
addErrorBubble(data.error, true);
// Fehlgeschlagene Message aus dem Array entfernen
// damit das kaputte Bild nicht endlos mitgeschickt wird
if (messages.length > 0 && messages[messages.length - 1].role === 'user') {
messages.pop();
}
break;
case 'done':
+51 -5
View File
@@ -1,5 +1,36 @@
// ── History Loading ──
async function _autoLoadImageAttachments(messageId) {
// Finde die User-Bubble mit dieser messageId
const bubble = document.querySelector('.msg.user[data-message-id="' + messageId + '"]');
if (!bubble) return;
const placeholders = bubble.querySelectorAll('.inline-image-placeholder');
if (placeholders.length === 0) return;
try {
const resp = await fetch('/api/attachments/' + messageId);
const attachments = await resp.json();
const imageAtts = attachments.filter(function(a) { return a.type === 'image'; });
placeholders.forEach(function(ph) {
const filename = ph.dataset.filename;
// Bild anhand Dateiname zuordnen, oder der Reihe nach
const att = imageAtts.find(function(a) { return a.fileName === filename; }) || imageAtts.shift();
if (att) {
const img = document.createElement('img');
img.className = 'inline-user-image';
img.src = 'data:' + att.mediaType + ';base64,' + att.data;
img.alt = filename;
img.title = filename;
img.addEventListener('click', function() { showKreaImageModal(filename, img.src); });
ph.replaceWith(img);
}
});
} catch (e) {
// Platzhalter bleiben stehen bei Fehler
}
}
let lastKnownMessageId = 0;
let _oldestLoadedMessageId = 0;
let _hasMoreOlderMessages = false;
@@ -67,8 +98,9 @@ async function loadOlderMessages() {
// Nachrichten oben einfügen (vor dem ersten .msg Element)
const fragment = document.createDocumentFragment();
const imageMessageIds = [];
for (const entry of entries) {
_renderMessagesIntoFragment(fragment, entry.messages);
_renderMessagesIntoFragment(fragment, entry.messages, imageMessageIds);
if (entry.chain && isChainMetaVisible(entry.chain)) {
const badges = buildMetaBadges(entry.chain);
const metaDiv = document.createElement('div');
@@ -86,6 +118,9 @@ async function loadOlderMessages() {
chat.appendChild(fragment);
}
// Bild-Attachments automatisch laden
for (const mid of imageMessageIds) _autoLoadImageAttachments(mid);
// Neuen Button oder Archiv-Button anzeigen
if (data.hasMore) {
showOlderMessagesButton(data.remainingCount);
@@ -138,7 +173,7 @@ function _mergeConsecutiveAssistantTexts(msgs) {
return result;
}
function _renderMessagesIntoFragment(fragment, msgs) {
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
msgs = _mergeConsecutiveAssistantTexts(msgs);
for (const msg of msgs) {
const div = document.createElement('div');
@@ -156,6 +191,10 @@ function _renderMessagesIntoFragment(fragment, msgs) {
_addMarkButton(div, msg.id, msg.isMarked);
fragment.appendChild(div);
if (msg.attachmentCount > 0) {
// Prüfe ob Bilder dabei sind (Content enthält [Bild: ...])
if (/\[Bild: [^\]]+\]/.test(msg.content) && imageMessageIds) {
imageMessageIds.push(msg.id);
}
let names = [];
try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {}
const nameList = names.filter(n => n).join(', ');
@@ -220,11 +259,12 @@ function renderMessages(msgs, addToConversation) {
case 'text':
if (msg.role === 'user') {
addUserBubble(msg.content, false, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
if (msg.attachmentCount > 0) addAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames);
if (addToConversation && !msg.deletedOn) messages.push({ role: 'user', content: msg.content });
if (msg.attachmentCount > 0) {
_autoLoadImageAttachments(msg.id);
addAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames);
}
} else if (msg.role === 'assistant') {
addAssistantBubble(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
if (addToConversation && !msg.deletedOn) messages.push({ role: 'assistant', content: msg.content });
}
break;
@@ -293,6 +333,7 @@ async function loadLatestArchive() {
sep.innerHTML = `<i class="fa fa-archive"></i> Archiv vom ${new Date(latest.createdAt).toLocaleString('de-AT')} (${latest.chainCount} Chains, ${latest.messageCount} Messages)`;
fragment.appendChild(sep);
const archiveImageIds = [];
for (const entry of data) {
const mergedMsgs = _mergeConsecutiveAssistantTexts(entry.messages);
for (const msg of mergedMsgs) {
@@ -301,6 +342,7 @@ async function loadLatestArchive() {
case 'text':
if (msg.role === 'user') {
div.className = 'msg user markdown archived';
if (msg.id) div.dataset.messageId = msg.id;
renderUserContent(div, msg.content);
} else {
div.className = 'msg assistant markdown archived';
@@ -308,6 +350,7 @@ async function loadLatestArchive() {
parseFluentEmojis(div);
}
if (msg.role === 'user' && msg.attachmentCount > 0) {
if (/\[Bild: [^\]]+\]/.test(msg.content)) archiveImageIds.push(msg.id);
let names = [];
try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {}
const nameList = names.filter(n => n).join(', ');
@@ -360,6 +403,9 @@ async function loadLatestArchive() {
// Älteres Archiv vor dem bisherigen Inhalt einfügen
container.prepend(fragment);
// Bild-Attachments automatisch laden
for (const mid of archiveImageIds) _autoLoadImageAttachments(mid);
// Button ganz oben, falls es noch ältere Archive gibt
if (archives.length > loadedArchiveCount) {
showArchiveLoadButton();
-1
View File
@@ -341,7 +341,6 @@ registerPage('chat', {
}
// State zurücksetzen und History laden
messages.length = 0;
lastKnownMessageId = 0;
loadedArchiveCount = 0;
_oldestLoadedMessageId = 0;
+36 -8
View File
@@ -242,7 +242,7 @@ function _addReplyButton(div) {
// ── Message Bubbles ──
function renderUserContent(container, text) {
function renderUserContent(container, text, inlineImages) {
const raw = (text || '').trim();
// Reply-Zitat erkennen und als visuellen Block rendern
@@ -267,19 +267,27 @@ function renderUserContent(container, text) {
// Rest als normalen Text weiterverarbeiten
if (restText) {
_renderUserTextContent(container, restText);
_renderUserTextContent(container, restText, inlineImages);
}
return;
}
_renderUserTextContent(container, raw);
_renderUserTextContent(container, raw, inlineImages);
}
function _renderUserTextContent(container, raw) {
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 = [];
const mdText = raw.replace(pattern, function(m, ref) {
mdText = mdText.replace(pattern, function(m, ref) {
fileRefs.push(ref);
return '%%FILEREF' + (fileRefs.length - 1) + '%%';
});
@@ -287,7 +295,20 @@ function _renderUserTextContent(container, raw) {
// Markdown rendern
let html = renderMarkdown(mdText);
// Platzhalter durch File-Ref-Chips ersetzen (als HTML)
// 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 '<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+)?$/, '');
@@ -310,11 +331,18 @@ function _renderUserTextContent(container, raw) {
});
});
// 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);
}
function addUserBubble(text, streamed = false, timestamp = '', messageId = null, isMarked = false, deletedOn = null) {
function addUserBubble(text, streamed = false, timestamp = '', messageId = null, isMarked = false, deletedOn = null, inlineImages = null) {
const div = document.createElement('div');
div.className = 'msg user markdown' + (isMarked ? ' marked' : '');
if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
@@ -322,7 +350,7 @@ function addUserBubble(text, streamed = false, timestamp = '', messageId = null,
div.dataset.timestamp = timestamp || new Date().toISOString();
if (messageId) div.dataset.messageId = messageId;
if (streamed) div.classList.add('streamed');
renderUserContent(div, text);
renderUserContent(div, text, inlineImages);
_addReplyButton(div);
_addDeleteButton(div, messageId, deletedOn);
_addMarkButton(div, messageId, isMarked);