File-Upload: Attachments-Tabelle, Base64-Speicherung, Frontend mit Drag&Drop/Paste/Preview, Error-Handling Fix

This commit is contained in:
Meleki committed 2026-02-27 08:11:46 +01:00
1 parent 680f1d6fe2
commit 1271c9c651
11 files changed
+513 -11

No files matched your search

+40 -3
View File
@@ -104,20 +104,57 @@ public static class ChatEndpoint
var messages = new List<object>();
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<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)
{
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<object>
+29
View File
@@ -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<object>();
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();
+113 -1
View File
@@ -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; }
/// <summary>
/// Extrahiert den reinen Text aus Content (egal ob string oder content-block array).
/// </summary>
public string GetTextContent()
{
if (Content.ValueKind == JsonValueKind.String)
return Content.GetString() ?? "";
if (Content.ValueKind == JsonValueKind.Array)
{
var parts = new List<string>();
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();
}
/// <summary>
/// Prüft ob Datei-Attachments (image/document) enthalten sind.
/// </summary>
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;
}
/// <summary>
/// Gibt Attachment-Infos zurück (type + media_type).
/// </summary>
public List<AttachmentInfo> GetAttachments()
{
var result = new List<AttachmentInfo>();
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;
}
/// <summary>
/// Gibt vollständige Attachment-Blöcke zurück (inkl. Base64-Daten).
/// </summary>
public List<AttachmentBlock> GetAttachmentBlocks()
{
var result = new List<AttachmentBlock>();
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
+12
View File
@@ -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; }
}
+46 -2
View File
@@ -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<long>(@"
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<IEnumerable<AttachmentRecord>> GetAttachments(long messageId)
{
using var conn = CreateConnection();
return await conn.QueryAsync<AttachmentRecord>(
"SELECT * FROM attachments WHERE message_id = @messageId ORDER BY id",
new { messageId });
}
public async Task<IEnumerable<AttachmentRecord>> GetAttachmentsForMessages(IEnumerable<long> messageIds)
{
using var conn = CreateConnection();
return await conn.QueryAsync<AttachmentRecord>(
"SELECT * FROM attachments WHERE message_id IN @messageIds ORDER BY id",
new { messageIds });
}
public async Task<IEnumerable<MessageRecord>> GetChainMessages(string chainId)
{
using var conn = CreateConnection();
+96
View File
@@ -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);
}
+3
View File
@@ -122,7 +122,10 @@
</header>
<div class="meta-bar" id="meta"></div>
<div id="chat"></div>
<div id="filePreview" class="file-preview" hidden></div>
<div class="input-area">
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/gif,image/webp,application/pdf" hidden>
<button id="fileBtn" class="file-btn" title="Datei anhängen">📎</button>
<textarea id="input" placeholder="Nachricht eingeben... (Ctrl+Enter)" rows="2"></textarea>
<button id="send">Senden</button>
</div>
+81 -4
View File
@@ -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 = `
<div class="file-preview-inner">
${isImage ? '<img src="data:' + pendingFile.type + ';base64,' + pendingFile.base64 + '" class="file-thumb">' : '<span class="file-icon">📄</span>'}
<span class="file-name">${esc(pendingFile.name)}</span>
<button class="file-remove" onclick="clearPendingFile()">✕</button>
</div>
`;
preview.hidden = false;
}
function clearPendingFile() {
pendingFile = null;
const preview = $('#filePreview');
if (preview) {
preview.innerHTML = '';
preview.hidden = true;
}
const fileInput = $('#fileInput');
if (fileInput) fileInput.value = '';
}
+1
View File
@@ -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);
+37
View File
@@ -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();
}
});
+55 -1
View File
@@ -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) {