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:
1 parent
1597c10a3c
commit
3b7bdb7a20
10 files changed
+750
-38
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ──
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user