Files
gott/Endpoints/HistoryEndpoint.cs
T
Meleki 8fc29c82d9 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
2026-03-04 14:29:33 +01:00

232 lines
8.0 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Gott.Extensions;
using Gott.Models;
using Gott.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Gott.Endpoints;
public static class HistoryEndpoint
{
public static void MapHistoryEndpoint(this IEndpointRouteBuilder app)
{
app.MapGet("/api/history", HandleHistory);
app.MapGet("/api/history/poll", HandlePoll);
app.MapPost("/api/archive", HandleArchive);
app.MapGet("/api/archives", HandleListArchives);
app.MapGet("/api/archive/{archiveId}", HandleGetArchive);
app.MapGet("/api/attachments/{messageId}", HandleGetAttachments);
app.MapPut("/api/messages/{messageId}/mark", HandleToggleMark);
app.MapPut("/api/messages/{messageId}/delete", HandleToggleDelete);
app.MapGet("/api/chains/{chainId}", HandleGetChain);
}
private static async Task<bool> VerifySessionOwner(HttpContext ctx, DatabaseService db, string sessionId)
{
if (string.IsNullOrEmpty(sessionId)) return false;
var session = await db.GetSession(sessionId);
return session != null && session.UserId == ctx.GetUserId();
}
private static async Task HandleHistory(HttpContext ctx, DatabaseService db)
{
var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault();
if (string.IsNullOrEmpty(sessionId) || !await VerifySessionOwner(ctx, db, sessionId))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "sessionId required" });
return;
}
const int messageLimit = 500;
var beforeStr = ctx.Request.Query["beforeId"].FirstOrDefault();
var beforeId = long.TryParse(beforeStr, out var b) ? b : 0;
var includeDeleted = ctx.Request.Query["includeDeleted"].FirstOrDefault() == "true";
var totalCount = await db.GetActiveMessageCount(sessionId, includeDeleted);
List<MessageRecord> messages;
if (beforeId > 0)
{
messages = await db.GetActiveMessagesBeforeId(sessionId, beforeId, messageLimit, includeDeleted);
}
else
{
messages = await db.GetLatestActiveMessages(sessionId, messageLimit, includeDeleted);
}
// Zähle wie viele Nachrichten es VOR der ältesten geladenen noch gibt
var oldestLoadedId = messages.Count > 0 ? messages[0].Id : 0;
var remainingOlder = oldestLoadedId > 0
? await db.GetActiveMessageCountBeforeId(sessionId, oldestLoadedId, includeDeleted)
: 0;
// Nach Chain gruppieren (Reihenfolge beibehalten)
var chainIds = messages.Select(m => m.ChainId).Distinct().ToList();
var chains = await db.GetActiveChains(sessionId);
var chainMap = chains.ToDictionary(c => c.ChainId);
var result = new List<object>();
foreach (var chainId in chainIds)
{
chainMap.TryGetValue(chainId, out var chain);
var chainMessages = messages.Where(m => m.ChainId == chainId).ToList();
result.Add(new { chain, messages = chainMessages });
}
await ctx.Response.WriteAsJsonAsync(new
{
entries = result,
hasMore = remainingOlder > 0,
remainingCount = remainingOlder,
totalCount,
loadedCount = messages.Count
});
}
private static async Task HandlePoll(HttpContext ctx, DatabaseService db)
{
var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault();
var afterStr = ctx.Request.Query["afterId"].FirstOrDefault();
var afterId = long.TryParse(afterStr, out var a) ? a : 0;
var includeDeleted = ctx.Request.Query["includeDeleted"].FirstOrDefault() == "true";
if (string.IsNullOrEmpty(sessionId) || !await VerifySessionOwner(ctx, db, sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var messages = await db.GetActiveMessagesAfterId(sessionId, afterId, includeDeleted: includeDeleted);
await ctx.Response.WriteAsJsonAsync(messages);
}
private static async Task HandleArchive(HttpContext ctx, DatabaseService db)
{
var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault();
if (string.IsNullOrEmpty(sessionId) || !await VerifySessionOwner(ctx, db, sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var archive = await db.CreateArchive(sessionId);
if (archive == null)
{
ctx.Response.StatusCode = 204;
return;
}
await ctx.Response.WriteAsJsonAsync(archive);
}
private static async Task HandleListArchives(HttpContext ctx, DatabaseService db)
{
var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault();
if (string.IsNullOrEmpty(sessionId) || !await VerifySessionOwner(ctx, db, sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var archives = await db.GetArchives(sessionId);
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 HandleToggleMark(HttpContext ctx, DatabaseService db)
{
var messageIdStr = ctx.Request.RouteValues["messageId"]?.ToString();
if (!long.TryParse(messageIdStr, out var messageId))
{
ctx.Response.StatusCode = 400;
return;
}
var isMarked = await db.ToggleMessageMark(messageId);
await ctx.Response.WriteAsJsonAsync(new { messageId, isMarked });
}
private static async Task HandleToggleDelete(HttpContext ctx, DatabaseService db)
{
var messageIdStr = ctx.Request.RouteValues["messageId"]?.ToString();
if (!long.TryParse(messageIdStr, out var messageId))
{
ctx.Response.StatusCode = 400;
return;
}
var deletedOn = await db.ToggleMessageDelete(messageId);
await ctx.Response.WriteAsJsonAsync(new { messageId, deletedOn });
}
private static async Task HandleGetChain(HttpContext ctx, DatabaseService db)
{
var chainId = ctx.Request.RouteValues["chainId"]?.ToString();
if (string.IsNullOrEmpty(chainId))
{
ctx.Response.StatusCode = 400;
return;
}
var chain = await db.GetChain(chainId);
if (chain == null)
{
ctx.Response.StatusCode = 404;
return;
}
await ctx.Response.WriteAsJsonAsync(chain);
}
private static async Task HandleGetArchive(HttpContext ctx, DatabaseService db)
{
var archiveId = ctx.Request.RouteValues["archiveId"]?.ToString();
if (string.IsNullOrEmpty(archiveId))
{
ctx.Response.StatusCode = 400;
return;
}
var chains = await db.GetArchiveChains(archiveId);
var result = new List<object>();
foreach (var chain in chains)
{
var messages = await db.GetChainMessages(chain.ChainId, includeDeleted: true);
result.Add(new { chain, messages });
}
await ctx.Response.WriteAsJsonAsync(result);
}
}