Backend: - New OpenClawEndpoint with GET /api/openclaw/sessions and POST /api/openclaw/import - Lists all OpenClaw sessions with preview messages, model, summary - Imports user/assistant text messages (skips tool-use/tool-result) - Cleans user messages (removes webchat prefixes, cron tags, conversation info blocks) - Re-import support: deletes previous openclaw-import chains before importing - New DatabaseService methods: CreateChainForImport, CreateMessageForImport, DeleteOpenClawImport - Added SortKey to MessageRecord (computed: openclaw-import gets id-10B, else id) History sorting: - OpenClaw-imported messages always appear before native messages - Sort by CASE WHEN tag='openclaw-import' THEN id-10000000000 ELSE id END - Cursor-based paging updated from beforeId/afterId to beforeSortKey/afterSortKey - All history queries (GetLatestActive, Before, After, CountBefore) updated Frontend: - Dashboard: new 'OpenClaw Import' menu item in session card dropdown - Modal: shows OpenClaw sessions with metadata, summary, preview messages - Imported messages display blue 'OC' badge in chat - API functions: apiGetOpenclawSessions, apiImportOpenclawSession - History paging updated to use sortKey cursors
232 lines
8.1 KiB
C#
232 lines
8.1 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["beforeSortKey"].FirstOrDefault();
|
|
var beforeSortKey = 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 (beforeSortKey != 0)
|
|
{
|
|
messages = await db.GetActiveMessagesBeforeSortKey(sessionId, beforeSortKey, messageLimit, includeDeleted);
|
|
}
|
|
else
|
|
{
|
|
messages = await db.GetLatestActiveMessages(sessionId, messageLimit, includeDeleted);
|
|
}
|
|
|
|
// Zähle wie viele Nachrichten es VOR der ältesten geladenen noch gibt
|
|
var oldestLoadedSortKey = messages.Count > 0 ? messages[0].SortKey : 0;
|
|
var remainingOlder = oldestLoadedSortKey != 0
|
|
? await db.GetActiveMessageCountBeforeSortKey(sessionId, oldestLoadedSortKey, 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["afterSortKey"].FirstOrDefault();
|
|
var afterSortKey = 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.GetActiveMessagesAfterSortKey(sessionId, afterSortKey, 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);
|
|
}
|
|
}
|