From 1271c9c651b5a5100931afcca719a6729c0c80ca Mon Sep 17 00:00:00 2001 From: Meleki Date: Fri, 27 Feb 2026 08:11:46 +0100 Subject: [PATCH] File-Upload: Attachments-Tabelle, Base64-Speicherung, Frontend mit Drag&Drop/Paste/Preview, Error-Handling Fix --- Endpoints/ChatEndpoint.cs | 43 ++++++++++++- Endpoints/HistoryEndpoint.cs | 29 +++++++++ Models/ChatModels.cs | 114 +++++++++++++++++++++++++++++++++- Models/MessageRecord.cs | 12 ++++ Services/DatabaseService.cs | 48 +++++++++++++- wwwroot/css/chat.css | 96 ++++++++++++++++++++++++++++ wwwroot/index.html | 3 + wwwroot/js/chat.js | 85 +++++++++++++++++++++++-- wwwroot/js/history.js | 1 + wwwroot/js/pages/chat-page.js | 37 +++++++++++ wwwroot/js/ui.js | 56 ++++++++++++++++- 11 files changed, 513 insertions(+), 11 deletions(-) diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 28861c5..2413111 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -104,20 +104,57 @@ public static class ChatEndpoint var messages = new List(); foreach (var msg in body.Messages) - messages.Add(new { role = msg.Role, content = msg.Content }); + { + // Content bereinigen: interne Felder (_file_name) entfernen + JsonElement content = msg.Content; + if (msg.Content.ValueKind == System.Text.Json.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) { - await db.SaveMessage(new MessageRecord + var textContent = lastUserMsg.GetTextContent(); + var attachmentInfos = lastUserMsg.GetAttachments(); + var attachmentBlocks = lastUserMsg.GetAttachmentBlocks(); + + var messageId = await db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = 0, Role = "user", Type = "text", - Content = lastUserMsg.Content + Content = textContent, + AttachmentCount = attachmentBlocks.Count }); + + // Attachments separat speichern + foreach (var block in attachmentBlocks) + { + await db.SaveAttachment(new AttachmentRecord + { + MessageId = messageId, + Type = block.Type, + MediaType = block.MediaType, + FileName = block.FileName, + Data = block.Data + }); + } } var systemPrompt = new List diff --git a/Endpoints/HistoryEndpoint.cs b/Endpoints/HistoryEndpoint.cs index dcfd933..d9ecf4e 100644 --- a/Endpoints/HistoryEndpoint.cs +++ b/Endpoints/HistoryEndpoint.cs @@ -18,6 +18,7 @@ public static class HistoryEndpoint app.MapPost("/api/archive", HandleArchive); app.MapGet("/api/archives", HandleListArchives); app.MapGet("/api/archive/{archiveId}", HandleGetArchive); + app.MapGet("/api/attachments/{messageId}", HandleGetAttachments); } private static async Task HandleHistory(HttpContext ctx, DatabaseService db) @@ -89,6 +90,34 @@ public static class HistoryEndpoint await ctx.Response.WriteAsJsonAsync(archives); } + private static async Task HandleGetAttachments(HttpContext ctx, DatabaseService db) + { + var messageIdStr = ctx.Request.RouteValues["messageId"]?.ToString(); + if (!long.TryParse(messageIdStr, out var messageId)) + { + ctx.Response.StatusCode = 400; + return; + } + + var attachments = await db.GetAttachments(messageId); + // Ohne Base64-Data zurückgeben (zu groß), stattdessen als separater Download + var result = new List(); + foreach (var att in attachments) + { + result.Add(new + { + att.Id, + att.MessageId, + att.Type, + att.MediaType, + att.FileName, + att.Data, + att.CreatedAt + }); + } + await ctx.Response.WriteAsJsonAsync(result); + } + private static async Task HandleGetArchive(HttpContext ctx, DatabaseService db) { var archiveId = ctx.Request.RouteValues["archiveId"]?.ToString(); diff --git a/Models/ChatModels.cs b/Models/ChatModels.cs index 6b55854..f39b262 100644 --- a/Models/ChatModels.cs +++ b/Models/ChatModels.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; namespace Gott.Models; @@ -9,7 +10,118 @@ public class ChatMessage public string Role { get; set; } = ""; [JsonPropertyName("content")] - public string Content { get; set; } = ""; + public JsonElement Content { get; set; } + + /// + /// Extrahiert den reinen Text aus Content (egal ob string oder content-block array). + /// + public string GetTextContent() + { + if (Content.ValueKind == JsonValueKind.String) + return Content.GetString() ?? ""; + + if (Content.ValueKind == JsonValueKind.Array) + { + var parts = new List(); + foreach (var block in Content.EnumerateArray()) + { + if (block.TryGetProperty("type", out var t) && t.GetString() == "text" + && block.TryGetProperty("text", out var txt)) + parts.Add(txt.GetString() ?? ""); + } + return string.Join("\n", parts); + } + + return Content.ToString(); + } + + /// + /// Prüft ob Datei-Attachments (image/document) enthalten sind. + /// + public bool HasAttachments() + { + if (Content.ValueKind != JsonValueKind.Array) return false; + foreach (var block in Content.EnumerateArray()) + { + if (block.TryGetProperty("type", out var t)) + { + var type = t.GetString(); + if (type == "image" || type == "document") return true; + } + } + return false; + } + + /// + /// Gibt Attachment-Infos zurück (type + media_type). + /// + public List GetAttachments() + { + var result = new List(); + if (Content.ValueKind != JsonValueKind.Array) return result; + foreach (var block in Content.EnumerateArray()) + { + if (block.TryGetProperty("type", out var t)) + { + var type = t.GetString(); + if (type == "image" || type == "document") + { + var mediaType = ""; + if (block.TryGetProperty("source", out var src) && src.TryGetProperty("media_type", out var mt)) + mediaType = mt.GetString() ?? ""; + result.Add(new AttachmentInfo { Type = type, MediaType = mediaType }); + } + } + } + return result; + } + + /// + /// Gibt vollständige Attachment-Blöcke zurück (inkl. Base64-Daten). + /// + public List GetAttachmentBlocks() + { + var result = new List(); + if (Content.ValueKind != JsonValueKind.Array) return result; + foreach (var block in Content.EnumerateArray()) + { + if (block.TryGetProperty("type", out var t)) + { + var type = t.GetString(); + if (type == "image" || type == "document") + { + var mediaType = ""; + var data = ""; + var fileName = ""; + if (block.TryGetProperty("source", out var src)) + { + if (src.TryGetProperty("media_type", out var mt)) + mediaType = mt.GetString() ?? ""; + if (src.TryGetProperty("data", out var d)) + data = d.GetString() ?? ""; + } + if (block.TryGetProperty("_file_name", out var fn)) + fileName = fn.GetString() ?? ""; + result.Add(new AttachmentBlock { Type = type, MediaType = mediaType, Data = data, FileName = fileName }); + } + } + } + return result; + } +} + +public class AttachmentBlock +{ + public string Type { get; set; } = ""; + public string MediaType { get; set; } = ""; + public string Data { get; set; } = ""; + public string FileName { get; set; } = ""; +} + +public class AttachmentInfo +{ + public string Type { get; set; } = ""; + public string MediaType { get; set; } = ""; } public class ChatRequest diff --git a/Models/MessageRecord.cs b/Models/MessageRecord.cs index 7f5cbe4..93464bb 100644 --- a/Models/MessageRecord.cs +++ b/Models/MessageRecord.cs @@ -11,5 +11,17 @@ public class MessageRecord public string ToolCallId { get; set; } // tool_use id public bool IsError { get; set; } public int Iteration { get; set; } // welche Loop-Iteration + public int AttachmentCount { get; set; } // Anzahl angehängter Dateien + public string CreatedAt { get; set; } +} + +public class AttachmentRecord +{ + public long Id { get; set; } + public long MessageId { get; set; } // FK auf messages.id + public string Type { get; set; } // image, document + public string MediaType { get; set; } // image/png, application/pdf, etc. + public string FileName { get; set; } // Original-Dateiname (optional) + public string Data { get; set; } // Base64-encoded Inhalt public string CreatedAt { get; set; } } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 70422fd..d348844 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -97,6 +97,21 @@ public class DatabaseService CREATE INDEX IF NOT EXISTS idx_messages_chain ON messages(chain_id); "); + conn.Execute(@" + CREATE TABLE IF NOT EXISTS attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL, + type TEXT NOT NULL, + media_type TEXT NOT NULL, + file_name TEXT, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (message_id) REFERENCES messages(id) + ); + + CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id); + "); + // Migrations TryAddColumn(conn, "sessions", "agent_id", "TEXT"); TryAddColumn(conn, "sessions", "description", "TEXT"); @@ -105,6 +120,7 @@ public class DatabaseService TryAddColumn(conn, "sessions", "context", "TEXT"); TryAddColumn(conn, "sessions", "is_deleted", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "chains", "agent_id", "TEXT"); + TryAddColumn(conn, "messages", "attachment_count", "INTEGER NOT NULL DEFAULT 0"); } private void TryAddColumn(SqliteConnection conn, string table, string column, string type) @@ -338,12 +354,40 @@ public class DatabaseService using var conn = CreateConnection(); record.CreatedAt = DateTime.UtcNow.ToString("o"); return await conn.ExecuteScalarAsync(@" - INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, created_at) - VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @CreatedAt); + INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, attachment_count, created_at) + VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @CreatedAt); SELECT last_insert_rowid(); ", record); } + // ── Attachments ── + + public async Task SaveAttachment(AttachmentRecord attachment) + { + using var conn = CreateConnection(); + attachment.CreatedAt = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync(@" + INSERT INTO attachments (message_id, type, media_type, file_name, data, created_at) + VALUES (@MessageId, @Type, @MediaType, @FileName, @Data, @CreatedAt) + ", attachment); + } + + public async Task> GetAttachments(long messageId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM attachments WHERE message_id = @messageId ORDER BY id", + new { messageId }); + } + + public async Task> GetAttachmentsForMessages(IEnumerable messageIds) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM attachments WHERE message_id IN @messageIds ORDER BY id", + new { messageIds }); + } + public async Task> GetChainMessages(string chainId) { using var conn = CreateConnection(); diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 5faf31d..3550303 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -97,3 +97,99 @@ display: block; margin-inline: auto; } + +/* File Upload */ + +.file-btn { + align-self: flex-end; + background: none; + border: var(--border-width) solid var(--f2-border); + cursor: pointer; + padding: var(--s2); + font-size: 1.2em; + line-height: 1; +} + +.file-btn:hover { + background: var(--f1-bg); +} + +.file-preview { + padding: var(--s2) var(--s4); + background: var(--f1-bg); + border-top: var(--border-width) solid var(--f1-border); +} + +.file-preview-inner { + display: flex; + align-items: center; + gap: var(--s3); +} + +.file-thumb { + max-height: 60px; + max-width: 100px; + object-fit: contain; + border: var(--border-width) solid var(--f2-border); +} + +.file-icon { + font-size: 2em; +} + +.file-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-remove { + background: none; + border: none; + cursor: pointer; + font-size: 1.2em; + padding: var(--s1); + color: var(--text); + opacity: 0.6; +} + +.file-remove:hover { + opacity: 1; +} + +/* Attachments in History */ + +.attachment-indicator { + margin-top: var(--s2); + font-size: 0.85em; + opacity: 0.7; +} + +.attachment-indicator:hover { + opacity: 1; +} + +.attachment-container { + margin-top: var(--s2); + display: flex; + flex-wrap: wrap; + gap: var(--s2); +} + +.attachment-image { + max-width: 300px; + max-height: 200px; + object-fit: contain; + border: var(--border-width) solid var(--f2-border); + cursor: pointer; +} + +.attachment-file { + display: inline-block; + padding: var(--s2) var(--s3); + background: var(--f2-bg); + border: var(--border-width) solid var(--f2-border); + text-decoration: none; + color: var(--text); +} diff --git a/wwwroot/index.html b/wwwroot/index.html index 8dfbef8..9486eb7 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -122,7 +122,10 @@
+
+ +
diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index d7b090c..816da83 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -2,6 +2,7 @@ const messages = []; let isStreaming = false; +let pendingFile = null; // { name, type, base64 } async function clearChat() { const archive = await apiArchive(getCurrentSessionId()); @@ -22,10 +23,29 @@ async function sendMessage() { const input = $('#input'); const btn = $('#send'); const text = input.value.trim(); - if (!text) return; + if (!text && !pendingFile) return; - messages.push({ role: 'user', content: text }); - addUserBubble(text, true); + // Content bauen: string oder array mit file-blocks + let content; + let bubbleText = text; + + if (pendingFile) { + content = []; + const blockType = pendingFile.type.startsWith('image/') ? 'image' : 'document'; + content.push({ + type: blockType, + source: { type: 'base64', media_type: pendingFile.type, data: pendingFile.base64 }, + _file_name: pendingFile.name + }); + if (text) content.push({ type: 'text', text }); + bubbleText = (blockType === 'image' ? '📷 ' : '📄 ') + pendingFile.name + (text ? '\n' + text : ''); + clearPendingFile(); + } else { + content = text; + } + + messages.push({ role: 'user', content }); + addUserBubble(bubbleText, true); input.value = ''; input.style.height = 'auto'; @@ -85,7 +105,12 @@ async function sendMessage() { case 'error': endStreamBubble(); - addErrorBubble(data.error); + 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': @@ -114,3 +139,55 @@ async function sendMessage() { removeStreamedElements(); } } + +// ── File Handling ── + +function handleFileSelect(file) { + if (!file) return; + + const maxSize = 20 * 1024 * 1024; // 20MB + if (file.size > maxSize) { + alert('Datei zu groß (max. 20MB)'); + return; + } + + const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; + if (!allowed.includes(file.type)) { + alert('Nicht unterstützter Dateityp. Erlaubt: JPEG, PNG, GIF, WebP, PDF'); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + const base64 = reader.result.split(',')[1]; + pendingFile = { name: file.name, type: file.type, base64 }; + showFilePreview(); + }; + reader.readAsDataURL(file); +} + +function showFilePreview() { + const preview = $('#filePreview'); + if (!preview || !pendingFile) return; + + const isImage = pendingFile.type.startsWith('image/'); + preview.innerHTML = ` +
+ ${isImage ? '' : '📄'} + ${esc(pendingFile.name)} + +
+ `; + preview.hidden = false; +} + +function clearPendingFile() { + pendingFile = null; + const preview = $('#filePreview'); + if (preview) { + preview.innerHTML = ''; + preview.hidden = true; + } + const fileInput = $('#fileInput'); + if (fileInput) fileInput.value = ''; +} diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index b63edd1..b4bc712 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -34,6 +34,7 @@ function renderMessages(msgs, addToConversation) { case 'text': if (msg.role === 'user') { addUserBubble(msg.content); + if (msg.attachmentCount > 0) addAttachmentIndicator(msg.id, msg.attachmentCount); if (addToConversation) messages.push({ role: 'user', content: msg.content }); } else if (msg.role === 'assistant') { addAssistantBubble(msg.content); diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index 16bcebd..bacff76 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -43,6 +43,42 @@ registerPage('chat', { }); } + // File Upload + const fileBtn = document.getElementById('fileBtn'); + const fileInput = document.getElementById('fileInput'); + if (fileBtn && fileInput) { + fileBtn.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', (e) => { + if (e.target.files[0]) handleFileSelect(e.target.files[0]); + }); + } + + // Drag & Drop + const chatContainer = document.querySelector('.chat-page'); + if (chatContainer) { + chatContainer.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); }); + chatContainer.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.files[0]) handleFileSelect(e.dataTransfer.files[0]); + }); + } + + // Paste + if (inputEl) { + inputEl.addEventListener('paste', (e) => { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.kind === 'file') { + e.preventDefault(); + handleFileSelect(item.getAsFile()); + return; + } + } + }); + } + // Archivieren const archiveBtn = document.getElementById('archiveBtn'); if (archiveBtn) archiveBtn.addEventListener('click', clearChat); @@ -62,5 +98,6 @@ registerPage('chat', { lastKnownMessageId = 0; loadedArchiveCount = 0; isStreaming = false; + clearPendingFile(); } }); diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 281f627..0af7e22 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -39,9 +39,10 @@ function addAssistantBubble(text) { return div; } -function addErrorBubble(text) { +function addErrorBubble(text, streamed = false) { const div = document.createElement('div'); div.className = 'msg system-info'; + if (streamed) div.classList.add('streamed'); div.style.color = '#ff6b6b'; div.textContent = text; chatEl().appendChild(div); @@ -121,6 +122,59 @@ function removeStreamedElements() { chatEl().querySelectorAll('.streamed').forEach(el => el.remove()); } +// ── Attachment Indicator ── + +function addAttachmentIndicator(messageId, count) { + const chat = chatEl(); + const lastMsg = chat.lastElementChild; + if (!lastMsg) return; + + const indicator = document.createElement('div'); + indicator.className = 'attachment-indicator'; + indicator.textContent = `📎 ${count} Datei${count > 1 ? 'en' : ''}`; + indicator.style.cursor = 'pointer'; + indicator.addEventListener('click', () => loadAndShowAttachments(messageId, indicator)); + lastMsg.appendChild(indicator); +} + +async function loadAndShowAttachments(messageId, indicator) { + if (indicator.dataset.loaded) return; + indicator.dataset.loaded = '1'; + indicator.textContent = '📎 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.textContent = `📄 ${att.fileName || 'Dokument'}`; + container.appendChild(link); + } + } + + indicator.textContent = `📎 ${attachments.length} Datei${attachments.length > 1 ? 'en' : ''}`; + indicator.parentElement.appendChild(container); + scrollDown(); + } catch (e) { + indicator.textContent = '📎 Fehler beim Laden'; + indicator.dataset.loaded = ''; + } +} + // ── Meta Bar ── function setMeta(text) {