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);
|
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
|
// POST /api/sync - run export.mjs to import new entries
|
||||||
app.MapPost("/api/sync", async () =>
|
app.MapPost("/api/sync", async () =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ const ZOOM_LEVELS = [
|
|||||||
{ name: '3 Tage', days: 3 },
|
{ name: '3 Tage', days: 3 },
|
||||||
{ name: '1 Tag', days: 1 },
|
{ name: '1 Tag', days: 1 },
|
||||||
{ name: '12 Stunden', days: 0.5 },
|
{ name: '12 Stunden', days: 0.5 },
|
||||||
{ name: '6 Stunden', days: 0.25 },
|
|
||||||
];
|
];
|
||||||
const BAR_COUNT = 30;
|
const BAR_COUNT = 30;
|
||||||
|
const CHAIN_COLORS = [
|
||||||
|
'#4ade80','#60a5fa','#f97316','#c084fc','#f472b6',
|
||||||
|
'#facc15','#34d399','#818cf8','#fb7185','#a78bfa',
|
||||||
|
'#22d3ee','#e879f9','#84cc16','#f59e0b','#6ee7b7',
|
||||||
|
];
|
||||||
|
|
||||||
let zoomIndex = 0;
|
let zoomIndex = 0;
|
||||||
let viewFrom, viewTo;
|
let viewFrom, viewTo;
|
||||||
let zoomStack = [];
|
let zoomStack = [];
|
||||||
let entryMode = false; // true = max zoom, individual entries as bars
|
let entryMode = false;
|
||||||
let entryData = []; // cached entries for entry mode
|
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|
||||||
@@ -45,10 +48,9 @@ async function loadView() {
|
|||||||
|
|
||||||
if (entryMode) {
|
if (entryMode) {
|
||||||
const [entries, summary] = await Promise.all([
|
const [entries, summary] = await Promise.all([
|
||||||
api(`/api/cost-entries?from=${enc(from)}&to=${enc(to)}`),
|
api(`/api/entry-chains?from=${enc(from)}&to=${enc(to)}`),
|
||||||
api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`),
|
api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`),
|
||||||
]);
|
]);
|
||||||
entryData = entries;
|
|
||||||
renderSummary(summary);
|
renderSummary(summary);
|
||||||
renderEntryChart(entries);
|
renderEntryChart(entries);
|
||||||
renderEntryList(entries);
|
renderEntryList(entries);
|
||||||
@@ -103,7 +105,6 @@ function renderBucketChart(buckets, bucketMinutes) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
if (isMaxZoom) {
|
if (isMaxZoom) {
|
||||||
// At max zoom: click switches to entry mode centered on this bucket
|
|
||||||
wrapper.addEventListener('click', () => enterEntryMode(bucketStart, bucketEnd));
|
wrapper.addEventListener('click', () => enterEntryMode(bucketStart, bucketEnd));
|
||||||
} else {
|
} else {
|
||||||
wrapper.addEventListener('click', () => zoomInto(bucketStart, bucketEnd));
|
wrapper.addEventListener('click', () => zoomInto(bucketStart, bucketEnd));
|
||||||
@@ -113,14 +114,12 @@ function renderBucketChart(buckets, bucketMinutes) {
|
|||||||
|
|
||||||
const lbl = document.createElement('div');
|
const lbl = document.createElement('div');
|
||||||
lbl.className = 'chart-label';
|
lbl.className = 'chart-label';
|
||||||
if (i % Math.ceil(BAR_COUNT / 6) === 0) {
|
if (i % Math.ceil(BAR_COUNT / 6) === 0) lbl.textContent = fmtShort(bucketStart);
|
||||||
lbl.textContent = fmtShort(bucketStart);
|
|
||||||
}
|
|
||||||
labels.appendChild(lbl);
|
labels.appendChild(lbl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Entry chart (individual entries as bars) ---
|
// --- Entry chart (grouped by chain from server) ---
|
||||||
function renderEntryChart(entries) {
|
function renderEntryChart(entries) {
|
||||||
const chart = $('chart');
|
const chart = $('chart');
|
||||||
const labels = $('chart-labels');
|
const labels = $('chart-labels');
|
||||||
@@ -129,28 +128,63 @@ function renderEntryChart(entries) {
|
|||||||
|
|
||||||
if (!entries.length) return;
|
if (!entries.length) return;
|
||||||
|
|
||||||
|
// Group by chainId from server
|
||||||
|
const chainMap = new Map();
|
||||||
|
entries.forEach(e => {
|
||||||
|
const cid = e.chainId ?? 0;
|
||||||
|
if (!chainMap.has(cid)) chainMap.set(cid, []);
|
||||||
|
chainMap.get(cid).push(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chains = [...chainMap.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([, v]) => v);
|
||||||
|
|
||||||
const maxCost = Math.max(...entries.map(e => Number(e.cost)), 0.001);
|
const maxCost = Math.max(...entries.map(e => Number(e.cost)), 0.001);
|
||||||
|
|
||||||
entries.forEach((e, i) => {
|
window._entryChainColors = new Map();
|
||||||
const cost = Number(e.cost);
|
|
||||||
const pct = (cost / maxCost) * 100;
|
|
||||||
|
|
||||||
const wrapper = document.createElement('div');
|
chains.forEach((chain, ci) => {
|
||||||
wrapper.className = 'bar-wrapper';
|
const color = CHAIN_COLORS[ci % CHAIN_COLORS.length];
|
||||||
wrapper.innerHTML = `
|
|
||||||
<div class="bar-tooltip">$${cost.toFixed(4)} - ${e.model || '?'}</div>
|
|
||||||
<div class="bar" style="height:${Math.max(pct, 2)}%"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
wrapper.addEventListener('click', () => openModal(e.data));
|
if (ci > 0) {
|
||||||
chart.appendChild(wrapper);
|
const spacer = document.createElement('div');
|
||||||
|
spacer.className = 'bar-spacer';
|
||||||
const lbl = document.createElement('div');
|
chart.appendChild(spacer);
|
||||||
lbl.className = 'chart-label';
|
labels.appendChild(Object.assign(document.createElement('div'), { className: 'chart-label bar-spacer' }));
|
||||||
if (entries.length <= 15 || i % Math.ceil(entries.length / 8) === 0) {
|
|
||||||
lbl.textContent = fmtTime(e.timestamp);
|
|
||||||
}
|
}
|
||||||
labels.appendChild(lbl);
|
|
||||||
|
const chainTotal = chain.reduce((s, e) => s + Number(e.cost), 0);
|
||||||
|
|
||||||
|
chain.forEach((e, ei) => {
|
||||||
|
const cost = Number(e.cost);
|
||||||
|
const pct = (cost / maxCost) * 100;
|
||||||
|
|
||||||
|
window._entryChainColors.set(e.id, color);
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'bar-wrapper bar-in-chain';
|
||||||
|
wrapper.innerHTML = `
|
||||||
|
<div class="bar-tooltip">$${cost.toFixed(4)} / ${e.model || '?'}<br>Kette: $${chainTotal.toFixed(2)}</div>
|
||||||
|
<div class="bar" style="height:${Math.max(pct, 2)}%;background:${color}"></div>
|
||||||
|
`;
|
||||||
|
wrapper.addEventListener('click', () => openModal(e.data, e.id));
|
||||||
|
chart.appendChild(wrapper);
|
||||||
|
|
||||||
|
// Chain total label above first bar
|
||||||
|
if (ei === 0) {
|
||||||
|
const totalLbl = document.createElement('div');
|
||||||
|
totalLbl.className = 'chain-cost-label';
|
||||||
|
totalLbl.style.color = color;
|
||||||
|
totalLbl.textContent = `$${chainTotal.toFixed(2)}`;
|
||||||
|
wrapper.prepend(totalLbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lbl = document.createElement('div');
|
||||||
|
lbl.className = 'chart-label';
|
||||||
|
if (ei === 0) lbl.textContent = fmtTime(chain[0].timestamp);
|
||||||
|
labels.appendChild(lbl);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,10 +220,8 @@ function renderBlocks(buckets, bucketMinutes) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="block-entries" id="be-${i}"></div>
|
<div class="block-entries" id="be-${i}"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
block.querySelector('.block-header').addEventListener('click', () =>
|
block.querySelector('.block-header').addEventListener('click', () =>
|
||||||
toggleBlock(i, bucketStart, bucketEnd));
|
toggleBlock(i, bucketStart, bucketEnd));
|
||||||
|
|
||||||
container.appendChild(block);
|
container.appendChild(block);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +230,7 @@ function renderBlocks(buckets, bucketMinutes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Entry list (entry mode - flat, no expandable blocks) ---
|
// --- Entry list (flat, sorted by cost) ---
|
||||||
function renderEntryList(entries) {
|
function renderEntryList(entries) {
|
||||||
const container = $('blocks');
|
const container = $('blocks');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -208,12 +240,13 @@ function renderEntryList(entries) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by cost descending
|
|
||||||
const sorted = [...entries].sort((a, b) => Number(b.cost) - Number(a.cost));
|
const sorted = [...entries].sort((a, b) => Number(b.cost) - Number(a.cost));
|
||||||
|
|
||||||
sorted.forEach(e => {
|
sorted.forEach(e => {
|
||||||
|
const color = window._entryChainColors?.get(e.id) || 'var(--green)';
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'entry entry-flat';
|
row.className = 'entry entry-flat';
|
||||||
|
row.style.borderLeftColor = color;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<span class="entry-time">${fmtTime(e.timestamp)}</span>
|
<span class="entry-time">${fmtTime(e.timestamp)}</span>
|
||||||
<span class="entry-model">${e.model || '-'}</span>
|
<span class="entry-model">${e.model || '-'}</span>
|
||||||
@@ -221,7 +254,7 @@ function renderEntryList(entries) {
|
|||||||
<span class="entry-cost">$${Number(e.cost).toFixed(4)}</span>
|
<span class="entry-cost">$${Number(e.cost).toFixed(4)}</span>
|
||||||
<span class="entry-type">${e.type}</span>
|
<span class="entry-type">${e.type}</span>
|
||||||
`;
|
`;
|
||||||
row.addEventListener('click', () => openModal(e.data));
|
row.addEventListener('click', () => openModal(e.data, e.id));
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -229,10 +262,7 @@ function renderEntryList(entries) {
|
|||||||
// --- Toggle block entries ---
|
// --- Toggle block entries ---
|
||||||
async function toggleBlock(idx, from, to) {
|
async function toggleBlock(idx, from, to) {
|
||||||
const el = $(`be-${idx}`);
|
const el = $(`be-${idx}`);
|
||||||
if (el.classList.contains('open')) {
|
if (el.classList.contains('open')) { el.classList.remove('open'); return; }
|
||||||
el.classList.remove('open');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!el.dataset.loaded) {
|
if (!el.dataset.loaded) {
|
||||||
const entries = await api(`/api/cost-entries?from=${enc(from.toISOString())}&to=${enc(to.toISOString())}`);
|
const entries = await api(`/api/cost-entries?from=${enc(from.toISOString())}&to=${enc(to.toISOString())}`);
|
||||||
@@ -245,42 +275,34 @@ async function toggleBlock(idx, from, to) {
|
|||||||
<span class="entry-type">${e.type}</span>
|
<span class="entry-type">${e.type}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
el.querySelectorAll('.entry').forEach(row => {
|
el.querySelectorAll('.entry').forEach(row => {
|
||||||
row.addEventListener('click', () => openModal(row.dataset.json));
|
const eid = JSON.parse(row.dataset.json).id;
|
||||||
|
row.addEventListener('click', () => openModal(row.dataset.json, eid));
|
||||||
});
|
});
|
||||||
|
|
||||||
el.dataset.loaded = '1';
|
el.dataset.loaded = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
el.classList.add('open');
|
el.classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Zoom ---
|
// --- Zoom ---
|
||||||
function zoomInto(clickStart, clickEnd) {
|
function zoomInto(clickStart, clickEnd) {
|
||||||
if (zoomIndex >= ZOOM_LEVELS.length - 1) return;
|
if (zoomIndex >= ZOOM_LEVELS.length - 1) return;
|
||||||
|
|
||||||
zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false });
|
zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false });
|
||||||
|
|
||||||
const nextLevel = ZOOM_LEVELS[zoomIndex + 1];
|
const nextLevel = ZOOM_LEVELS[zoomIndex + 1];
|
||||||
const clickMid = new Date((clickStart.getTime() + clickEnd.getTime()) / 2);
|
const clickMid = new Date((clickStart.getTime() + clickEnd.getTime()) / 2);
|
||||||
const halfMs = (nextLevel.days * 86400000) / 2;
|
const halfMs = (nextLevel.days * 86400000) / 2;
|
||||||
|
|
||||||
viewFrom = new Date(clickMid.getTime() - halfMs);
|
viewFrom = new Date(clickMid.getTime() - halfMs);
|
||||||
viewTo = new Date(clickMid.getTime() + halfMs);
|
viewTo = new Date(clickMid.getTime() + halfMs);
|
||||||
zoomIndex++;
|
zoomIndex++;
|
||||||
entryMode = false;
|
entryMode = false;
|
||||||
|
|
||||||
loadView();
|
loadView();
|
||||||
}
|
}
|
||||||
|
|
||||||
function enterEntryMode(bucketStart, bucketEnd) {
|
function enterEntryMode(bucketStart, bucketEnd) {
|
||||||
zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false });
|
zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false });
|
||||||
|
|
||||||
viewFrom = bucketStart;
|
viewFrom = bucketStart;
|
||||||
viewTo = bucketEnd;
|
viewTo = bucketEnd;
|
||||||
entryMode = true;
|
entryMode = true;
|
||||||
|
|
||||||
loadView();
|
loadView();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,11 +334,78 @@ async function syncData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Modal ---
|
// --- Modal ---
|
||||||
function openModal(dataStr) {
|
async function openModal(dataStr, clickedId) {
|
||||||
try {
|
const chainEl = $('modal-chain');
|
||||||
$('modal-content').textContent = JSON.stringify(JSON.parse(dataStr), null, 2);
|
const rawEl = $('modal-content');
|
||||||
} catch { $('modal-content').textContent = dataStr; }
|
|
||||||
|
try { rawEl.textContent = JSON.stringify(JSON.parse(dataStr), null, 2); }
|
||||||
|
catch { rawEl.textContent = dataStr; }
|
||||||
|
|
||||||
|
chainEl.innerHTML = '<p style="color:var(--muted)">Lade Kontext...</p>';
|
||||||
$('modal').classList.remove('hidden');
|
$('modal').classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dataStr);
|
||||||
|
const entryId = parsed.id;
|
||||||
|
const chain = await api(`/api/entry-chain/${encodeURIComponent(entryId)}`);
|
||||||
|
|
||||||
|
// Calculate chain total
|
||||||
|
const chainCosts = chain.map(entry => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(entry.data);
|
||||||
|
return d.message?.usage?.cost?.total || 0;
|
||||||
|
} catch { return 0; }
|
||||||
|
});
|
||||||
|
const chainTotal = chainCosts.reduce((a, b) => a + b, 0);
|
||||||
|
const chainCount = chainCosts.filter(c => c > 0).length;
|
||||||
|
|
||||||
|
let html = `<div class="chain-summary">
|
||||||
|
<span class="chain-summary-cost">Kette: $${chainTotal.toFixed(4)}</span>
|
||||||
|
<span class="chain-summary-count">${chainCount} Einträge mit Kosten / ${chain.length} gesamt</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
html += chain.map(entry => {
|
||||||
|
const d = JSON.parse(entry.data);
|
||||||
|
const msg = d.message;
|
||||||
|
if (!msg) return '';
|
||||||
|
|
||||||
|
const role = msg.role || '?';
|
||||||
|
const isUser = role === 'user';
|
||||||
|
const isHighlighted = d.id === clickedId;
|
||||||
|
const content = extractContent(msg);
|
||||||
|
const cost = msg.usage?.cost?.total;
|
||||||
|
const model = msg.model;
|
||||||
|
const time = fmtTime(entry.timestamp);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="chain-msg ${isUser ? 'chain-user' : 'chain-assistant'} ${isHighlighted ? 'chain-highlighted' : ''}" ${isHighlighted ? 'id="chain-active"' : ''}>
|
||||||
|
<div class="chain-header">
|
||||||
|
<span class="chain-role">${role}</span>
|
||||||
|
<span class="chain-time">${time}</span>
|
||||||
|
${model ? `<span class="chain-model">${model}</span>` : ''}
|
||||||
|
${cost ? `<span class="chain-cost">$${cost.toFixed(4)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="chain-body">${escHtml(content)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
chainEl.innerHTML = html;
|
||||||
|
|
||||||
|
// Scroll to highlighted entry
|
||||||
|
const active = $('chain-active');
|
||||||
|
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
} catch (e) {
|
||||||
|
chainEl.innerHTML = `<p style="color:var(--red)">Kontext konnte nicht geladen werden</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractContent(msg) {
|
||||||
|
if (typeof msg.content === 'string') return msg.content.slice(0, 2000);
|
||||||
|
if (Array.isArray(msg.content)) {
|
||||||
|
return msg.content.filter(c => c.type === 'text').map(c => c.text).join('\n').slice(0, 2000);
|
||||||
|
}
|
||||||
|
return JSON.stringify(msg.content).slice(0, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() { $('modal').classList.add('hidden'); }
|
function closeModal() { $('modal').classList.add('hidden'); }
|
||||||
@@ -345,6 +434,10 @@ function shortNum(n) {
|
|||||||
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
|
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
function escAttr(s) {
|
function escAttr(s) {
|
||||||
return String(s).replace(/'/g, ''').replace(/"/g, '"');
|
return String(s).replace(/'/g, ''').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,11 @@
|
|||||||
<div id="modal" class="modal hidden">
|
<div id="modal" class="modal hidden">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<button id="modal-close">✕</button>
|
<button id="modal-close">✕</button>
|
||||||
<pre id="modal-content"></pre>
|
<div id="modal-chain"></div>
|
||||||
|
<details class="raw-json">
|
||||||
|
<summary>Raw JSON</summary>
|
||||||
|
<pre id="modal-content"></pre>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -98,9 +98,14 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
|
|||||||
#chart {
|
#chart {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
height: 140px;
|
height: 140px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chart-labels {
|
||||||
|
gap: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-wrapper {
|
.bar-wrapper {
|
||||||
@@ -114,6 +119,9 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bar-in-chain { gap: 0; }
|
||||||
|
.bar-spacer { width: 10px; min-width: 10px; flex: 0 0 10px; }
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 4px;
|
min-width: 4px;
|
||||||
@@ -228,6 +236,7 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
|
|||||||
.entry-flat {
|
.entry-flat {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--green);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
@@ -265,13 +274,117 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
|
|||||||
color: var(--muted); font-size: 1.2rem; cursor: pointer;
|
color: var(--muted); font-size: 1.2rem; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chain messages */
|
||||||
|
.chain-msg {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-user {
|
||||||
|
background: #1e2d5a;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-assistant {
|
||||||
|
background: #1a2a2a;
|
||||||
|
border-left: 3px solid var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-role {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-user .chain-role { color: var(--accent); }
|
||||||
|
.chain-assistant .chain-role { color: var(--green); }
|
||||||
|
|
||||||
|
.chain-time { color: var(--muted); font-family: monospace; }
|
||||||
|
.chain-model { color: var(--muted); }
|
||||||
|
.chain-cost { color: var(--green); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Chain summary in modal */
|
||||||
|
.chain-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-summary-cost {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-summary-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chain cost label above first bar */
|
||||||
|
.chain-cost-label {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-highlighted {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: 0 0 12px rgba(108, 142, 245, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-body {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Raw JSON toggle */
|
||||||
|
.raw-json {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-json summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
#modal-content {
|
#modal-content {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.78rem;
|
font-size: 0.72rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
color: var(--text);
|
color: var(--muted);
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar { width: 6px; }
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
|||||||
Reference in new issue
Block a user