File-Upload: Attachments-Tabelle, Base64-Speicherung, Frontend mit Drag&Drop/Paste/Preview, Error-Handling Fix
This commit is contained in:
1 parent
680f1d6fe2
commit
1271c9c651
11 files changed
+513
-11
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 = '';
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user