chain blocks: single-line layout, user message display, raw json fix, range label with time
This commit is contained in:
1 parent
791dd5718c
commit
670c5b7f16
4 files changed
+160
-52
No files matched your search
+94
-13
@@ -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;
|
||||
}
|
||||
+38
-36
@@ -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
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user