Chain visualization, zoom levels, modal with full conversation context, sync button

This commit is contained in:
Meleki committed 2026-02-20 23:19:02 +01:00
1 parent 9803f8c727
commit 1d3070ed4c
4 files changed
+421 -55

No files matched your search

+143 -50
View File
@@ -7,15 +7,18 @@ const ZOOM_LEVELS = [
{ name: '3 Tage', days: 3 },
{ name: '1 Tag', days: 1 },
{ name: '12 Stunden', days: 0.5 },
{ name: '6 Stunden', days: 0.25 },
];
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 viewFrom, viewTo;
let zoomStack = [];
let entryMode = false; // true = max zoom, individual entries as bars
let entryData = []; // cached entries for entry mode
let entryMode = false;
init();
@@ -45,10 +48,9 @@ async function loadView() {
if (entryMode) {
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)}`),
]);
entryData = entries;
renderSummary(summary);
renderEntryChart(entries);
renderEntryList(entries);
@@ -103,7 +105,6 @@ function renderBucketChart(buckets, bucketMinutes) {
`;
if (isMaxZoom) {
// At max zoom: click switches to entry mode centered on this bucket
wrapper.addEventListener('click', () => enterEntryMode(bucketStart, bucketEnd));
} else {
wrapper.addEventListener('click', () => zoomInto(bucketStart, bucketEnd));
@@ -113,14 +114,12 @@ function renderBucketChart(buckets, bucketMinutes) {
const lbl = document.createElement('div');
lbl.className = 'chart-label';
if (i % Math.ceil(BAR_COUNT / 6) === 0) {
lbl.textContent = fmtShort(bucketStart);
}
if (i % Math.ceil(BAR_COUNT / 6) === 0) lbl.textContent = fmtShort(bucketStart);
labels.appendChild(lbl);
}
}
// --- Entry chart (individual entries as bars) ---
// --- Entry chart (grouped by chain from server) ---
function renderEntryChart(entries) {
const chart = $('chart');
const labels = $('chart-labels');
@@ -129,28 +128,63 @@ function renderEntryChart(entries) {
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);
entries.forEach((e, i) => {
const cost = Number(e.cost);
const pct = (cost / maxCost) * 100;
window._entryChainColors = new Map();
const wrapper = document.createElement('div');
wrapper.className = 'bar-wrapper';
wrapper.innerHTML = `
<div class="bar-tooltip">$${cost.toFixed(4)} - ${e.model || '?'}</div>
<div class="bar" style="height:${Math.max(pct, 2)}%"></div>
`;
chains.forEach((chain, ci) => {
const color = CHAIN_COLORS[ci % CHAIN_COLORS.length];
wrapper.addEventListener('click', () => openModal(e.data));
chart.appendChild(wrapper);
const lbl = document.createElement('div');
lbl.className = 'chart-label';
if (entries.length <= 15 || i % Math.ceil(entries.length / 8) === 0) {
lbl.textContent = fmtTime(e.timestamp);
if (ci > 0) {
const spacer = document.createElement('div');
spacer.className = 'bar-spacer';
chart.appendChild(spacer);
labels.appendChild(Object.assign(document.createElement('div'), { className: 'chart-label bar-spacer' }));
}
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 class="block-entries" id="be-${i}"></div>
`;
block.querySelector('.block-header').addEventListener('click', () =>
toggleBlock(i, bucketStart, bucketEnd));
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) {
const container = $('blocks');
container.innerHTML = '';
@@ -208,12 +240,13 @@ function renderEntryList(entries) {
return;
}
// Sort by cost descending
const sorted = [...entries].sort((a, b) => Number(b.cost) - Number(a.cost));
sorted.forEach(e => {
const color = window._entryChainColors?.get(e.id) || 'var(--green)';
const row = document.createElement('div');
row.className = 'entry entry-flat';
row.style.borderLeftColor = color;
row.innerHTML = `
<span class="entry-time">${fmtTime(e.timestamp)}</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-type">${e.type}</span>
`;
row.addEventListener('click', () => openModal(e.data));
row.addEventListener('click', () => openModal(e.data, e.id));
container.appendChild(row);
});
}
@@ -229,10 +262,7 @@ function renderEntryList(entries) {
// --- Toggle block entries ---
async function toggleBlock(idx, from, to) {
const el = $(`be-${idx}`);
if (el.classList.contains('open')) {
el.classList.remove('open');
return;
}
if (el.classList.contains('open')) { el.classList.remove('open'); return; }
if (!el.dataset.loaded) {
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>
</div>
`).join('');
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.classList.add('open');
}
// --- Zoom ---
function zoomInto(clickStart, clickEnd) {
if (zoomIndex >= ZOOM_LEVELS.length - 1) return;
zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false });
const nextLevel = ZOOM_LEVELS[zoomIndex + 1];
const clickMid = new Date((clickStart.getTime() + clickEnd.getTime()) / 2);
const halfMs = (nextLevel.days * 86400000) / 2;
viewFrom = new Date(clickMid.getTime() - halfMs);
viewTo = new Date(clickMid.getTime() + halfMs);
zoomIndex++;
entryMode = false;
loadView();
}
function enterEntryMode(bucketStart, bucketEnd) {
zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false });
viewFrom = bucketStart;
viewTo = bucketEnd;
entryMode = true;
loadView();
}
@@ -312,11 +334,78 @@ async function syncData() {
}
// --- Modal ---
function openModal(dataStr) {
try {
$('modal-content').textContent = JSON.stringify(JSON.parse(dataStr), null, 2);
} catch { $('modal-content').textContent = dataStr; }
async function openModal(dataStr, clickedId) {
const chainEl = $('modal-chain');
const rawEl = $('modal-content');
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');
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'); }
@@ -345,6 +434,10 @@ function shortNum(n) {
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
return String(s).replace(/'/g, '&#39;').replace(/"/g, '&quot;');
}