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:
1 parent
51db01752f
commit
8fc29c82d9
12 files changed
+222
-106
No files matched your search
+27
-50
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user