Chain visualization, zoom levels, modal with full conversation context, sync button
This commit is contained in:
1 parent
9803f8c727
commit
1d3070ed4c
4 files changed
+421
-55
No files matched your search
@@ -76,6 +76,162 @@ app.MapGet("/api/cost-summary", (Func<SqliteConnection> factory, string? from, s
|
||||
return Results.Ok(row);
|
||||
});
|
||||
|
||||
// GET /api/entry-chain/{id} - full chain: walk back to user message, then forward to end
|
||||
app.MapGet("/api/entry-chain/{id}", (string id, Func<SqliteConnection> factory) =>
|
||||
{
|
||||
using var db = factory();
|
||||
|
||||
// Step 1: Walk backwards to find the user message (chain root)
|
||||
var backChain = new List<dynamic>();
|
||||
var currentId = id;
|
||||
var seen = new HashSet<string>();
|
||||
string? rootId = null;
|
||||
|
||||
while (currentId != null && seen.Add(currentId))
|
||||
{
|
||||
var row = db.QueryFirstOrDefault(
|
||||
"SELECT id, session, type, timestamp, data, json_extract(data, '$.parentId') AS parentId FROM entries WHERE id = @id",
|
||||
new { id = currentId });
|
||||
|
||||
if (row == null) break;
|
||||
|
||||
backChain.Add(row);
|
||||
|
||||
string dataStr = row.data;
|
||||
try
|
||||
{
|
||||
var parsed = System.Text.Json.JsonDocument.Parse(dataStr);
|
||||
var role = parsed.RootElement.TryGetProperty("message", out var msg)
|
||||
&& msg.TryGetProperty("role", out var r) ? r.GetString() : null;
|
||||
if (role == "user")
|
||||
{
|
||||
rootId = (string)row.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
currentId = (string?)row.parentId;
|
||||
}
|
||||
|
||||
backChain.Reverse();
|
||||
|
||||
// Step 2: Walk forwards from the last entry in backChain until we hit the next user message
|
||||
var chainIds = new HashSet<string>(backChain.Select(r => (string)r.id));
|
||||
var forwardEntries = new List<dynamic>();
|
||||
|
||||
var lastId = (string)backChain.Last().id;
|
||||
var maxIter = 100;
|
||||
var currentId2 = lastId;
|
||||
while (maxIter-- > 0)
|
||||
{
|
||||
var child = db.QueryFirstOrDefault(
|
||||
"SELECT id, session, type, timestamp, data, json_extract(data, '$.message.role') AS role FROM entries WHERE json_extract(data, '$.parentId') = @pid",
|
||||
new { pid = currentId2 });
|
||||
|
||||
if (child == null) break;
|
||||
|
||||
string childRole = child.role;
|
||||
// Stop when we reach the next user message (new turn)
|
||||
if (childRole == "user") break;
|
||||
|
||||
string childId = child.id;
|
||||
if (!chainIds.Add(childId)) break;
|
||||
|
||||
forwardEntries.Add(new { id = childId, session = (string)child.session, type = (string)child.type, timestamp = (string)child.timestamp, data = (string)child.data });
|
||||
currentId2 = childId;
|
||||
}
|
||||
|
||||
// Combine and sort chronologically
|
||||
var fullChain = backChain.Concat(forwardEntries)
|
||||
.OrderBy(r => (string)r.timestamp)
|
||||
.Select(r => new { id = (string)r.id, session = (string)r.session, type = (string)r.type, timestamp = (string)r.timestamp, data = (string)r.data })
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(fullChain);
|
||||
});
|
||||
|
||||
// GET /api/entry-chains?from=&to= - returns cost entries with resolved chainId
|
||||
app.MapGet("/api/entry-chains", (Func<SqliteConnection> factory, string from, string to) =>
|
||||
{
|
||||
using var db = factory();
|
||||
|
||||
// Get all cost entries in range
|
||||
var costEntries = db.Query(@"
|
||||
SELECT id, session, type, timestamp,
|
||||
json_extract(data, '$.message.usage.cost.total') AS cost,
|
||||
json_extract(data, '$.message.usage.input') AS inputTokens,
|
||||
json_extract(data, '$.message.usage.output') AS outputTokens,
|
||||
json_extract(data, '$.message.model') AS model,
|
||||
json_extract(data, '$.parentId') AS parentId,
|
||||
data
|
||||
FROM entries
|
||||
WHERE json_extract(data, '$.message.usage.cost.total') IS NOT NULL
|
||||
AND timestamp >= @from AND timestamp < @to
|
||||
ORDER BY timestamp ASC", new { from, to }).AsList();
|
||||
|
||||
// For each cost entry, walk up parentId chain to find the nearest user message.
|
||||
// That user message ID becomes the chain root.
|
||||
// Entries sharing the same user-message ancestor = same turn/chain.
|
||||
var chainRoot = new Dictionary<string, string>();
|
||||
|
||||
string FindUserRoot(string entryId, SqliteConnection conn)
|
||||
{
|
||||
if (chainRoot.TryGetValue(entryId, out var cached)) return cached;
|
||||
|
||||
var visited = new HashSet<string> { entryId };
|
||||
var currentId = entryId;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var row = conn.QueryFirstOrDefault(
|
||||
@"SELECT json_extract(data, '$.parentId') AS parentId,
|
||||
json_extract(data, '$.message.role') AS role
|
||||
FROM entries WHERE id = @id",
|
||||
new { id = currentId });
|
||||
|
||||
if (row == null) break;
|
||||
|
||||
string role = row.role;
|
||||
// If we landed on a user message, that's our chain root
|
||||
if (role == "user" && currentId != entryId)
|
||||
{
|
||||
chainRoot[entryId] = currentId;
|
||||
return currentId;
|
||||
}
|
||||
|
||||
string parentId = row.parentId;
|
||||
if (parentId == null || visited.Contains(parentId)) break;
|
||||
visited.Add(parentId);
|
||||
currentId = parentId;
|
||||
}
|
||||
|
||||
// No user message found - entry is its own root
|
||||
chainRoot[entryId] = entryId;
|
||||
return entryId;
|
||||
}
|
||||
|
||||
foreach (var e in costEntries) FindUserRoot((string)e.id, db);
|
||||
|
||||
// Assign chain index by first appearance
|
||||
var chainIndex = new Dictionary<string, int>();
|
||||
int nextChain = 0;
|
||||
var result = costEntries.Select(e =>
|
||||
{
|
||||
var root = chainRoot[(string)e.id];
|
||||
if (!chainIndex.ContainsKey(root)) chainIndex[root] = nextChain++;
|
||||
return new
|
||||
{
|
||||
e.id, e.session, e.type, e.timestamp,
|
||||
e.cost, e.inputTokens, e.outputTokens, e.model,
|
||||
e.data,
|
||||
chainId = chainIndex[root]
|
||||
};
|
||||
});
|
||||
|
||||
return Results.Ok(result);
|
||||
});
|
||||
|
||||
// POST /api/sync - run export.mjs to import new entries
|
||||
app.MapPost("/api/sync", async () =>
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user