// stats.js — Usage Stats (self-contained module; fits your existing component style)
// - Registers "Usage Stats" in AppMenu
// - Works even if App.state.stats doesn’t exist yet (rebuilds rough estimates from history)
// - Dark UI, matches your chat modal styling
// - Pricing map included; costs are ESTIMATES
// - AppOverlay polyfill included so it “just works”
//
// Drop this file after your chat bundle. In the next step we’ll wire chat.js to
// record precise per-call stats; for now, this can rebuild rough stats from history.
window.App = window.App || {};
window.AppMenu = window.AppMenu || [];
(() => {
// ---------- Minimal Overlay (only if not already present) ----------
if (!window.AppOverlay) {
window.AppOverlay = (() => {
let backdrop = null;
function close() { if (backdrop) { backdrop.remove(); backdrop = null; } }
function open(slides, startIndex = 0) {
close();
const slide = Array.isArray(slides) ? slides[startIndex] : null;
if (!slide) return;
backdrop = document.createElement('div');
backdrop.className = 'chat-modal__backdrop';
backdrop.style.zIndex = 1000000;
const modal = document.createElement('div');
modal.className = 'chat-modal';
modal.style.width = 'min(900px, 92vw)';
modal.style.maxHeight = '80vh';
modal.style.background = '#0f172a';
modal.style.border = '1px solid #334155';
modal.style.borderRadius = '0.75rem';
modal.style.boxShadow = '0 20px 50px rgba(0,0,0,0.4)';
modal.style.display = 'flex';
modal.style.flexDirection = 'column';
modal.style.overflow = 'hidden';
const header = document.createElement('div');
header.className = 'chat-modal__header';
const title = document.createElement('div');
title.className = 'chat-modal__title';
title.textContent = slide.title || 'Overlay';
const actions = document.createElement('div');
actions.className = 'chat-modal__actions';
const closeBtn = document.createElement('button');
closeBtn.className = 'chat-btn chat-btn--danger';
closeBtn.textContent = 'Close';
closeBtn.onclick = close;
actions.appendChild(closeBtn);
header.appendChild(title);
header.appendChild(actions);
const body = document.createElement('div');
body.className = 'chat-modal__body';
body.innerHTML = slide.html || '';
modal.appendChild(header);
modal.appendChild(body);
backdrop.appendChild(modal);
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); });
document.body.appendChild(backdrop);
}
return { open, close };
})();
}
// ---------- Pricing (per million tokens) ----------
const MODEL_PRICING = {
'deepseek-chat': { input: 0.27, output: 1.10, inputCache: 0.07 },
'deepseek-reasoner': { input: 0.55, output: 2.19, inputCache: 0.14 },
'gpt-4o': { input: 2.50, output: 10.00 },
'gpt-4o-mini': { input: 0.15, output: 0.60 },
'gpt-5': { input: 1.25, output: 10.00 },
'gpt-5-mini': { input: 0.25, output: 2.00 },
'gpt-5-nano': { input: 0.05, output: 0.40 },
'gpt-5-thinking': { input: 1.25, output: 10.00 },
'gpt-5-pro': { input: 2.50, output: 15.00 },
'grok-3': { input: 3.00, output: 15.00 },
'grok-3-mini': { input: 0.30, output: 0.50 },
'grok-code-fast-1': { input: 0.20, output: 1.50 },
'grok-4-0709': { input: 3.00, output: 15.00 }
};
// ---------- Helpers ----------
function fmtNum(n) { return Number(n || 0).toLocaleString(); }
function fmtCost(cost) {
if (!isFinite(cost)) return '$0.00';
if (cost < 0.01) return `$${cost.toFixed(4)}`;
if (cost < 1) return `$${cost.toFixed(3)}`;
return `$${cost.toFixed(2)}`;
}
// cheap heuristic ~4 chars/token
function estTokens(str) {
if (!str) return 0;
return Math.ceil(String(str).length / 4);
}
function estInputTokensFromPayload(payload) {
try {
let sum = 0;
if (payload?.system) sum += estTokens(payload.system);
if (payload?.question) sum += estTokens(payload.question);
if (Array.isArray(payload?.history)) {
for (const m of payload.history) sum += estTokens(m?.content || '');
}
return sum;
} catch { return 0; }
}
function calcCostsFromUsage(usageByModel) {
let totalCost = 0;
const rows = [];
Object.entries(usageByModel || {}).forEach(([model, u]) => {
const p = MODEL_PRICING[model];
if (!p) {
rows.push({ model, input: u.input|0, output: u.output|0, calls: u.calls|0, cost: 0, note: 'Pricing unknown' });
return;
}
const inputCost = ( (u.input || 0) / 1_000_000 ) * p.input;
const outputCost = ( (u.output || 0) / 1_000_000 ) * p.output;
const cost = inputCost + outputCost;
totalCost += cost;
rows.push({ model, input: u.input|0, output: u.output|0, calls: u.calls|0, cost, inputCost, outputCost });
});
// sort by cost desc
rows.sort((a,b) => (b.cost||0) - (a.cost||0));
return { totalCost, rows };
}
// ---------- Rebuild rough stats from history (best effort) ----------
function rebuildStatsFromHistory() {
const state = window.App?.state || {};
const msgs = Array.isArray(state.messages) ? state.messages : [];
const usage = {}; // by model
let totalMsgs = 0;
for (const m of msgs) {
if (m.role === 'assistant') {
const model =
m?.meta?.sent?.payload?.model ||
state?.settings?.model ||
'gpt-5';
const payload = m?.meta?.sent?.payload || null;
// Estimate input from payload if we have it; otherwise use previous user message content
let inputTok = 0;
if (payload) {
inputTok = estInputTokensFromPayload(payload);
} else {
// fallback: grab the closest previous user message
const idx = msgs.indexOf(m);
for (let i = idx - 1; i >= 0; i--) {
if (msgs[i]?.role === 'user') { inputTok = estTokens(msgs[i].content || ''); break; }
}
}
// Output = this assistant message content (note: if you used Continue, this may represent multiple calls)
const outputTok = estTokens(m.content || '');
// Tally
const row = usage[model] || (usage[model] = { input: 0, output: 0, calls: 0 });
row.input += inputTok;
row.output += outputTok;
row.calls += 1; // note: if Continue appends to the same message, this will still count as 1
totalMsgs += 1;
}
}
// Save into App.state.stats for consistency with future precise updates
const stats = { totalMessages: totalMsgs, modelUsage: usage };
try {
window.App.state = window.App.state || {};
window.App.state.stats = stats;
window.App.saveState && window.App.saveState(window.App.state);
} catch {}
return stats;
}
// ---------- Rendering ----------
function statsHTML(stats) {
const safe = stats || window.App?.state?.stats || { totalMessages: 0, modelUsage: {} };
const { totalCost, rows } = calcCostsFromUsage(safe.modelUsage || {});
const empty = !rows.length;
if (empty) {
return `
<div style="display:flex;align-items:center;justify-content:center;min-height:300px;color:#94a3b8;text-align:center;padding:2rem;">
<div>
<div style="font-size:2rem;margin-bottom:0.5rem;">📊</div>
<div style="font-size:0.875rem;max-width:480px;">
No usage data yet.<br/>You can rebuild rough estimates from your chat history.
</div>
<div style="margin-top:1rem;">
<button id="rebuildStats" class="chat-btn" style="padding:0.5rem 0.75rem;">Rebuild from history</button>
</div>
</div>
</div>
`;
}
let cards = '';
rows.forEach(r => {
cards += `
<div style="background:#1e293b;border:1px solid #334155;border-radius:0.5rem;padding:0.75rem;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
<span style="font-weight:600;font-size:0.875rem;">${r.model}</span>
<span style="font-weight:700;color:#a78bfa;">${fmtCost(r.cost)}</span>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem;font-size:0.75rem;color:#94a3b8;">
<div>In: ${fmtNum(r.input)}</div>
<div>Out: ${fmtNum(r.output)}</div>
<div>Calls: ${fmtNum(r.calls)}</div>
<div>Unit: in $${MODEL_PRICING[r.model]?.input ?? '?'} / out $${MODEL_PRICING[r.model]?.output ?? '?'}</div>
</div>
${r.note ? `<div style="margin-top:0.5rem;font-size:0.6875rem;color:#fbbf24;">${r.note}</div>` : ''}
</div>
`;
});
return `
<div style="padding:1.25rem;background:#0f172a;color:#f1f5f9;">
<div style="background:linear-gradient(135deg,rgba(79,70,229,.2),rgba(139,92,246,.2));border:1px solid rgba(79,70,229,.3);border-radius:0.75rem;padding:1rem;margin-bottom:1rem;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
<h3 style="margin:0;font-size:1rem;font-weight:700;">Overview</h3>
<div style="display:flex;gap:0.5rem;">
<button id="exportStatsJson" class="chat-btn">Export JSON</button>
<button id="exportStatsCsv" class="chat-btn">Export CSV</button>
<button id="rebuildStats" class="chat-btn">Rebuild</button>
<button id="resetStats" class="chat-btn chat-btn--danger">Reset</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
<div style="background:#1e293b;border:1px solid #334155;border-radius:0.5rem;padding:1rem;">
<div style="font-size:0.75rem;color:#94a3b8;margin-bottom:0.25rem;">Messages</div>
<div style="font-size:1.5rem;font-weight:800;">${fmtNum(safe.totalMessages || 0)}</div>
</div>
<div style="background:#1e293b;border:1px solid #334155;border-radius:0.5rem;padding:1rem;">
<div style="font-size:0.75rem;color:#94a3b8;margin-bottom:0.25rem;">Estimated Cost</div>
<div style="font-size:1.5rem;font-weight:800;color:#a78bfa;">${fmtCost(totalCost)}</div>
</div>
</div>
</div>
<h4 style="margin:0 0 0.75rem 0;font-size:0.875rem;font-weight:700;">By Model</h4>
<div style="display:grid;gap:0.75rem;">
${cards}
</div>
<div style="margin-top:1rem;font-size:0.6875rem;color:#94a3b8;">* Estimates only; exact usage depends on provider accounting.</div>
</div>
`;
}
// ---------- UI actions ----------
function download(filename, text, type = 'application/json') {
const blob = new Blob([text], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 0);
}
function toCSV(rows) {
const headers = ['model','input','output','calls','cost'];
const lines = [headers.join(',')];
rows.forEach(r => {
lines.push([r.model, r.input, r.output, r.calls, (r.cost||0).toFixed(6)].join(','));
});
return lines.join('\n');
}
function openStats() {
// Ensure stats bucket exists
window.App.state = window.App.state || {};
if (!window.App.state.stats) {
window.App.state.stats = { totalMessages: 0, modelUsage: {} };
window.App.saveState && window.App.saveState(window.App.state);
}
const slides = [{ title: 'Usage Stats', html: statsHTML(window.App.state.stats) }];
window.AppOverlay.open(slides, 0);
// Bind buttons after render
setTimeout(() => {
const root = document.querySelector('.chat-modal__body');
if (!root) return;
const resetBtn = root.querySelector('#resetStats');
const rebuildBtn = root.querySelector('#rebuildStats');
const exportJson = root.querySelector('#exportStatsJson');
const exportCsv = root.querySelector('#exportStatsCsv');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
if (!confirm('Reset all usage statistics?')) return;
window.App.state.stats = { totalMessages: 0, modelUsage: {} };
window.App.saveState && window.App.saveState(window.App.state);
// Re-render
openStats();
});
}
if (rebuildBtn) {
rebuildBtn.addEventListener('click', () => {
const stats = rebuildStatsFromHistory();
// Re-render with rebuilt stats
window.AppOverlay.open([{ title:'Usage Stats', html: statsHTML(stats) }], 0);
setTimeout(() => { // re-bind after rerender
const again = document.querySelector('.chat-modal__body #rebuildStats');
if (again) again.addEventListener('click', () => { rebuildBtn.click(); });
const rb = document.querySelector('.chat-modal__body #resetStats');
if (rb) rb.addEventListener('click', () => { resetBtn.click(); });
const ej = document.querySelector('.chat-modal__body #exportStatsJson');
if (ej) ej.addEventListener('click', () => {
download('usage-stats.json', JSON.stringify(window.App.state.stats, null, 2));
});
const ec = document.querySelector('.chat-modal__body #exportStatsCsv');
if (ec) ec.addEventListener('click', () => {
const { rows } = calcCostsFromUsage(window.App.state.stats.modelUsage || {});
download('usage-stats.csv', toCSV(rows), 'text/csv');
});
}, 0);
});
}
if (exportJson) {
exportJson.addEventListener('click', () => {
download('usage-stats.json', JSON.stringify(window.App.state.stats || {}, null, 2));
});
}
if (exportCsv) {
exportCsv.addEventListener('click', () => {
const { rows } = calcCostsFromUsage(window.App.state.stats.modelUsage || {});
download('usage-stats.csv', toCSV(rows), 'text/csv');
});
}
}, 0);
}
// ---------- Register in AppMenu ----------
window.AppMenu.push({
id: 'stats',
label: 'Usage Stats',
action: openStats
});
console.log('[Stats] Registered (Usage Stats) — ready');
})();