chain blocks: single-line layout, user message display, raw json fix, range label with time

This commit is contained in:
Meleki committed 2026-02-22 17:32:29 +01:00
1 parent 791dd5718c
commit 670c5b7f16
4 files changed
+160 -52

No files matched your search

+94 -13
View File
@@ -64,21 +64,65 @@ app.MapGet("/api/cost-chains", (Func<SqliteConnection> factory, string from, str
{
using var db = factory();
var rows = db.Query(@"
SELECT chainId,
SUM(CAST(json_extract(data, '$.message.usage.cost.total') AS REAL)) AS totalCost,
COUNT(*) AS count,
MIN(timestamp) AS first,
MAX(timestamp) AS last
FROM entries
WHERE json_extract(data, '$.message.usage.cost.total') IS NOT NULL
AND timestamp >= @from AND timestamp < @to
AND chainId IS NOT NULL
GROUP BY chainId
ORDER BY totalCost DESC
LIMIT @limit OFFSET @offset",
SELECT c.chainId,
c.totalCost,
c.count,
c.first,
c.last,
e.data AS chainData
FROM (
SELECT chainId,
SUM(CAST(json_extract(data, '$.message.usage.cost.total') AS REAL)) AS totalCost,
COUNT(*) AS count,
MIN(timestamp) AS first,
MAX(timestamp) AS last
FROM entries
WHERE json_extract(data, '$.message.usage.cost.total') IS NOT NULL
AND timestamp >= @from AND timestamp < @to
AND chainId IS NOT NULL
GROUP BY chainId
ORDER BY totalCost DESC
LIMIT @limit OFFSET @offset
) c
LEFT JOIN entries e ON e.id = c.chainId",
new { from, to, limit, offset });
return Results.Ok(rows);
var result = rows.Select((dynamic r) =>
{
string? userMsg = null;
try
{
string raw = r.chainData;
var doc = System.Text.Json.JsonDocument.Parse(raw);
var msg = doc.RootElement.GetProperty("message");
var content = msg.GetProperty("content");
string fullText;
if (content.ValueKind == System.Text.Json.JsonValueKind.String)
fullText = content.GetString()!;
else
{
fullText = string.Join("\n", content.EnumerateArray()
.Where(c => c.TryGetProperty("type", out var t) && t.GetString() == "text")
.Select(c => c.GetProperty("text").GetString()));
}
userMsg = StripSystemPrefixes(fullText);
if (userMsg?.Length > 200) userMsg = userMsg[..200] + "...";
}
catch { }
return new
{
chainId = (string)r.chainId,
totalCost = (double)r.totalCost,
count = (long)r.count,
first = (string)r.first,
last = (string)r.last,
userMessage = userMsg ?? ""
};
});
return Results.Ok(result);
});
// GET /api/cost-summary?from=&to=
@@ -233,3 +277,40 @@ app.MapPost("/api/sync", async () =>
});
app.Run();
static string StripSystemPrefixes(string text)
{
var lines = text.Split('\n').ToList();
var inCodeBlock = false;
// Strip from top
while (lines.Count > 0)
{
var t = lines[0].Trim();
if (string.IsNullOrEmpty(t)) { lines.RemoveAt(0); continue; }
// [Direct ...], [Current ...], [System ...], [message_id ...], [message-id ...], [Chat messages ...], [Queued messages ...]
if (t.StartsWith('[') && (t.StartsWith("[Direct ") || t.StartsWith("[Current ") ||
t.StartsWith("[System ") || t.StartsWith("[message_id:") || t.StartsWith("[message-id ") ||
t.StartsWith("[Chat messages ") || t.StartsWith("[Queued messages ")))
{ lines.RemoveAt(0); continue; }
// [Telegram ... id:xxx ...] msg - strip only the bracket prefix, keep the message after ]
if (t.StartsWith("[Telegram ") && t.Contains(']'))
{
var afterBracket = t[(t.IndexOf(']') + 1)..].Trim();
if (!string.IsNullOrEmpty(afterBracket)) { lines[0] = afterBracket; break; }
lines.RemoveAt(0); continue;
}
// "Conversation info", "Queued #N", "---", "Assistant:"
if (t.StartsWith("Conversation info") || t.StartsWith("Queued #") ||
t.StartsWith("Assistant:") || t == "---") { lines.RemoveAt(0); continue; }
// Code block boundaries and JSON content inside
if (t == "```json" || t == "```") { inCodeBlock = !inCodeBlock; lines.RemoveAt(0); continue; }
if (inCodeBlock) { lines.RemoveAt(0); continue; }
// "User: " prefix
if (t.StartsWith("User: ")) { lines[0] = t["User: ".Length..]; break; }
break;
}
var result = string.Join("\n", lines).Trim();
return string.IsNullOrEmpty(result) ? text.Split('\n').LastOrDefault(l => !string.IsNullOrWhiteSpace(l))?.Trim() ?? "" : result;
}
BIN
View File
Binary file not shown.
+38 -36
View File
@@ -66,33 +66,28 @@ async function loadView() {
const to = viewTo.toISOString();
$('range-label').textContent =
`${fmtDate(viewFrom)} - ${fmtDate(viewTo)} (${entryMode ? 'Einträge' : ZOOM_LEVELS[zoomIndex].name})`;
`${fmtDateTime(viewFrom)} - ${fmtDateTime(viewTo)} (${entryMode ? 'Einträge' : ZOOM_LEVELS[zoomIndex].name})`;
$('btn-zoom-out').disabled = zoomStack.length === 0 && zoomIndex === 0 && !entryMode;
const [summary, chains] = await Promise.all([
api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`),
api(`/api/cost-chains?from=${enc(from)}&to=${enc(to)}`),
]);
if (entryMode) {
const [entries, summary] = await Promise.all([
api(`/api/entry-chains?from=${enc(from)}&to=${enc(to)}`),
api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`),
]);
const entries = await api(`/api/entry-chains?from=${enc(from)}&to=${enc(to)}`);
renderSummary(summary);
renderEntryChart(entries);
renderEntryList(entries);
} else {
const totalMinutes = (viewTo - viewFrom) / 60000;
const bucketMinutes = Math.max(1, Math.round(totalMinutes / BAR_COUNT));
const [buckets, summary] = await Promise.all([
api(`/api/cost-buckets?from=${enc(from)}&to=${enc(to)}&bucketMinutes=${bucketMinutes}`),
api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`),
]);
const chains = await api(`/api/cost-chains?from=${enc(from)}&to=${enc(to)}`);
const buckets = await api(`/api/cost-buckets?from=${enc(from)}&to=${enc(to)}&bucketMinutes=${bucketMinutes}`);
renderSummary(summary);
renderBucketChart(buckets, bucketMinutes);
renderChainBlocks(chains);
}
renderChainBlocks(chains);
saveToHash();
}
@@ -127,7 +122,7 @@ function renderBucketChart(buckets, bucketMinutes) {
const wrapper = document.createElement('div');
wrapper.className = 'bar-wrapper';
wrapper.innerHTML = `
<div class="bar-tooltip">${pctScaled(cost)} / ${count}</div>
<div class="bar-tooltip">${pct(cost)} / ${count}</div>
<div class="bar" style="height:${Math.max(barPct, cost > 0 ? 2 : 0)}%"></div>
`;
@@ -228,26 +223,20 @@ function renderChainBlocks(chains) {
chains.forEach((c, ci) => {
const block = document.createElement('div');
block.className = 'block';
const first = new Date(c.first);
const last = new Date(c.last);
const durSec = Math.round((last - first) / 1000);
block.innerHTML = `
<div class="block-header">
<span class="block-label">${fmtShort(new Date(c.first))} - ${fmtShort(new Date(c.last))}</span>
<span class="block-stats">
<span class="block-count">${c.count} Einträge</span>
<span class="block-cost">${pct(c.totalCost)}</span>
</span>
<span class="block-date">${fmtDateTime(first)}</span>
<span class="block-dur">${durSec}s</span>
<span class="block-msg">${escHtml(c.userMessage || '-')}</span>
<span class="block-count">${c.count}</span>
<span class="block-cost">${pct(c.totalCost)}</span>
</div>
`;
block.querySelector('.block-header').addEventListener('click', () => {
// Jump directly to entry mode, centered on this chain
const first = new Date(c.first);
const last = new Date(c.last);
const pad = Math.max((last - first) * 0.5, 60000); // min 1 min padding
zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false });
viewFrom = new Date(first.getTime() - pad);
viewTo = new Date(last.getTime() + pad);
zoomIndex = ZOOM_LEVELS.length - 1;
entryMode = true;
loadView();
openModal(null, null, c.chainId);
});
container.appendChild(block);
});
@@ -372,19 +361,22 @@ async function syncData() {
}
// --- Modal ---
async function openModal(dataStr, clickedId) {
async function openModal(dataStr, clickedId, chainId) {
const chainEl = $('modal-chain');
const rawEl = $('modal-content');
try { rawEl.textContent = JSON.stringify(JSON.parse(dataStr), null, 2); }
catch { rawEl.textContent = dataStr; }
if (dataStr) {
try { rawEl.textContent = JSON.stringify(JSON.parse(dataStr), null, 2); }
catch { rawEl.textContent = dataStr; }
} else {
rawEl.textContent = '';
}
chainEl.innerHTML = '<p style="color:var(--muted)">Lade Kontext...</p>';
$('modal').classList.remove('hidden');
try {
const parsed = JSON.parse(dataStr);
const entryId = parsed.id;
const entryId = chainId || JSON.parse(dataStr).id;
const chain = await api(`/api/entry-chain/${encodeURIComponent(entryId)}`);
// Calculate chain total
@@ -397,6 +389,11 @@ async function openModal(dataStr, clickedId) {
const chainTotal = chainCosts.reduce((a, b) => a + b, 0);
const chainCount = chainCosts.filter(c => c > 0).length;
// Fill raw JSON if not set (e.g. opened from chain block)
if (!dataStr) {
rawEl.textContent = JSON.stringify(chain.map(e => JSON.parse(e.data)), null, 2);
}
let html = `<div class="chain-summary">
<span class="chain-summary-cost">Kette: ${pct(chainTotal)}</span>
<span class="chain-summary-count">${chainCount} Einträge mit Kosten / ${chain.length} gesamt</span>
@@ -461,6 +458,11 @@ function fmtShort(d) {
+ ' ' + d.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' });
}
function fmtDateTime(d) {
return d.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + d.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function fmtRange(a, b) { return fmtShort(a) + ' - ' + fmtShort(b); }
function fmtTime(ts) {
+28 -3
View File
@@ -84,7 +84,6 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
#range-label {
font-size: 0.85rem;
color: var(--muted);
font-family: monospace;
}
/* Chart */
@@ -186,12 +185,38 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
}
.block-header {
padding: 12px 16px;
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
cursor: pointer;
transition: background 0.15s;
font-size: 0.8rem;
}
.block-date {
color: var(--muted);
white-space: nowrap;
}
.block-dur {
color: var(--muted);
font-family: monospace;
white-space: nowrap;
min-width: 50px;
}
.block-msg {
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.block-count {
white-space: nowrap;
}
.block-header:hover { background: #22263a; }