From 3b7bdb7a200c503c6e38be00193c2fd8e8791a20 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sun, 8 Mar 2026 17:32:32 +0100 Subject: [PATCH] feat: OpenClaw session import with sorted history 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 --- Endpoints/HistoryEndpoint.cs | 20 +- Endpoints/OpenClawEndpoint.cs | 386 ++++++++++++++++++++++++++++++++++ Models/MessageRecord.cs | 1 + Program.cs | 1 + Services/DatabaseService.cs | 79 +++++-- wwwroot/css/chat.css | 128 +++++++++++ wwwroot/js/api.js | 32 ++- wwwroot/js/history.js | 29 ++- wwwroot/js/modals.js | 106 ++++++++++ wwwroot/js/pages/dashboard.js | 6 + 10 files changed, 750 insertions(+), 38 deletions(-) create mode 100644 Endpoints/OpenClawEndpoint.cs diff --git a/Endpoints/HistoryEndpoint.cs b/Endpoints/HistoryEndpoint.cs index aff4efd..95c081e 100644 --- a/Endpoints/HistoryEndpoint.cs +++ b/Endpoints/HistoryEndpoint.cs @@ -43,16 +43,16 @@ public static class HistoryEndpoint } const int messageLimit = 500; - var beforeStr = ctx.Request.Query["beforeId"].FirstOrDefault(); - var beforeId = long.TryParse(beforeStr, out var b) ? b : 0; + 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 messages; - if (beforeId > 0) + if (beforeSortKey != 0) { - messages = await db.GetActiveMessagesBeforeId(sessionId, beforeId, messageLimit, includeDeleted); + messages = await db.GetActiveMessagesBeforeSortKey(sessionId, beforeSortKey, messageLimit, includeDeleted); } else { @@ -60,9 +60,9 @@ public static class HistoryEndpoint } // 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) + 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) @@ -91,8 +91,8 @@ public static class HistoryEndpoint 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 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)) @@ -101,7 +101,7 @@ public static class HistoryEndpoint return; } - var messages = await db.GetActiveMessagesAfterId(sessionId, afterId, includeDeleted: includeDeleted); + var messages = await db.GetActiveMessagesAfterSortKey(sessionId, afterSortKey, includeDeleted: includeDeleted); await ctx.Response.WriteAsJsonAsync(messages); } diff --git a/Endpoints/OpenClawEndpoint.cs b/Endpoints/OpenClawEndpoint.cs new file mode 100644 index 0000000..1cb772b --- /dev/null +++ b/Endpoints/OpenClawEndpoint.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Dapper; +using Gott.Extensions; +using Gott.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Data.Sqlite; + +namespace Gott.Endpoints; + +public static class OpenClawEndpoint +{ + private const string OpenClawDbRelativePath = "publish/data/openclaw-sessions.db"; + private const string ImportTag = "openclaw-import"; + + public static void MapOpenClawEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/openclaw/sessions", HandleListSessions); + app.MapPost("/api/openclaw/import", HandleImport); + } + + private static string GetOpenClawDbPath() + { + // Resolve relative to the app base directory + var basePath = AppContext.BaseDirectory; + // Navigate up from bin/Debug/net10.0 or similar to the project root + var candidate = Path.Combine(basePath, OpenClawDbRelativePath); + if (File.Exists(candidate)) return candidate; + + // Try from current directory + candidate = Path.Combine(Directory.GetCurrentDirectory(), OpenClawDbRelativePath); + if (File.Exists(candidate)) return candidate; + + // Try absolute common path + candidate = Path.Combine("/app/gott", OpenClawDbRelativePath); + if (File.Exists(candidate)) return candidate; + + return null; + } + + private static SqliteConnection OpenReadOnly(string dbPath) + { + var conn = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); + conn.Open(); + DefaultTypeMap.MatchNamesWithUnderscores = true; + return conn; + } + + // ── GET /api/openclaw/sessions ── + + private static async Task HandleListSessions(HttpContext ctx) + { + var dbPath = GetOpenClawDbPath(); + if (dbPath == null) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "OpenClaw-Datenbank nicht gefunden" }); + return; + } + + using var conn = OpenReadOnly(dbPath); + + // Get all sessions + var sessions = (await conn.QueryAsync( + "SELECT id, session, timestamp, substr(data,1,500) as data FROM entries WHERE type='session' ORDER BY timestamp DESC" + )).ToList(); + + var result = new List(); + + foreach (var s in sessions) + { + string sessionId = s.session; + + // Count user + assistant text messages (skip toolResult) + var messageCounts = await conn.QuerySingleAsync(@" + SELECT + COUNT(CASE WHEN json_extract(data,'$.message.role')='user' THEN 1 END) as userCount, + COUNT(CASE WHEN json_extract(data,'$.message.role')='assistant' THEN 1 END) as assistantCount + FROM entries + WHERE type='message' AND session=@sessionId", + new { sessionId }); + + int userCount = (int)(long)messageCounts.userCount; + int assistantCount = (int)(long)messageCounts.assistantCount; + int totalMessages = userCount + assistantCount; + + if (totalMessages == 0) continue; // Skip empty sessions + + // Get chain count + var chainCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(DISTINCT chainId) FROM entries WHERE type='message' AND session=@sessionId AND chainId IS NOT NULL", + new { sessionId }); + + // Get most common model + var model = await conn.ExecuteScalarAsync(@" + SELECT json_extract(data,'$.message.model') + FROM entries + WHERE type='message' AND session=@sessionId AND json_extract(data,'$.message.role')='assistant' AND json_extract(data,'$.message.model') IS NOT NULL + GROUP BY json_extract(data,'$.message.model') + ORDER BY COUNT(*) DESC LIMIT 1", + new { sessionId }); + + // Get summary from latest compaction + var summaryRaw = await conn.ExecuteScalarAsync(@" + SELECT json_extract(data,'$.summary') + FROM entries + WHERE type='compaction' AND session=@sessionId + ORDER BY timestamp DESC LIMIT 1", + new { sessionId }); + var summary = summaryRaw != null && summaryRaw.Length > 200 ? summaryRaw.Substring(0, 200) + "…" : summaryRaw; + + // Get last 5 user/assistant text messages for preview + var previewEntries = (await conn.QueryAsync(@" + SELECT json_extract(data,'$.message.role') as role, data + FROM entries + WHERE type='message' AND session=@sessionId + AND json_extract(data,'$.message.role') IN ('user','assistant') + ORDER BY timestamp DESC LIMIT 8", + new { sessionId })).ToList(); + + var previewMessages = new List(); + foreach (var pe in previewEntries) + { + string role = pe.role; + string dataJson = pe.data; + + var text = ExtractTextFromEntryData(dataJson); + if (string.IsNullOrWhiteSpace(text)) continue; + + if (role == "user") + text = CleanUserMessage(text); + + if (text.Length > 120) + text = text.Substring(0, 120) + "…"; + + previewMessages.Add(new { role, text }); + if (previewMessages.Count >= 5) break; + } + + previewMessages.Reverse(); // Chronological order + + result.Add(new + { + sessionId, + createdAt = (string)s.timestamp, + userMessages = userCount, + assistantMessages = assistantCount, + totalMessages, + chainCount, + model = model ?? "unknown", + summary, + previewMessages + }); + } + + await ctx.Response.WriteAsJsonAsync(result); + } + + // ── POST /api/openclaw/import ── + + private static async Task HandleImport(HttpContext ctx, DatabaseService db) + { + var userId = ctx.GetUserId(); + var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); + + var openclawSessionId = body.TryGetProperty("openclawSessionId", out var ocs) ? ocs.GetString() : null; + var sessionId = body.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null; + + if (string.IsNullOrEmpty(openclawSessionId) || string.IsNullOrEmpty(sessionId)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "openclawSessionId und sessionId sind erforderlich" }); + return; + } + + // Validate target session + var session = await db.GetSession(sessionId); + if (session == null || session.UserId != userId) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Session nicht gefunden" }); + return; + } + + var dbPath = GetOpenClawDbPath(); + if (dbPath == null) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "OpenClaw-Datenbank nicht gefunden" }); + return; + } + + // Delete previous imports for this session (update scenario) + await db.DeleteOpenClawImport(sessionId); + + // Read all messages from OpenClaw + List entries; + using (var conn = OpenReadOnly(dbPath)) + { + entries = (await conn.QueryAsync(@" + SELECT id, chainId, timestamp, data + FROM entries + WHERE type='message' AND session=@openclawSessionId + ORDER BY timestamp ASC", + new { openclawSessionId })).ToList(); + } + + if (entries.Count == 0) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Keine Nachrichten in der OpenClaw-Session gefunden" }); + return; + } + + // Group by chainId + var chains = new Dictionary>(); + foreach (var entry in entries) + { + string chainId = entry.chainId ?? "__default__"; + if (!chains.ContainsKey(chainId)) + chains[chainId] = new List(); + chains[chainId].Add(entry); + } + + int totalChains = 0; + int totalMessages = 0; + string agentId = session.AgentId; + + foreach (var kvp in chains) + { + var chainEntries = kvp.Value; + var chainMessages = new List<(string role, string type, string content, string createdAt)>(); + + string chainModel = "unknown"; + int inputTokens = 0; + int outputTokens = 0; + int cacheCreationTokens = 0; + int cacheReadTokens = 0; + string firstTimestamp = null; + + foreach (var entry in chainEntries) + { + string dataJson = (string)entry.data; + string timestamp = (string)entry.timestamp; + + if (firstTimestamp == null) firstTimestamp = timestamp; + + JsonElement msgData; + try + { + msgData = JsonSerializer.Deserialize(dataJson); + } + catch { continue; } + + if (!msgData.TryGetProperty("message", out var message)) continue; + + var role = message.TryGetProperty("role", out var r) ? r.GetString() : null; + if (role == null) continue; + + // Skip toolResult entirely + if (role == "toolResult") continue; + + // Extract model and usage from assistant messages + if (role == "assistant") + { + if (message.TryGetProperty("model", out var modelEl)) + { + var m = modelEl.GetString(); + if (!string.IsNullOrEmpty(m)) chainModel = m; + } + if (message.TryGetProperty("usage", out var usage)) + { + if (usage.TryGetProperty("input", out var inp)) inputTokens += inp.GetInt32(); + if (usage.TryGetProperty("output", out var outp)) outputTokens += outp.GetInt32(); + if (usage.TryGetProperty("cacheWrite", out var cw)) cacheCreationTokens += cw.GetInt32(); + if (usage.TryGetProperty("cacheRead", out var cr)) cacheReadTokens += cr.GetInt32(); + } + } + + // Extract content + if (!message.TryGetProperty("content", out var contentArr) || contentArr.ValueKind != JsonValueKind.Array) + continue; + + // Process content blocks + foreach (var block in contentArr.EnumerateArray()) + { + var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() : null; + + if (blockType == "text") + { + var text = block.TryGetProperty("text", out var t) ? t.GetString() : null; + if (string.IsNullOrWhiteSpace(text)) continue; + + // Skip tool-use and tool-result blocks + if (role == "user") + text = CleanUserMessage(text); + + if (!string.IsNullOrWhiteSpace(text)) + { + chainMessages.Add(( + role == "user" ? "user" : "assistant", + "text", + text, + timestamp + )); + } + } + // Skip thinking, toolCall, toolResult blocks + } + } + + // Skip chains with no text messages + if (chainMessages.Count == 0) continue; + + // Create chain record + var newChainId = Guid.NewGuid().ToString("N"); + await db.CreateChainForImport(newChainId, sessionId, agentId, chainModel, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, firstTimestamp ?? DateTime.UtcNow.ToString("o")); + + // Create messages + foreach (var (msgRole, msgType, content, createdAt) in chainMessages) + { + await db.CreateMessageForImport(newChainId, msgRole, msgType, content, createdAt, ImportTag); + totalMessages++; + } + + totalChains++; + } + + await ctx.Response.WriteAsJsonAsync(new + { + imported = true, + chainCount = totalChains, + messageCount = totalMessages + }); + } + + // ── Helper Methods ── + + private static string ExtractTextFromEntryData(string dataJson) + { + try + { + var data = JsonSerializer.Deserialize(dataJson); + if (!data.TryGetProperty("message", out var message)) return null; + if (!message.TryGetProperty("content", out var contentArr) || contentArr.ValueKind != JsonValueKind.Array) return null; + + foreach (var block in contentArr.EnumerateArray()) + { + var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() : null; + if (blockType == "text") + { + var text = block.TryGetProperty("text", out var t) ? t.GetString() : null; + if (!string.IsNullOrWhiteSpace(text)) return text; + } + } + } + catch { } + return null; + } + + private static string CleanUserMessage(string text) + { + if (string.IsNullOrEmpty(text)) return text; + + // Remove [Direct 1:1 webchat -- always respond, never use NO_REPLY] + text = Regex.Replace(text, @"\[Direct 1:1 webchat\s*--\s*always respond,?\s*never use NO_REPLY\]\s*", "", RegexOptions.IgnoreCase); + + // Remove [Current message - respond to this]\nUser: + text = Regex.Replace(text, @"\[Current message\s*-\s*respond to this\]\s*User:\s*", "", RegexOptions.IgnoreCase); + + // Remove [cron:...] prefix + text = Regex.Replace(text, @"\[cron:[^\]]*\]\s*", ""); + + // Remove "Conversation info (untrusted metadata):\n```json\n{...}\n```" blocks + text = Regex.Replace(text, @"Conversation info \(untrusted metadata\):\s*```[a-z]*\s*\{.*?\}\s*```\s*", "", RegexOptions.Singleline); + + return text.Trim(); + } +} diff --git a/Models/MessageRecord.cs b/Models/MessageRecord.cs index e60aaba..e4a3515 100644 --- a/Models/MessageRecord.cs +++ b/Models/MessageRecord.cs @@ -17,6 +17,7 @@ public class MessageRecord public bool IsMarked { get; set; } // als wichtig markiert public string DeletedOn { get; set; } // null = aktiv, sonst Zeitstempel der Löschung public string CreatedAt { get; set; } + public long SortKey { get; set; } // Computed: openclaw-import messages get id - 10B, else id } public class AttachmentRecord diff --git a/Program.cs b/Program.cs index d69d4b6..b856c03 100644 --- a/Program.cs +++ b/Program.cs @@ -83,6 +83,7 @@ app.MapKreaEndpoints(); app.MapSharedEndpoints(); app.MapAdminEndpoints(); app.MapServerSettingsEndpoints(); +app.MapOpenClawEndpoints(); var port = app.Configuration.GetValue("Port", 19000); app.Run($"http://0.0.0.0:{port}"); diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 49fdfd5..a03f025 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -796,6 +796,55 @@ public class DatabaseService return messageId; } + // ── OpenClaw Import ── + + public async Task CreateChainForImport(string chainId, string sessionId, string agentId, string model, int inputTokens, int outputTokens, int cacheCreationTokens, int cacheReadTokens, string createdAt) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + INSERT INTO chains (chain_id, session_id, agent_id, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, iterations, stop_reason, duration_ms, plan_mode, created_at) + VALUES (@chainId, @sessionId, @agentId, @model, @inputTokens, @outputTokens, @cacheCreationTokens, @cacheReadTokens, 0, 'end_turn', 0, 0, @createdAt) + ", new { chainId, sessionId, agentId, model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, createdAt }); + } + + public async Task CreateMessageForImport(string chainId, string role, string type, string content, string createdAt, string tag) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + INSERT INTO messages (chain_id, role, type, content, is_error, iteration, attachment_count, tag, created_at) + VALUES (@chainId, @role, @type, @content, 0, 0, 0, @tag, @createdAt) + ", new { chainId, role, type, content, tag, createdAt }); + } + + public async Task DeleteOpenClawImport(string sessionId) + { + using var conn = CreateConnection(); + using var tx = conn.BeginTransaction(); + + // Get all chain_ids that belong to this session and have imported messages + var importedChainIds = (await conn.QueryAsync(@" + SELECT DISTINCT c.chain_id + FROM chains c + INNER JOIN messages m ON m.chain_id = c.chain_id + WHERE c.session_id = @sessionId AND m.tag = 'openclaw-import' + ", new { sessionId }, tx)).ToList(); + + if (importedChainIds.Count > 0) + { + // Delete messages for these chains + await conn.ExecuteAsync( + "DELETE FROM messages WHERE chain_id IN @chainIds", + new { chainIds = importedChainIds }, tx); + + // Delete the chains themselves + await conn.ExecuteAsync( + "DELETE FROM chains WHERE chain_id IN @chainIds", + new { chainIds = importedChainIds }, tx); + } + + tx.Commit(); + } + // ── Attachments ── public async Task> GetAttachments(long messageId) @@ -839,48 +888,48 @@ public class DatabaseService using var conn = CreateConnection(); var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; var messages = await conn.QueryAsync($@" - SELECT m.* FROM messages m + SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m JOIN chains c ON m.chain_id = c.chain_id WHERE c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} - ORDER BY m.id DESC LIMIT @limit", + ORDER BY sort_key DESC LIMIT @limit", new { sessionId, limit }); return messages.Reverse().ToList(); } - public async Task GetActiveMessageCountBeforeId(string sessionId, long beforeId, bool includeDeleted = false) + public async Task GetActiveMessageCountBeforeSortKey(string sessionId, long beforeSortKey, bool includeDeleted = false) { using var conn = CreateConnection(); var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; return await conn.ExecuteScalarAsync($@" SELECT COUNT(*) FROM messages m JOIN chains c ON m.chain_id = c.chain_id - WHERE m.id < @beforeId AND c.archive_id IS NULL AND c.session_id = @sessionId AND m.type = 'text' {deletedFilter}", - new { sessionId, beforeId }); + WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) < @beforeSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId AND m.type = 'text' {deletedFilter}", + new { sessionId, beforeSortKey }); } - public async Task> GetActiveMessagesBeforeId(string sessionId, long beforeId, int limit, bool includeDeleted = false) + public async Task> GetActiveMessagesBeforeSortKey(string sessionId, long beforeSortKey, int limit, bool includeDeleted = false) { using var conn = CreateConnection(); var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; var messages = await conn.QueryAsync($@" - SELECT m.* FROM messages m + SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m JOIN chains c ON m.chain_id = c.chain_id - WHERE m.id < @beforeId AND c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} - ORDER BY m.id DESC LIMIT @limit", - new { sessionId, beforeId, limit }); + WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) < @beforeSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} + ORDER BY sort_key DESC LIMIT @limit", + new { sessionId, beforeSortKey, limit }); return messages.Reverse().ToList(); } - public async Task> GetActiveMessagesAfterId(string sessionId, long afterId, int limit = 200, bool includeDeleted = false) + public async Task> GetActiveMessagesAfterSortKey(string sessionId, long afterSortKey, int limit = 200, bool includeDeleted = false) { using var conn = CreateConnection(); var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; return await conn.QueryAsync($@" - SELECT m.* FROM messages m + SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m JOIN chains c ON m.chain_id = c.chain_id - WHERE m.id > @afterId AND c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} - ORDER BY m.id LIMIT @limit", - new { sessionId, afterId, limit }); + WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) > @afterSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} + ORDER BY sort_key LIMIT @limit", + new { sessionId, afterSortKey, limit }); } // ── Message Marking ── diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index b2dd785..9efa3ae 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -1208,3 +1208,131 @@ border-color: var(--f1-border); color: var(--muted); } + +/* ── OpenClaw Import Badge ── */ + +.openclaw-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.7em; + padding: 2px 6px; + border-radius: var(--border-radius); + background: rgb(from var(--primary) r g b / 0.15); + color: var(--primary); + font-weight: 600; + margin-bottom: 6px; + letter-spacing: 0.02em; +} + +.openclaw-badge i { + font-size: 0.9em; +} + +/* ── OpenClaw Import Modal ── */ + +.openclaw-import-modal { + max-width: 700px; +} + +.openclaw-hint { + font-size: var(--small-font-size); + color: var(--muted); + margin-bottom: var(--box-spacing); +} + +.openclaw-session-list { + display: flex; + flex-direction: column; + gap: var(--box-spacing); +} + +.openclaw-session-card { + border: var(--border-width) solid var(--f2-border); + border-radius: var(--border-radius); + padding: var(--box-padding); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.openclaw-session-card:hover { + border-color: var(--primary); + background: rgb(from var(--primary) r g b / 0.05); +} + +.openclaw-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--box-spacing); + margin-bottom: 6px; + flex-wrap: wrap; +} + +.openclaw-session-id { + font-family: var(--mono-font); + font-size: var(--small-font-size); + color: var(--primary); + font-weight: 600; +} + +.openclaw-card-meta { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.openclaw-meta-item { + font-size: var(--small-font-size); + color: var(--muted); + white-space: nowrap; +} + +.openclaw-meta-item i { + margin-right: 3px; +} + +.openclaw-summary { + font-size: var(--small-font-size); + color: var(--text-color); + margin-bottom: 8px; + line-height: 1.4; + opacity: 0.8; +} + +.openclaw-preview { + display: flex; + flex-direction: column; + gap: 4px; + border-top: var(--border-width) solid var(--f1-border); + padding-top: 8px; +} + +.openclaw-preview-msg { + font-size: var(--small-font-size); + display: flex; + gap: 6px; + align-items: flex-start; + line-height: 1.4; +} + +.openclaw-preview-msg i { + flex-shrink: 0; + margin-top: 2px; + width: 14px; + text-align: center; +} + +.openclaw-preview-user { + color: var(--text-color); +} + +.openclaw-preview-assistant { + color: var(--muted); +} + +.openclaw-preview-msg span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 556820b..f9396fa 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -96,16 +96,16 @@ async function apiDeleteAgent(agentId) { return fetch(`/api/agents/${agentId}`, { method: 'DELETE' }); } -async function apiHistory(sessionId, beforeId, includeDeleted = false) { +async function apiHistory(sessionId, beforeSortKey, includeDeleted = false) { let url = `/api/history?sessionId=${sessionId}`; - if (beforeId) url += `&beforeId=${beforeId}`; + if (beforeSortKey) url += `&beforeSortKey=${beforeSortKey}`; if (includeDeleted) url += `&includeDeleted=true`; const resp = await fetch(url); return resp.json(); } -async function apiHistoryPoll(sessionId, afterId, includeDeleted = false) { - let url = `/api/history/poll?sessionId=${sessionId}&afterId=${afterId}`; +async function apiHistoryPoll(sessionId, afterSortKey, includeDeleted = false) { + let url = `/api/history/poll?sessionId=${sessionId}&afterSortKey=${afterSortKey}`; if (includeDeleted) url += `&includeDeleted=true`; const resp = await fetch(url); return resp.json(); @@ -544,6 +544,30 @@ async function apiKreaGenerate(prompt, model, width, height) { return resp.json(); } +// ── OpenClaw Import API ── + +async function apiGetOpenclawSessions() { + const resp = await fetch('/api/openclaw/sessions'); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || 'OpenClaw-Sessions konnten nicht geladen werden'); + } + return resp.json(); +} + +async function apiImportOpenclawSession(openclawSessionId, sessionId) { + const resp = await fetch('/api/openclaw/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ openclawSessionId, sessionId }) + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || 'Import fehlgeschlagen'); + } + return resp.json(); +} + // ── Backup API ── async function apiBackupDb() { diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 1970f9f..59dae2a 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -34,7 +34,7 @@ async function _autoLoadImageAttachments(messageId) { // ── History State ── let lastKnownMessageId = 0; -let _oldestLoadedMessageId = 0; +let _oldestLoadedSortKey = 0; let _hasMoreOlderMessages = false; let historyPollInterval = null; @@ -50,7 +50,7 @@ async function loadHistory() { // Älteste geladene Message-ID merken für Nachladen _hasMoreOlderMessages = data.hasMore; - _oldestLoadedMessageId = _findOldestMessageId(entries); + _oldestLoadedSortKey = _findOldestSortKey(entries); if (data.hasMore) { showOlderMessagesButton(data.remainingCount); @@ -76,7 +76,7 @@ async function loadHistory() { } async function loadOlderMessages() { - if (!_hasMoreOlderMessages || !_oldestLoadedMessageId) return; + if (!_hasMoreOlderMessages || !_oldestLoadedSortKey) return; const btn = chatEl().querySelector('.older-messages-btn'); if (btn) { @@ -86,12 +86,12 @@ async function loadOlderMessages() { try { const sid = getCurrentSessionId(); - const data = await apiHistory(sid, _oldestLoadedMessageId, isDeletedVisible()); + const data = await apiHistory(sid, _oldestLoadedSortKey, isDeletedVisible()); const entries = data.entries || []; _hasMoreOlderMessages = data.hasMore; - const newOldest = _findOldestMessageId(entries); - if (newOldest > 0) _oldestLoadedMessageId = newOldest; + const newOldest = _findOldestSortKey(entries); + if (newOldest) _oldestLoadedSortKey = newOldest; // Button entfernen removeOlderMessagesButton(); @@ -145,10 +145,10 @@ async function loadOlderMessages() { // ── Helper Functions ── -function _findOldestMessageId(entries) { +function _findOldestSortKey(entries) { for (const entry of entries) { if (entry.messages && entry.messages.length > 0) { - return entry.messages[0].id; + return entry.messages[0].sortKey; } } return 0; @@ -205,6 +205,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { const isReviewMsg = _isReviewMessage(msg.content); if (msg.role === 'user' && !isReviewMsg) { const div = _createMessageBubble({ role: 'user', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived }); + if (msg.tag === 'openclaw-import') _addOpenClawBadge(div); renderUserContent(div, msg.content); _addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked }); fragment.appendChild(div); @@ -224,6 +225,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { const div = _createMessageBubble({ role: 'assistant', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived }); div.innerHTML = renderMarkdown(msg.content); parseFluentEmojis(div); + if (msg.tag === 'openclaw-import') _addOpenClawBadge(div); _addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked }); fragment.appendChild(div); } @@ -379,6 +381,14 @@ function _renderReviewGroup(tagGroup, archived) { return bubble; } +function _addOpenClawBadge(div) { + var badge = document.createElement('span'); + badge.className = 'openclaw-badge'; + badge.innerHTML = ' OC'; + badge.title = 'Importiert aus OpenClaw'; + div.insertBefore(badge, div.firstChild); +} + function _isReviewMessage(content) { return content && (content.startsWith('[Review:') || content.startsWith('[Review-Feedback') || content.startsWith('[Finalizer:') || content.startsWith('[Preparer:')); } @@ -429,7 +439,8 @@ function renderMessages(msgs, addToConversation) { chatEl().appendChild(fragment); // Track lastKnownMessageId for (const msg of msgs) { - if (msg.id > lastKnownMessageId) lastKnownMessageId = msg.id; + const sk = msg.sortKey || msg.id; + if (sk > lastKnownMessageId) lastKnownMessageId = sk; } // Auto-load image attachments for (const mid of imageMessageIds) _autoLoadImageAttachments(mid); diff --git a/wwwroot/js/modals.js b/wwwroot/js/modals.js index 88c1138..5d295a2 100644 --- a/wwwroot/js/modals.js +++ b/wwwroot/js/modals.js @@ -172,6 +172,112 @@ function _openPickerModal(title, items, currentId, onSelect, options) { }); } +// ── OpenClaw Import Modal ── + +async function showOpenClawImportModal(sessionId) { + const { body, close } = _createModal('OpenClaw Import', { modal: 'openclaw-import-modal' }); + body.innerHTML = '
Sessions werden geladen...
'; + + try { + const sessions = await apiGetOpenclawSessions(); + + if (!sessions || sessions.length === 0) { + body.innerHTML = '
Keine OpenClaw-Sessions gefunden
'; + return; + } + + body.innerHTML = ''; + + var hint = document.createElement('div'); + hint.className = 'openclaw-hint'; + hint.textContent = 'Wähle eine OpenClaw-Session zum Importieren. Vorherige Imports werden aktualisiert.'; + body.appendChild(hint); + + var list = document.createElement('div'); + list.className = 'openclaw-session-list'; + + for (var i = 0; i < sessions.length; i++) { + var s = sessions[i]; + var card = document.createElement('div'); + card.className = 'openclaw-session-card'; + + // Header + var header = document.createElement('div'); + header.className = 'openclaw-card-header'; + + var idSpan = document.createElement('span'); + idSpan.className = 'openclaw-session-id'; + idSpan.textContent = s.sessionId.substring(0, 8) + '…'; + idSpan.title = s.sessionId; + header.appendChild(idSpan); + + var meta = document.createElement('span'); + meta.className = 'openclaw-card-meta'; + meta.innerHTML = ' ' + esc(formatDate(s.createdAt)) + '' + + ' ' + s.totalMessages + ' Nachrichten' + + (s.model && s.model !== 'unknown' ? ' ' + esc(s.model) + '' : ''); + header.appendChild(meta); + card.appendChild(header); + + // Summary + if (s.summary) { + var summary = document.createElement('div'); + summary.className = 'openclaw-summary'; + // Take first line of summary that's meaningful (skip empty lines and headers) + var summaryLines = s.summary.split('\n').filter(function(l) { return l.trim() && !l.startsWith('#'); }); + var summaryText = summaryLines.slice(0, 2).join(' ').trim(); + if (summaryText.length > 150) summaryText = summaryText.substring(0, 150) + '…'; + summary.textContent = summaryText || s.summary.substring(0, 150); + card.appendChild(summary); + } + + // Preview messages + if (s.previewMessages && s.previewMessages.length > 0) { + var preview = document.createElement('div'); + preview.className = 'openclaw-preview'; + + for (var j = 0; j < s.previewMessages.length; j++) { + var pm = s.previewMessages[j]; + var line = document.createElement('div'); + line.className = 'openclaw-preview-msg openclaw-preview-' + pm.role; + var icon = pm.role === 'user' ? 'fa-user' : 'fa-cog'; + line.innerHTML = ' ' + esc(pm.text) + ''; + preview.appendChild(line); + } + card.appendChild(preview); + } + + // Click handler - capture session in closure + (function(openclawSession) { + card.addEventListener('click', async function() { + // Confirm + var confirmed = confirm('OpenClaw-Session importieren?\n\nSession: ' + openclawSession.sessionId.substring(0, 8) + '…\n' + openclawSession.totalMessages + ' Nachrichten\n\nVorhandene Imports werden ersetzt.'); + if (!confirmed) return; + + card.style.opacity = '0.5'; + card.style.pointerEvents = 'none'; + + try { + var result = await apiImportOpenclawSession(openclawSession.sessionId, sessionId); + close(); + showToast(result.messageCount + ' Nachrichten in ' + result.chainCount + ' Chains importiert', 'success'); + } catch (e) { + card.style.opacity = '1'; + card.style.pointerEvents = ''; + showToast('Import fehlgeschlagen: ' + e.message, 'error'); + } + }); + })(s); + + list.appendChild(card); + } + + body.appendChild(list); + } catch (e) { + body.innerHTML = '
Fehler: ' + esc(e.message) + '
'; + } +} + // Event-Delegation für Krea-Thumbnails im Chat document.addEventListener('click', function(e) { var ref = e.target.closest('.krea-ref'); diff --git a/wwwroot/js/pages/dashboard.js b/wwwroot/js/pages/dashboard.js index 03a5458..cb24c76 100644 --- a/wwwroot/js/pages/dashboard.js +++ b/wwwroot/js/pages/dashboard.js @@ -87,6 +87,7 @@ registerPage('dashboard', { `); @@ -120,6 +121,11 @@ registerPage('dashboard', { menuDropdown.hidden = true; navigateTo('session', { id: s.sessionId }); }); + menuDropdown.querySelector('[data-action="openclaw-import"]').addEventListener('click', (e) => { + e.stopPropagation(); + menuDropdown.hidden = true; + showOpenClawImportModal(s.sessionId); + }); grid.appendChild(card); }