📜
stats.js
Back
📝 Javascript ⚡ Executable Ctrl+S: Save • Ctrl+R: Run • Ctrl+F: Find
// 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'); })();