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
This commit is contained in:
Meleki committed 2026-03-08 17:32:32 +01:00
1 parent 1597c10a3c
commit 3b7bdb7a20
10 files changed
+750 -38

No files matched your search

+10 -10
View File
@@ -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<MessageRecord> 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);
}
+386
View File
@@ -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<dynamic>(
"SELECT id, session, timestamp, substr(data,1,500) as data FROM entries WHERE type='session' ORDER BY timestamp DESC"
)).ToList();
var result = new List<object>();
foreach (var s in sessions)
{
string sessionId = s.session;
// Count user + assistant text messages (skip toolResult)
var messageCounts = await conn.QuerySingleAsync<dynamic>(@"
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<int>(
"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<string>(@"
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<string>(@"
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<dynamic>(@"
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<object>();
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<JsonElement>(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<dynamic> entries;
using (var conn = OpenReadOnly(dbPath))
{
entries = (await conn.QueryAsync<dynamic>(@"
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<string, List<dynamic>>();
foreach (var entry in entries)
{
string chainId = entry.chainId ?? "__default__";
if (!chains.ContainsKey(chainId))
chains[chainId] = new List<dynamic>();
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<JsonElement>(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<JsonElement>(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();
}
}
+1
View File
@@ -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
+1
View File
@@ -83,6 +83,7 @@ app.MapKreaEndpoints();
app.MapSharedEndpoints();
app.MapAdminEndpoints();
app.MapServerSettingsEndpoints();
app.MapOpenClawEndpoints();
var port = app.Configuration.GetValue<int>("Port", 19000);
app.Run($"http://0.0.0.0:{port}");
+64 -15
View File
@@ -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<string>(@"
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<IEnumerable<AttachmentRecord>> 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<MessageRecord>($@"
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<int> GetActiveMessageCountBeforeId(string sessionId, long beforeId, bool includeDeleted = false)
public async Task<int> 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<int>($@"
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<List<MessageRecord>> GetActiveMessagesBeforeId(string sessionId, long beforeId, int limit, bool includeDeleted = false)
public async Task<List<MessageRecord>> 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<MessageRecord>($@"
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<IEnumerable<MessageRecord>> GetActiveMessagesAfterId(string sessionId, long afterId, int limit = 200, bool includeDeleted = false)
public async Task<IEnumerable<MessageRecord>> 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<MessageRecord>($@"
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 ──
+128
View File
@@ -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;
}
+28 -4
View File
@@ -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() {
+20 -9
View File
@@ -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 = '<i class="fa fa-cloud-download"></i> 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);
+106
View File
@@ -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 = '<div class="sp-loading">Sessions werden geladen...</div>';
try {
const sessions = await apiGetOpenclawSessions();
if (!sessions || sessions.length === 0) {
body.innerHTML = '<div class="sp-loading">Keine OpenClaw-Sessions gefunden</div>';
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 = '<span class="openclaw-meta-item"><i class="fa fa-calendar"></i> ' + esc(formatDate(s.createdAt)) + '</span>'
+ '<span class="openclaw-meta-item"><i class="fa fa-comments"></i> ' + s.totalMessages + ' Nachrichten</span>'
+ (s.model && s.model !== 'unknown' ? '<span class="openclaw-meta-item"><i class="fa fa-microchip"></i> ' + esc(s.model) + '</span>' : '');
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 = '<i class="fa ' + icon + '"></i> <span>' + esc(pm.text) + '</span>';
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 = '<div class="sp-loading" style="color:var(--dangerous)">Fehler: ' + esc(e.message) + '</div>';
}
}
// Event-Delegation für Krea-Thumbnails im Chat
document.addEventListener('click', function(e) {
var ref = e.target.closest('.krea-ref');
+6
View File
@@ -87,6 +87,7 @@ registerPage('dashboard', {
<div class="card-menu-dropdown" hidden>
<button class="card-menu-item" data-action="edit"><i class="fa fa-pencil"></i> Bearbeiten</button>
<button class="card-menu-item" data-action="chat"><i class="fa fa-comment"></i> Chat öffnen</button>
<button class="card-menu-item" data-action="openclaw-import"><i class="fa fa-download"></i> OpenClaw Import</button>
</div>
</div>`);
@@ -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);
}