diff --git a/Program.cs b/Program.cs index 6549ba4..90b2f46 100644 --- a/Program.cs +++ b/Program.cs @@ -64,21 +64,65 @@ app.MapGet("/api/cost-chains", (Func 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; +} diff --git a/core b/core new file mode 100644 index 0000000..4e11a5a Binary files /dev/null and b/core differ diff --git a/wwwroot/app.js b/wwwroot/app.js index 6cd23c2..fb10e96 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -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 = ` -
${pctScaled(cost)} / ${count}
+
${pct(cost)} / ${count}
`; @@ -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 = `
- ${fmtShort(new Date(c.first))} - ${fmtShort(new Date(c.last))} - - ${c.count} Einträge - ${pct(c.totalCost)} - + ${fmtDateTime(first)} + ${durSec}s + ${escHtml(c.userMessage || '-')} + ${c.count} + ${pct(c.totalCost)}
`; 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 = '

Lade Kontext...

'; $('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 = `
Kette: ${pct(chainTotal)} ${chainCount} Einträge mit Kosten / ${chain.length} gesamt @@ -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) { diff --git a/wwwroot/style.css b/wwwroot/style.css index ca992cb..311e0d0 100644 --- a/wwwroot/style.css +++ b/wwwroot/style.css @@ -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; }