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:
1 parent
51db01752f
commit
8fc29c82d9
12 files changed
+222
-106
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -341,7 +341,6 @@ registerPage('chat', {
|
||||
}
|
||||
|
||||
// State zurücksetzen und History laden
|
||||
messages.length = 0;
|
||||
lastKnownMessageId = 0;
|
||||
loadedArchiveCount = 0;
|
||||
_oldestLoadedMessageId = 0;
|
||||
|
||||
+36
-8
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user