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

+27 -50
View File
@@ -23,10 +23,10 @@ public static class ChatEndpoint
private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, CancellationToken cancellationToken) private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, CancellationToken cancellationToken)
{ {
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body); var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
if (body == null || body.Messages == null || body.Messages.Count == 0) if (body == null || body.Content.ValueKind == JsonValueKind.Undefined)
{ {
res.StatusCode = 400; res.StatusCode = 400;
await res.WriteAsJsonAsync(new { error = "messages required" }); await res.WriteAsJsonAsync(new { error = "content required" });
return; return;
} }
@@ -49,58 +49,35 @@ public static class ChatEndpoint
return; return;
} }
// Neue User-Message aus Request-Content aufbereiten und speichern
var userMessage = new ChatMessage { Role = "user", Content = body.Content };
var textContent = userMessage.GetTextContent();
var attachmentBlocks = userMessage.GetAttachmentBlocks();
var attachmentRecords = attachmentBlocks.Select(b => new AttachmentRecord
{
Type = b.Type,
MediaType = b.MediaType,
FileName = b.FileName,
Data = b.Data
}).ToList();
await db.SaveMessageWithAttachments(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 0,
Role = "user",
Type = "text",
Content = textContent
}, attachmentRecords);
// History komplett aus DB aufbauen (inkl. gerade gespeicherter Message)
var messages = await chatService.BuildHistoryFromDb(sessionId);
res.Headers["Content-Type"] = "text/event-stream"; res.Headers["Content-Type"] = "text/event-stream";
res.Headers["Cache-Control"] = "no-cache"; res.Headers["Cache-Control"] = "no-cache";
res.Headers["Connection"] = "keep-alive"; res.Headers["Connection"] = "keep-alive";
// Messages aus dem Request aufbereiten
var messages = new List<object>();
foreach (var msg in body.Messages)
{
JsonElement content = msg.Content;
if (msg.Content.ValueKind == JsonValueKind.Array)
{
var node = System.Text.Json.Nodes.JsonNode.Parse(msg.Content.GetRawText());
foreach (var block in node.AsArray())
{
if (block is System.Text.Json.Nodes.JsonObject obj)
obj.Remove("_file_name");
}
content = JsonSerializer.Deserialize<JsonElement>(node.ToJsonString());
}
messages.Add(new Dictionary<string, JsonElement>
{
["role"] = JsonSerializer.Deserialize<JsonElement>($"\"{msg.Role}\""),
["content"] = content
});
}
// Nur die letzte User-Message speichern (= die neue)
var lastUserMsg = body.Messages.LastOrDefault(m => m.Role == "user");
if (lastUserMsg != null)
{
var textContent = lastUserMsg.GetTextContent();
var attachmentBlocks = lastUserMsg.GetAttachmentBlocks();
var attachmentRecords = attachmentBlocks.Select(b => new AttachmentRecord
{
Type = b.Type,
MediaType = b.MediaType,
FileName = b.FileName,
Data = b.Data
}).ToList();
await db.SaveMessageWithAttachments(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 0,
Role = "user",
Type = "text",
Content = textContent
}, attachmentRecords);
}
streamingTracker.Start(sessionId, ctx.ChainId); streamingTracker.Start(sessionId, ctx.ChainId);
try try
{ {
+1 -1
View File
@@ -222,7 +222,7 @@ public static class HistoryEndpoint
var result = new List<object>(); var result = new List<object>();
foreach (var chain in chains) foreach (var chain in chains)
{ {
var messages = await db.GetChainMessages(chain.ChainId); var messages = await db.GetChainMessages(chain.ChainId, includeDeleted: true);
result.Add(new { chain, messages }); result.Add(new { chain, messages });
} }
+2 -2
View File
@@ -126,8 +126,8 @@ public class AttachmentInfo
public class ChatRequest public class ChatRequest
{ {
[JsonPropertyName("messages")] [JsonPropertyName("content")]
public List<ChatMessage> Messages { get; set; } = new(); public JsonElement Content { get; set; }
[JsonPropertyName("sessionId")] [JsonPropertyName("sessionId")]
public string SessionId { get; set; } = ""; public string SessionId { get; set; } = "";
+80 -24
View File
@@ -124,15 +124,59 @@ public class ChatService
/// <summary> /// <summary>
/// Baut die Message-History aus der DB (aktive Chains). /// Baut die Message-History aus der DB (aktive Chains).
/// Optimiert: Nur Text-Messages (user/assistant) für ältere Chains,
/// Tool-Calls nur für die aktuelle Chain, Attachments nur für die letzte User-Message.
/// </summary> /// </summary>
public async Task<List<object>> BuildHistoryFromDb(string sessionId) public async Task<List<object>> BuildHistoryFromDb(string sessionId)
{ {
var chains = await _db.GetActiveChains(sessionId); var chainsList = (await _db.GetActiveChains(sessionId)).ToList();
var history = new List<object>(); // chronologisch sortieren (älteste zuerst)
chainsList.Reverse();
foreach (var chain in chains) var history = new List<object>();
var isLastChain = false;
long? lastUserMsgId = null;
for (int i = 0; i < chainsList.Count; i++)
{ {
var msgs = await _db.GetChainMessages(chain.ChainId); var chain = chainsList[i];
isLastChain = (i == chainsList.Count - 1);
var msgs = (await _db.GetChainMessages(chain.ChainId)).ToList();
// Letzte User-Message-ID der letzten Chain merken (für Attachments)
if (isLastChain)
{
var lastUserMsg = msgs.LastOrDefault(m => m.Role == "user" && m.Type == "text");
lastUserMsgId = lastUserMsg?.Id;
}
// Für ältere Chains: nur Text-Messages (wie Frontend es vorher gemacht hat)
if (!isLastChain)
{
foreach (var msg in msgs)
{
if (msg.Role == "user" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content))
{
history.Add(new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
});
}
else if (msg.Role == "assistant" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content))
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
});
}
}
continue;
}
// Letzte Chain: vollständig mit Tool-Calls
var grouped = new Dictionary<int, List<MessageRecord>>(); var grouped = new Dictionary<int, List<MessageRecord>>();
foreach (var msg in msgs) foreach (var msg in msgs)
{ {
@@ -148,36 +192,48 @@ public class ChatService
if (msg.Role == "user" && msg.Type == "text") if (msg.Role == "user" && msg.Type == "text")
{ {
var content = new List<object>(); var content = new List<object>();
var attachments = await _db.GetAttachments(msg.Id);
foreach (var att in attachments) // Attachments nur für die letzte User-Message laden
if (msg.Id == lastUserMsgId)
{ {
if (att.Type == "image") var attachments = await _db.GetAttachments(msg.Id);
foreach (var att in attachments)
{ {
content.Add(new if (att.Type == "image")
{ {
type = "image", content.Add(new
source = new { type = "base64", media_type = att.MediaType, data = att.Data } {
}); type = "image",
} source = new { type = "base64", media_type = att.MediaType, data = att.Data }
else if (att.Type == "document") });
{ }
content.Add(new else if (att.Type == "document")
{ {
type = "document", content.Add(new
source = new { type = "base64", media_type = att.MediaType, data = att.Data } {
}); type = "document",
source = new { type = "base64", media_type = att.MediaType, data = att.Data }
});
}
} }
} }
content.Add(new { type = "text", text = msg.Content ?? "" });
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = content }); if (!string.IsNullOrEmpty(msg.Content))
content.Add(new { type = "text", text = msg.Content });
if (content.Count > 0)
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = content });
} }
else if (msg.Role == "assistant" && msg.Type == "text") else if (msg.Role == "assistant" && msg.Type == "text")
{ {
history.Add(new Dictionary<string, object> if (!string.IsNullOrEmpty(msg.Content))
{ {
["role"] = "assistant", history.Add(new Dictionary<string, object>
["content"] = new List<object> { new { type = "text", text = msg.Content ?? "" } } {
}); ["role"] = "assistant",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
});
}
} }
else if (msg.Role == "assistant" && msg.Type == "tool_use") else if (msg.Role == "assistant" && msg.Type == "tool_use")
{ {
+3 -2
View File
@@ -582,11 +582,12 @@ public class DatabaseService
new { messageIds }); new { messageIds });
} }
public async Task<IEnumerable<MessageRecord>> GetChainMessages(string chainId) public async Task<IEnumerable<MessageRecord>> GetChainMessages(string chainId, bool includeDeleted = false)
{ {
using var conn = CreateConnection(); using var conn = CreateConnection();
var deletedFilter = includeDeleted ? "" : " AND deleted_on IS NULL";
return await conn.QueryAsync<MessageRecord>( return await conn.QueryAsync<MessageRecord>(
"SELECT * FROM messages WHERE chain_id = @chainId ORDER BY id", new { chainId } $"SELECT * FROM messages WHERE chain_id = @chainId{deletedFilter} ORDER BY id", new { chainId }
); );
} }
+1 -1
View File
@@ -36,7 +36,7 @@
- [ ] Chat: Datum und Uhrzeit bei den Nachrichten anzeigen (Format: dd.MM.yyyy HH:mm:ss) - [ ] Chat: Datum und Uhrzeit bei den Nachrichten anzeigen (Format: dd.MM.yyyy HH:mm:ss)
- [x] Dashboard: Menu-Dropdown bei Cards sind abgeschnitten wenn die Card-Höhe nicht ausreicht (logisch, weil das Menu-Dropdown ein Child von der Card ist) - [x] Dashboard: Menu-Dropdown bei Cards sind abgeschnitten wenn die Card-Höhe nicht ausreicht (logisch, weil das Menu-Dropdown ein Child von der Card ist)
- [ ] Auto-scrolling deaktivieren, wenn man weiterhoch gescrollt hat, und er wieder fortsetzen wenn man ganz nach unten gescrollt hat - [ ] Auto-scrolling deaktivieren, wenn man weiterhoch gescrollt hat, und er wieder fortsetzen wenn man ganz nach unten gescrollt hat
- [ ] Anstelle von `[Bild: ...]`-Platzhalter direkt das gesendete Bild anzeigen - [x] Anstelle von `[Bild: ...]`-Platzhalter direkt das gesendete Bild anzeigen
## Ideas / Backlog ## Ideas / Backlog
- [ ] Memory: Embedding-Modell für semantische Suche über Memory-Dateien (Vektor-basiert + BM25 Hybrid Search) - [ ] Memory: Embedding-Modell für semantische Suche über Memory-Dateien (Vektor-basiert + BM25 Hybrid Search)
+13
View File
@@ -165,6 +165,7 @@
.tool-error-badge { .tool-error-badge {
color: var(--dangerous); color: var(--dangerous);
border: var(--border-width) solid var(--dangerous); border: var(--border-width) solid var(--dangerous);
border-radius: var(--border-radius);
padding: var(--badge-padding); padding: var(--badge-padding);
} }
@@ -636,6 +637,14 @@
background: var(--f3-bg); 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 */ /* Attachments in History */
.attachment-indicator { .attachment-indicator {
@@ -654,6 +663,10 @@
gap: var(--badge-spacing); gap: var(--badge-spacing);
} }
.attachment-container img {
border-radius: var(--border-radius);
}
.attachment-image { .attachment-image {
max-width: 300px; max-width: 300px;
max-height: 200px; max-height: 200px;
+2 -2
View File
@@ -41,11 +41,11 @@ async function apiMe() {
// ── API Calls ── // ── API Calls ──
async function apiChat(messages, sessionId, signal) { async function apiChat(content, sessionId, signal) {
return fetch('/api/chat', { return fetch('/api/chat', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, sessionId }), body: JSON.stringify({ content, sessionId }),
signal signal
}); });
} }
+6 -10
View File
@@ -29,7 +29,6 @@ function addChainMetaBubble(d, streamed) {
// ── Chat State ── // ── Chat State ──
const messages = [];
let isStreaming = false; let isStreaming = false;
let pendingFiles = []; // [{ name, type, base64 }] let pendingFiles = []; // [{ name, type, base64 }]
let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen
@@ -42,7 +41,6 @@ async function clearChat() {
const archive = await apiArchive(getCurrentSessionId()); const archive = await apiArchive(getCurrentSessionId());
if (!archive) return; if (!archive) return;
messages.length = 0;
chatEl().innerHTML = ''; chatEl().innerHTML = '';
lastKnownMessageId = 0; lastKnownMessageId = 0;
loadedArchiveCount = 0; loadedArchiveCount = 0;
@@ -100,6 +98,7 @@ async function sendMessage() {
clearPendingRefs(); clearPendingRefs();
} }
let inlineImages = []; // Bilder für Inline-Anzeige
if (pendingFiles.length > 0) { if (pendingFiles.length > 0) {
content = []; content = [];
const labels = []; const labels = [];
@@ -110,6 +109,9 @@ async function sendMessage() {
source: { type: 'base64', media_type: f.type, data: f.base64 }, source: { type: 'base64', media_type: f.type, data: f.base64 },
_file_name: f.name _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 + ']'); labels.push('[' + (blockType === 'image' ? 'Bild' : 'Datei') + ': ' + f.name + ']');
} }
if (msgText) content.push({ type: 'text', text: msgText }); if (msgText) content.push({ type: 'text', text: msgText });
@@ -120,8 +122,7 @@ async function sendMessage() {
bubbleText = msgText; bubbleText = msgText;
} }
messages.push({ role: 'user', content }); addUserBubble(bubbleText, true, '', null, false, null, inlineImages);
addUserBubble(bubbleText, true);
input.value = ''; input.value = '';
input.dispatchEvent(new Event('input')) input.dispatchEvent(new Event('input'))
@@ -139,7 +140,7 @@ async function sendMessage() {
_chatAbandoned = false; _chatAbandoned = false;
try { try {
const resp = await apiChat(messages, getCurrentSessionId(), _abortController.signal); const resp = await apiChat(content, getCurrentSessionId(), _abortController.signal);
const reader = resp.body.getReader(); const reader = resp.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
@@ -206,11 +207,6 @@ async function sendMessage() {
case 'error': case 'error':
endStreamBubble(); endStreamBubble();
addErrorBubble(data.error, true); 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; break;
case 'done': case 'done':
+51 -5
View File
@@ -1,5 +1,36 @@
// ── History Loading ── // ── 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 lastKnownMessageId = 0;
let _oldestLoadedMessageId = 0; let _oldestLoadedMessageId = 0;
let _hasMoreOlderMessages = false; let _hasMoreOlderMessages = false;
@@ -67,8 +98,9 @@ async function loadOlderMessages() {
// Nachrichten oben einfügen (vor dem ersten .msg Element) // Nachrichten oben einfügen (vor dem ersten .msg Element)
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
const imageMessageIds = [];
for (const entry of entries) { for (const entry of entries) {
_renderMessagesIntoFragment(fragment, entry.messages); _renderMessagesIntoFragment(fragment, entry.messages, imageMessageIds);
if (entry.chain && isChainMetaVisible(entry.chain)) { if (entry.chain && isChainMetaVisible(entry.chain)) {
const badges = buildMetaBadges(entry.chain); const badges = buildMetaBadges(entry.chain);
const metaDiv = document.createElement('div'); const metaDiv = document.createElement('div');
@@ -86,6 +118,9 @@ async function loadOlderMessages() {
chat.appendChild(fragment); chat.appendChild(fragment);
} }
// Bild-Attachments automatisch laden
for (const mid of imageMessageIds) _autoLoadImageAttachments(mid);
// Neuen Button oder Archiv-Button anzeigen // Neuen Button oder Archiv-Button anzeigen
if (data.hasMore) { if (data.hasMore) {
showOlderMessagesButton(data.remainingCount); showOlderMessagesButton(data.remainingCount);
@@ -138,7 +173,7 @@ function _mergeConsecutiveAssistantTexts(msgs) {
return result; return result;
} }
function _renderMessagesIntoFragment(fragment, msgs) { function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
msgs = _mergeConsecutiveAssistantTexts(msgs); msgs = _mergeConsecutiveAssistantTexts(msgs);
for (const msg of msgs) { for (const msg of msgs) {
const div = document.createElement('div'); const div = document.createElement('div');
@@ -156,6 +191,10 @@ function _renderMessagesIntoFragment(fragment, msgs) {
_addMarkButton(div, msg.id, msg.isMarked); _addMarkButton(div, msg.id, msg.isMarked);
fragment.appendChild(div); fragment.appendChild(div);
if (msg.attachmentCount > 0) { 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 = []; let names = [];
try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {} try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {}
const nameList = names.filter(n => n).join(', '); const nameList = names.filter(n => n).join(', ');
@@ -220,11 +259,12 @@ function renderMessages(msgs, addToConversation) {
case 'text': case 'text':
if (msg.role === 'user') { if (msg.role === 'user') {
addUserBubble(msg.content, false, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn); addUserBubble(msg.content, false, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
if (msg.attachmentCount > 0) addAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames); if (msg.attachmentCount > 0) {
if (addToConversation && !msg.deletedOn) messages.push({ role: 'user', content: msg.content }); _autoLoadImageAttachments(msg.id);
addAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames);
}
} else if (msg.role === 'assistant') { } else if (msg.role === 'assistant') {
addAssistantBubble(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn); addAssistantBubble(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
if (addToConversation && !msg.deletedOn) messages.push({ role: 'assistant', content: msg.content });
} }
break; 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)`; 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); fragment.appendChild(sep);
const archiveImageIds = [];
for (const entry of data) { for (const entry of data) {
const mergedMsgs = _mergeConsecutiveAssistantTexts(entry.messages); const mergedMsgs = _mergeConsecutiveAssistantTexts(entry.messages);
for (const msg of mergedMsgs) { for (const msg of mergedMsgs) {
@@ -301,6 +342,7 @@ async function loadLatestArchive() {
case 'text': case 'text':
if (msg.role === 'user') { if (msg.role === 'user') {
div.className = 'msg user markdown archived'; div.className = 'msg user markdown archived';
if (msg.id) div.dataset.messageId = msg.id;
renderUserContent(div, msg.content); renderUserContent(div, msg.content);
} else { } else {
div.className = 'msg assistant markdown archived'; div.className = 'msg assistant markdown archived';
@@ -308,6 +350,7 @@ async function loadLatestArchive() {
parseFluentEmojis(div); parseFluentEmojis(div);
} }
if (msg.role === 'user' && msg.attachmentCount > 0) { if (msg.role === 'user' && msg.attachmentCount > 0) {
if (/\[Bild: [^\]]+\]/.test(msg.content)) archiveImageIds.push(msg.id);
let names = []; let names = [];
try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {} try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {}
const nameList = names.filter(n => n).join(', '); const nameList = names.filter(n => n).join(', ');
@@ -360,6 +403,9 @@ async function loadLatestArchive() {
// Älteres Archiv vor dem bisherigen Inhalt einfügen // Älteres Archiv vor dem bisherigen Inhalt einfügen
container.prepend(fragment); container.prepend(fragment);
// Bild-Attachments automatisch laden
for (const mid of archiveImageIds) _autoLoadImageAttachments(mid);
// Button ganz oben, falls es noch ältere Archive gibt // Button ganz oben, falls es noch ältere Archive gibt
if (archives.length > loadedArchiveCount) { if (archives.length > loadedArchiveCount) {
showArchiveLoadButton(); showArchiveLoadButton();
-1
View File
@@ -341,7 +341,6 @@ registerPage('chat', {
} }
// State zurücksetzen und History laden // State zurücksetzen und History laden
messages.length = 0;
lastKnownMessageId = 0; lastKnownMessageId = 0;
loadedArchiveCount = 0; loadedArchiveCount = 0;
_oldestLoadedMessageId = 0; _oldestLoadedMessageId = 0;
+36 -8
View File
@@ -242,7 +242,7 @@ function _addReplyButton(div) {
// ── Message Bubbles ── // ── Message Bubbles ──
function renderUserContent(container, text) { function renderUserContent(container, text, inlineImages) {
const raw = (text || '').trim(); const raw = (text || '').trim();
// Reply-Zitat erkennen und als visuellen Block rendern // Reply-Zitat erkennen und als visuellen Block rendern
@@ -267,19 +267,27 @@ function renderUserContent(container, text) {
// Rest als normalen Text weiterverarbeiten // Rest als normalen Text weiterverarbeiten
if (restText) { if (restText) {
_renderUserTextContent(container, restText); _renderUserTextContent(container, restText, inlineImages);
} }
return; 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) // [Datei: path] oder [Datei: path:1-10] durch Platzhalter ersetzen (vor Markdown-Parsing)
const pattern = /\[Datei: ([^\]]+)\]/g; const pattern = /\[Datei: ([^\]]+)\]/g;
const fileRefs = []; const fileRefs = [];
const mdText = raw.replace(pattern, function(m, ref) { mdText = mdText.replace(pattern, function(m, ref) {
fileRefs.push(ref); fileRefs.push(ref);
return '%%FILEREF' + (fileRefs.length - 1) + '%%'; return '%%FILEREF' + (fileRefs.length - 1) + '%%';
}); });
@@ -287,7 +295,20 @@ function _renderUserTextContent(container, raw) {
// Markdown rendern // Markdown rendern
let html = renderMarkdown(mdText); 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) { html = html.replace(/%%FILEREF(\d+)%%/g, function(m, idx) {
const ref = fileRefs[parseInt(idx)]; const ref = fileRefs[parseInt(idx)];
const filePath = ref.replace(/:\d+(-\d+)?$/, ''); 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 // Fluent Emoji DOM-Parsing
parseFluentEmojis(wrap); 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'); const div = document.createElement('div');
div.className = 'msg user markdown' + (isMarked ? ' marked' : ''); div.className = 'msg user markdown' + (isMarked ? ' marked' : '');
if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); 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(); div.dataset.timestamp = timestamp || new Date().toISOString();
if (messageId) div.dataset.messageId = messageId; if (messageId) div.dataset.messageId = messageId;
if (streamed) div.classList.add('streamed'); if (streamed) div.classList.add('streamed');
renderUserContent(div, text); renderUserContent(div, text, inlineImages);
_addReplyButton(div); _addReplyButton(div);
_addDeleteButton(div, messageId, deletedOn); _addDeleteButton(div, messageId, deletedOn);
_addMarkButton(div, messageId, isMarked); _addMarkButton(div, messageId, isMarked);