diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 8c3dd28..022f2ec 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -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) { var body = await JsonSerializer.DeserializeAsync(req.Body); - if (body == null || body.Messages == null || body.Messages.Count == 0) + if (body == null || body.Content.ValueKind == JsonValueKind.Undefined) { res.StatusCode = 400; - await res.WriteAsJsonAsync(new { error = "messages required" }); + await res.WriteAsJsonAsync(new { error = "content required" }); return; } @@ -49,58 +49,35 @@ public static class ChatEndpoint 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["Cache-Control"] = "no-cache"; res.Headers["Connection"] = "keep-alive"; - // Messages aus dem Request aufbereiten - var messages = new List(); - 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(node.ToJsonString()); - } - - messages.Add(new Dictionary - { - ["role"] = JsonSerializer.Deserialize($"\"{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); try { diff --git a/Endpoints/HistoryEndpoint.cs b/Endpoints/HistoryEndpoint.cs index a870f02..aff4efd 100644 --- a/Endpoints/HistoryEndpoint.cs +++ b/Endpoints/HistoryEndpoint.cs @@ -222,7 +222,7 @@ public static class HistoryEndpoint var result = new List(); 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 }); } diff --git a/Models/ChatModels.cs b/Models/ChatModels.cs index f39b262..347c96f 100644 --- a/Models/ChatModels.cs +++ b/Models/ChatModels.cs @@ -126,8 +126,8 @@ public class AttachmentInfo public class ChatRequest { - [JsonPropertyName("messages")] - public List Messages { get; set; } = new(); + [JsonPropertyName("content")] + public JsonElement Content { get; set; } [JsonPropertyName("sessionId")] public string SessionId { get; set; } = ""; diff --git a/Services/ChatService.cs b/Services/ChatService.cs index ec99028..953ec57 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -124,15 +124,59 @@ public class ChatService /// /// 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. /// public async Task> BuildHistoryFromDb(string sessionId) { - var chains = await _db.GetActiveChains(sessionId); - var history = new List(); + var chainsList = (await _db.GetActiveChains(sessionId)).ToList(); + // chronologisch sortieren (älteste zuerst) + chainsList.Reverse(); - foreach (var chain in chains) + var history = new List(); + 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 + { + ["role"] = "user", + ["content"] = new List { new { type = "text", text = msg.Content } } + }); + } + else if (msg.Role == "assistant" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content)) + { + history.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = new List { new { type = "text", text = msg.Content } } + }); + } + } + continue; + } + + // Letzte Chain: vollständig mit Tool-Calls var grouped = new Dictionary>(); foreach (var msg in msgs) { @@ -148,36 +192,48 @@ public class ChatService if (msg.Role == "user" && msg.Type == "text") { var content = new List(); - 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", - source = new { type = "base64", media_type = att.MediaType, data = att.Data } - }); - } - else if (att.Type == "document") - { - content.Add(new + content.Add(new + { + type = "image", + source = new { type = "base64", media_type = att.MediaType, data = att.Data } + }); + } + else if (att.Type == "document") { - type = "document", - source = new { type = "base64", media_type = att.MediaType, data = att.Data } - }); + content.Add(new + { + 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 { ["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 { ["role"] = "user", ["content"] = content }); } else if (msg.Role == "assistant" && msg.Type == "text") { - history.Add(new Dictionary + if (!string.IsNullOrEmpty(msg.Content)) { - ["role"] = "assistant", - ["content"] = new List { new { type = "text", text = msg.Content ?? "" } } - }); + history.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = new List { new { type = "text", text = msg.Content } } + }); + } } else if (msg.Role == "assistant" && msg.Type == "tool_use") { diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index f5aa8ad..b5f37b8 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -582,11 +582,12 @@ public class DatabaseService new { messageIds }); } - public async Task> GetChainMessages(string chainId) + public async Task> GetChainMessages(string chainId, bool includeDeleted = false) { using var conn = CreateConnection(); + var deletedFilter = includeDeleted ? "" : " AND deleted_on IS NULL"; return await conn.QueryAsync( - "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 } ); } diff --git a/TODO.md b/TODO.md index 8833435..75af2cb 100644 --- a/TODO.md +++ b/TODO.md @@ -36,7 +36,7 @@ - [ ] 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) - [ ] 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 - [ ] Memory: Embedding-Modell für semantische Suche über Memory-Dateien (Vektor-basiert + BM25 Hybrid Search) diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index f0cdc7e..738b283 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -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; diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index da214a4..7bfccdd 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -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 }); } diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 10e54d5..b069db9 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -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': diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index ec35aef..6cd0edf 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -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 = ` 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(); diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index 559c6ab..6c277e8 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -341,7 +341,6 @@ registerPage('chat', { } // State zurücksetzen und History laden - messages.length = 0; lastKnownMessageId = 0; loadedArchiveCount = 0; _oldestLoadedMessageId = 0; diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index c99e7bd..7226aab 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -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 '' + esc(name) + ''; + } + // Sonst: Platzhalter mit data-Attribut für späteres Laden + return '' + + ' ' + esc(name) + ''; + }); + + // 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);