688 lines
23 KiB
JavaScript
688 lines
23 KiB
JavaScript
const $ = id => document.getElementById(id);
|
|
|
|
const ZOOM_LEVELS = [
|
|
{ name: '1 Monat', weeks: 4 },
|
|
{ name: '2 Wochen', weeks: 2 },
|
|
{ name: '1 Woche', weeks: 1 },
|
|
{ name: '1 Tag', days: 1 }
|
|
];
|
|
|
|
// Config - loaded from server
|
|
let CFG = { weeklyBudget: 500, anchorDay: 0, anchorHour: 0, minBarWidth: 24 };
|
|
|
|
function getBarCount() {
|
|
const w = window.innerWidth || document.documentElement.clientWidth;
|
|
const raw = Math.floor(w / CFG.minBarWidth);
|
|
const count = Math.floor(raw / 28) * 28;
|
|
return Math.max(28, count);
|
|
}
|
|
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;
|
|
|
|
// Find the most recent anchor point before or at `date`
|
|
function getLastAnchor(date) {
|
|
const d = new Date(date);
|
|
// Walk back to the anchor weekday
|
|
const diff = (d.getDay() - CFG.anchorDay + 7) % 7;
|
|
d.setDate(d.getDate() - diff);
|
|
d.setHours(CFG.anchorHour, 0, 0, 0);
|
|
// If we landed in the future, go back one week
|
|
if (d > date) d.setDate(d.getDate() - 7);
|
|
return d;
|
|
}
|
|
|
|
// Get view range for a zoom level anchored to the cycle
|
|
function getAnchoredRange(zoomIdx) {
|
|
const level = ZOOM_LEVELS[zoomIdx];
|
|
const now = new Date();
|
|
const anchor = getLastAnchor(now);
|
|
|
|
if (level.weeks) {
|
|
const from = new Date(anchor);
|
|
from.setDate(from.getDate() - (level.weeks - 1) * 7);
|
|
const to = new Date(anchor);
|
|
to.setDate(to.getDate() + 7);
|
|
return { from, to };
|
|
} else {
|
|
// Days/hours: subdivide current week, find the slot containing now
|
|
const weekStart = anchor;
|
|
const slotMs = level.days * 86400000;
|
|
const elapsed = now - weekStart;
|
|
const slotIndex = Math.floor(elapsed / slotMs);
|
|
const from = new Date(weekStart.getTime() + slotIndex * slotMs);
|
|
const to = new Date(from.getTime() + slotMs);
|
|
return { from, to };
|
|
}
|
|
}
|
|
|
|
init();
|
|
|
|
async function init() {
|
|
$('btn-zoom-out').addEventListener('click', zoomOut);
|
|
$('btn-sync').addEventListener('click', syncData);
|
|
$('modal-close').addEventListener('click', closeModal);
|
|
$('modal').addEventListener('click', e => { if (e.target === $('modal')) closeModal(); });
|
|
|
|
// Config modal
|
|
$('btn-config').addEventListener('click', openConfigModal);
|
|
$('config-close').addEventListener('click', closeConfigModal);
|
|
$('config-modal').addEventListener('click', e => { if (e.target === $('config-modal')) closeConfigModal(); });
|
|
$('cfg-save').addEventListener('click', saveConfig);
|
|
|
|
// Populate hour dropdown
|
|
const hourSelect = $('cfg-anchor-hour');
|
|
for (let h = 0; h < 24; h++) {
|
|
const opt = document.createElement('option');
|
|
opt.value = h;
|
|
opt.textContent = h.toString().padStart(2, '0') + ':00';
|
|
hourSelect.appendChild(opt);
|
|
}
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') { closeModal(); closeConfigModal(); }
|
|
});
|
|
|
|
// Load config from server
|
|
try {
|
|
const serverCfg = await api('/api/config');
|
|
if (serverCfg.weeklyBudget != null) CFG.weeklyBudget = serverCfg.weeklyBudget;
|
|
if (serverCfg.anchorDay != null) CFG.anchorDay = serverCfg.anchorDay;
|
|
if (serverCfg.anchorHour != null) CFG.anchorHour = serverCfg.anchorHour;
|
|
if (serverCfg.minBarWidth != null) CFG.minBarWidth = serverCfg.minBarWidth;
|
|
} catch {}
|
|
|
|
zoomIndex = 0;
|
|
const range = getAnchoredRange(zoomIndex);
|
|
viewFrom = range.from;
|
|
viewTo = range.to;
|
|
|
|
restoreFromHash();
|
|
await loadView();
|
|
|
|
let resizeTimer;
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => loadView(), 250);
|
|
});
|
|
}
|
|
|
|
function openConfigModal() {
|
|
$('cfg-budget').value = CFG.weeklyBudget;
|
|
$('cfg-anchor-day').value = CFG.anchorDay;
|
|
$('cfg-anchor-hour').value = CFG.anchorHour;
|
|
$('cfg-bar-width').value = CFG.minBarWidth;
|
|
$('config-modal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeConfigModal() {
|
|
$('config-modal').classList.add('hidden');
|
|
}
|
|
|
|
async function saveConfig() {
|
|
CFG.weeklyBudget = Number($('cfg-budget').value) || 500;
|
|
CFG.anchorDay = parseInt($('cfg-anchor-day').value);
|
|
CFG.anchorHour = parseInt($('cfg-anchor-hour').value);
|
|
CFG.minBarWidth = Number($('cfg-bar-width').value) || 12;
|
|
|
|
await fetch('/api/config', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(CFG)
|
|
});
|
|
|
|
closeConfigModal();
|
|
resetToStart();
|
|
}
|
|
|
|
function saveToHash() {
|
|
const state = {
|
|
f: viewFrom.toISOString(),
|
|
t: viewTo.toISOString(),
|
|
z: zoomIndex,
|
|
e: entryMode ? 1 : 0,
|
|
};
|
|
location.hash = btoa(JSON.stringify(state));
|
|
}
|
|
|
|
function restoreFromHash() {
|
|
if (!location.hash || location.hash.length < 2) return;
|
|
try {
|
|
const state = JSON.parse(atob(location.hash.slice(1)));
|
|
viewFrom = new Date(state.f);
|
|
viewTo = new Date(state.t);
|
|
zoomIndex = state.z ?? 0;
|
|
entryMode = !!state.e;
|
|
} catch {}
|
|
}
|
|
|
|
// --- Main load ---
|
|
async function loadView() {
|
|
const from = viewFrom.toISOString();
|
|
const to = viewTo.toISOString();
|
|
|
|
$('range-label').textContent =
|
|
`${fmtDateTime(viewFrom)} - ${fmtDateTime(viewTo)} (${entryMode ? 'Einträge' : ZOOM_LEVELS[zoomIndex].name})`;
|
|
$('btn-zoom-out').textContent = zoomStack.length === 0 ? '← Startseite' : '← Raus-Zoomen';
|
|
|
|
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 = await api(`/api/entry-chains?from=${enc(from)}&to=${enc(to)}`);
|
|
renderSummary(summary);
|
|
renderEntryChart(entries);
|
|
} else {
|
|
const totalMinutes = (viewTo - viewFrom) / 60000;
|
|
const bucketMinutes = Math.max(1, Math.round(totalMinutes / getBarCount()));
|
|
const buckets = await api(`/api/cost-buckets?from=${enc(from)}&to=${enc(to)}&bucketMinutes=${bucketMinutes}`);
|
|
renderSummary(summary);
|
|
renderBucketChart(buckets, bucketMinutes);
|
|
}
|
|
|
|
renderChainBlocks(chains);
|
|
|
|
saveToHash();
|
|
}
|
|
|
|
function renderSummary(s) {
|
|
$('summary').innerHTML = `
|
|
<span>Auslastung: <span class="cost">${pctScaled(s.totalCost)}</span></span>
|
|
<span>Einträge: <span class="val">${s.totalEntries}</span></span>
|
|
`;
|
|
}
|
|
|
|
// --- Bucket chart (normal zoom levels) ---
|
|
function renderBucketChart(buckets, bucketMinutes) {
|
|
const chart = $('chart');
|
|
const labels = $('chart-labels');
|
|
chart.innerHTML = '';
|
|
labels.innerHTML = '';
|
|
|
|
const bucketMap = {};
|
|
buckets.forEach(b => { bucketMap[b.bucket] = b; });
|
|
const maxCost = Math.max(...buckets.map(b => b.cost), 0.001);
|
|
const isMaxZoom = zoomIndex >= ZOOM_LEVELS.length - 1;
|
|
|
|
// Determine week boundaries for grouping
|
|
let lastWeekId = null;
|
|
let lastDayId = null;
|
|
|
|
for (let i = 0; i < getBarCount(); i++) {
|
|
const b = bucketMap[i];
|
|
const cost = b ? b.cost : 0;
|
|
const count = b ? b.count : 0;
|
|
const barPct = (cost / maxCost) * 100;
|
|
|
|
const bucketStart = new Date(viewFrom.getTime() + i * bucketMinutes * 60000);
|
|
const bucketEnd = new Date(viewFrom.getTime() + (i + 1) * bucketMinutes * 60000);
|
|
|
|
// Check if this bucket crosses into a new week or day
|
|
const weekAnchor = getLastAnchor(bucketStart);
|
|
const weekId = weekAnchor.getTime();
|
|
// Use local time for day boundary at anchor hour
|
|
const localDayStart = new Date(bucketStart);
|
|
localDayStart.setHours(localDayStart.getHours() - CFG.anchorHour);
|
|
const dayId = localDayStart.toLocaleDateString();
|
|
const isNewWeek = lastWeekId !== null && weekId !== lastWeekId;
|
|
const showDaySep = ZOOM_LEVELS[zoomIndex].weeks === 1;
|
|
const isNewDay = showDaySep && lastDayId !== null && dayId !== lastDayId && !isNewWeek;
|
|
if (isNewWeek || isNewDay) {
|
|
const spacer = document.createElement('div');
|
|
spacer.className = isNewWeek ? 'bar-spacer' : 'bar-spacer day-spacer';
|
|
chart.appendChild(spacer);
|
|
labels.appendChild(Object.assign(document.createElement('div'), { className: 'chart-label bar-spacer' }));
|
|
}
|
|
lastWeekId = weekId;
|
|
lastDayId = dayId;
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'bar-wrapper';
|
|
wrapper.innerHTML = `
|
|
<div class="bar-tooltip">${pct(cost)} / ${count}</div>
|
|
<div class="bar" style="height:${Math.max(barPct, cost > 0 ? 2 : 0)}%"></div>
|
|
`;
|
|
|
|
if (isMaxZoom) {
|
|
wrapper.addEventListener('click', () => enterEntryMode(bucketStart, bucketEnd));
|
|
} else {
|
|
wrapper.addEventListener('click', () => zoomInto(bucketStart, bucketEnd));
|
|
}
|
|
|
|
chart.appendChild(wrapper);
|
|
|
|
const lbl = document.createElement('div');
|
|
lbl.className = 'chart-label';
|
|
if (i % Math.ceil(getBarCount() / 6) === 0) lbl.textContent = fmtShort(bucketStart);
|
|
labels.appendChild(lbl);
|
|
}
|
|
}
|
|
|
|
// --- Entry chart (grouped by chain from server) ---
|
|
function renderEntryChart(entries) {
|
|
const chart = $('chart');
|
|
const labels = $('chart-labels');
|
|
chart.innerHTML = '';
|
|
labels.innerHTML = '';
|
|
|
|
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);
|
|
|
|
window._entryChainColors = new Map();
|
|
|
|
chains.forEach((chain, ci) => {
|
|
const color = CHAIN_COLORS[ci % CHAIN_COLORS.length];
|
|
|
|
if (ci > 0) {
|
|
const spacer = document.createElement('div');
|
|
spacer.className = 'bar-spacer chain-spacer';
|
|
chart.appendChild(spacer);
|
|
labels.appendChild(Object.assign(document.createElement('div'), { className: 'chart-label bar-spacer' }));
|
|
}
|
|
|
|
const chainTotal = chain.reduce((s, e) => s + Number(e.cost), 0);
|
|
|
|
chain.forEach((e, ei) => {
|
|
const cost = Number(e.cost);
|
|
const barPct = (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">${pct(cost)} / ${e.model || '?'}<br>Kette: ${pct(chainTotal)}</div>
|
|
<div class="bar" style="height:${Math.max(barPct, 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 = pct(chainTotal);
|
|
wrapper.prepend(totalLbl);
|
|
}
|
|
|
|
const lbl = document.createElement('div');
|
|
lbl.className = 'chart-label';
|
|
if (ei === 0) lbl.textContent = fmtTime(chain[0].timestamp);
|
|
labels.appendChild(lbl);
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Chain blocks (grouped by conversation chain, sorted by cost) ---
|
|
function renderChainBlocks(chains) {
|
|
const container = $('blocks');
|
|
container.innerHTML = '';
|
|
|
|
if (!chains.length) {
|
|
container.innerHTML = '<p style="color:var(--muted);padding:40px;text-align:center">Keine Einträge mit Kosten in diesem Zeitraum.</p>';
|
|
return;
|
|
}
|
|
|
|
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-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', () => {
|
|
if (entryMode) {
|
|
openModal(null, null, c.chainId);
|
|
} else if (zoomIndex >= ZOOM_LEVELS.length - 1) {
|
|
// Already at max zoom (1 Tag) - enter entry mode for the bucket containing this chain
|
|
const chainMid = new Date((new Date(c.first).getTime() + new Date(c.last).getTime()) / 2);
|
|
const totalMinutes = (viewTo - viewFrom) / 60000;
|
|
const bucketMinutes = Math.max(1, Math.round(totalMinutes / getBarCount()));
|
|
const bucketMs = bucketMinutes * 60000;
|
|
const elapsed = chainMid - viewFrom;
|
|
const bucketIndex = Math.floor(elapsed / bucketMs);
|
|
const bucketStart = new Date(viewFrom.getTime() + bucketIndex * bucketMs);
|
|
const bucketEnd = new Date(bucketStart.getTime() + bucketMs);
|
|
zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false });
|
|
viewFrom = bucketStart;
|
|
viewTo = bucketEnd;
|
|
entryMode = true;
|
|
loadView();
|
|
} else {
|
|
const chainMid = new Date((new Date(c.first).getTime() + new Date(c.last).getTime()) / 2);
|
|
zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false });
|
|
const nextZoom = Math.min(zoomIndex + 1, ZOOM_LEVELS.length - 1);
|
|
const nextLevel = ZOOM_LEVELS[nextZoom];
|
|
|
|
if (nextLevel.weeks) {
|
|
const anchor = getLastAnchor(chainMid);
|
|
viewFrom = anchor;
|
|
viewTo = new Date(anchor.getTime() + nextLevel.weeks * 7 * 86400000);
|
|
} else {
|
|
// Day slots within current week
|
|
const weekStart = getLastAnchor(chainMid);
|
|
const slotMs = nextLevel.days * 86400000;
|
|
const elapsed = chainMid - weekStart;
|
|
const slotIndex = Math.floor(elapsed / slotMs);
|
|
viewFrom = new Date(weekStart.getTime() + slotIndex * slotMs);
|
|
viewTo = new Date(viewFrom.getTime() + slotMs);
|
|
}
|
|
|
|
zoomIndex = nextZoom;
|
|
entryMode = false;
|
|
loadView();
|
|
}
|
|
});
|
|
container.appendChild(block);
|
|
});
|
|
}
|
|
|
|
// --- Entry list (flat, sorted by cost) ---
|
|
function renderEntryList(entries) {
|
|
const container = $('blocks');
|
|
container.innerHTML = '';
|
|
|
|
if (!entries.length) {
|
|
container.innerHTML = '<p style="color:var(--muted);padding:40px;text-align:center">Keine Einträge mit Kosten in diesem Zeitraum.</p>';
|
|
return;
|
|
}
|
|
|
|
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>
|
|
<span class="entry-tokens">${shortNum(e.inputTokens)}/${shortNum(e.outputTokens)}</span>
|
|
<span class="entry-cost">${pct(Number(e.cost))}</span>
|
|
<span class="entry-type">${e.type}</span>
|
|
`;
|
|
row.addEventListener('click', () => openModal(e.data, e.id));
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// --- Toggle chain block entries ---
|
|
async function toggleChainBlock(idx, chainId) {
|
|
const el = $(`chain-block-${idx}`);
|
|
if (el.classList.contains('open')) { el.classList.remove('open'); return; }
|
|
|
|
if (!el.dataset.loaded) {
|
|
// Load the full chain via entry-chain endpoint (using chainId = user message id)
|
|
const chain = await api(`/api/entry-chain/${encodeURIComponent(chainId)}`);
|
|
el.innerHTML = chain.map(entry => {
|
|
const d = JSON.parse(entry.data);
|
|
const msg = d.message;
|
|
if (!msg) return '';
|
|
const cost = msg.usage?.cost?.total;
|
|
return `
|
|
<div class="entry" data-json='${escAttr(entry.data)}'>
|
|
<span class="entry-time">${fmtTime(entry.timestamp)}</span>
|
|
<span class="entry-model">${msg.model || '-'}</span>
|
|
<span class="entry-tokens">${shortNum(msg.usage?.input)}/${shortNum(msg.usage?.output)}</span>
|
|
<span class="entry-cost">${cost ? pct(cost) : '-'}</span>
|
|
<span class="entry-type">${msg.role || entry.type}</span>
|
|
</div>
|
|
`;
|
|
}).filter(Boolean).join('');
|
|
el.querySelectorAll('.entry').forEach(row => {
|
|
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);
|
|
|
|
if (nextLevel.weeks) {
|
|
// Anchor-based: find the week-aligned range containing clickMid
|
|
const anchor = getLastAnchor(clickMid);
|
|
viewFrom = anchor;
|
|
viewTo = new Date(anchor.getTime() + nextLevel.weeks * 7 * 86400000);
|
|
} else {
|
|
// Day/hour slots within the parent range
|
|
const slotMs = nextLevel.days * 86400000;
|
|
const elapsed = clickMid - viewFrom;
|
|
const slotIndex = Math.floor(elapsed / slotMs);
|
|
viewFrom = new Date(viewFrom.getTime() + slotIndex * slotMs);
|
|
viewTo = new Date(viewFrom.getTime() + slotMs);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function resetToStart() {
|
|
location.hash = '';
|
|
zoomIndex = 0;
|
|
entryMode = false;
|
|
zoomStack = [];
|
|
const range = getAnchoredRange(zoomIndex);
|
|
viewFrom = range.from;
|
|
viewTo = range.to;
|
|
loadView();
|
|
}
|
|
|
|
function zoomOut() {
|
|
if (zoomStack.length) {
|
|
const prev = zoomStack.pop();
|
|
viewFrom = prev.from;
|
|
viewTo = prev.to;
|
|
zoomIndex = prev.zoom;
|
|
entryMode = prev.entry;
|
|
loadView();
|
|
} else {
|
|
// Back to start
|
|
location.hash = '';
|
|
zoomIndex = 0;
|
|
entryMode = false;
|
|
zoomStack = [];
|
|
const range = getAnchoredRange(zoomIndex);
|
|
viewFrom = range.from;
|
|
viewTo = range.to;
|
|
loadView();
|
|
}
|
|
}
|
|
|
|
// --- Sync ---
|
|
async function syncData() {
|
|
const btn = $('btn-sync');
|
|
btn.classList.add('syncing');
|
|
btn.textContent = '↻ Sync...';
|
|
try {
|
|
const res = await api('/api/sync', { method: 'POST' });
|
|
btn.textContent = `↻ ${res.output}`;
|
|
await loadView();
|
|
setTimeout(() => { btn.textContent = '↻ Sync'; btn.classList.remove('syncing'); }, 3000);
|
|
} catch (e) {
|
|
btn.textContent = '↻ Fehler!';
|
|
setTimeout(() => { btn.textContent = '↻ Sync'; btn.classList.remove('syncing'); }, 3000);
|
|
}
|
|
}
|
|
|
|
// --- Modal ---
|
|
async function openModal(dataStr, clickedId, chainId) {
|
|
const chainEl = $('modal-chain');
|
|
const rawEl = $('modal-content');
|
|
|
|
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 entryId = chainId || JSON.parse(dataStr).id;
|
|
const chain = await api(`/api/entry-chain/${encodeURIComponent(entryId)}`);
|
|
|
|
// Calculate chain total
|
|
let chainTotal = 0, chainCount = 0, visibleCount = 0;
|
|
chain.forEach(entry => {
|
|
try {
|
|
const d = JSON.parse(entry.data);
|
|
const msg = d.message;
|
|
const cost = msg?.usage?.cost?.total || 0;
|
|
if ((msg && extractContent(msg).trim()) || cost > 0) visibleCount++;
|
|
chainTotal += cost;
|
|
if (cost > 0) chainCount++;
|
|
} catch {}
|
|
});
|
|
|
|
// 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} mit Kosten / ${visibleCount} 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">${pct(cost)}</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'); }
|
|
|
|
// --- Helpers ---
|
|
async function api(url, opts) { return (await fetch(url, opts)).json(); }
|
|
function enc(s) { return encodeURIComponent(s); }
|
|
|
|
function fmtDate(d) {
|
|
return d.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
}
|
|
|
|
function fmtShort(d) {
|
|
return d.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit' })
|
|
+ ' ' + 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) {
|
|
return ts ? new Date(ts).toLocaleTimeString('de-AT', { hour:'2-digit', minute:'2-digit', second:'2-digit' }) : '';
|
|
}
|
|
|
|
function shortNum(n) {
|
|
if (n == null) return '-';
|
|
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) {
|
|
return String(s).replace(/'/g, ''').replace(/"/g, '"');
|
|
}
|
|
|
|
function pct(cost) {
|
|
return (cost / CFG.weeklyBudget * 100).toFixed(3) + '%';
|
|
}
|
|
|
|
function pctScaled(cost) {
|
|
const days = (viewTo - viewFrom) / 86400000;
|
|
const budget = CFG.weeklyBudget / 7 * days;
|
|
return (cost / budget * 100).toFixed(3) + '%';
|
|
}
|