Record token usage per turn and show daily, weekly and all-time totals by host

This commit is contained in:
Gmer4Lfe
2026-08-04 18:04:46 -04:00
parent 961c57c6f0
commit 969a85f303
8 changed files with 340 additions and 3 deletions
+115 -1
View File
@@ -165,6 +165,28 @@ if (is_dir('/var/log/varaverk')) {
.vv-ai-mdl-m { color:#444; font-family:monospace; margin-left:auto; flex-shrink:0; font-size:10px; }
.vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; }
/* ── Token accounting ───────────────────────────────────────────────────── */
/* Splits on the second banner stat rather than the third, so the host window stays narrow and
the tiles get the room — still a line the banner already draws, so the rows read as a grid
rather than three unrelated cards. renderBanner() keeps --vv-tok-split in step. */
.vv-ai-tok { display:grid; grid-template-columns:var(--vv-tok-split,2fr 3fr); gap:1px; background:#1a1a1a;
border:1px solid #262626; border-radius:6px; overflow:hidden; }
@media (max-width:900px) { .vv-ai-tok { grid-template-columns:1fr; } }
.vv-ai-hostrow { display:flex; align-items:center; gap:8px; font-size:11px; padding:4px 6px;
border-radius:4px; cursor:pointer; border:1px solid transparent; }
.vv-ai-hostrow:hover { background:#141414; }
.vv-ai-hostrow.active { background:#14202c; border-color:#2d4a6a; }
.vv-ai-hostrow-n { color:#8a8a8a; }
.vv-ai-hostrow-h { color:#4a4a4a; font-family:monospace; font-size:10px; }
.vv-ai-hostrow-v { margin-left:auto; color:#c8c8c8; font-family:monospace; font-size:11px; flex-shrink:0; }
.vv-ai-hostrow-v.none { color:#3a3a3a; font-style:italic; font-family:inherit; font-size:10px; }
.vv-ai-tok-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:12px; }
.vv-ai-tok-l { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a; }
.vv-ai-tok-v { font-size:17px; font-weight:bold; color:#c8c8c8; font-family:monospace; line-height:1.3; }
.vv-ai-tok-s { font-size:10px; color:#5a5a5a; }
.vv-ai-tok-foot { margin-top:9px; padding-top:7px; border-top:1px solid #1a1a1a; font-size:10px;
color:#4a4a4a; display:flex; gap:14px; flex-wrap:wrap; }
.vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; }
.vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; }
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
@@ -213,6 +235,17 @@ if (is_dir('/var/log/varaverk')) {
</div>
</div>
<div class="vv-ai-tok">
<div class="vv-ai-diag-col">
<div class="vv-ai-diag-h">Hosts</div>
<div id="vv-ai-tok-hosts"></div>
</div>
<div class="vv-ai-diag-col">
<div class="vv-ai-diag-h">Token usage — <span id="vv-ai-tok-scope">all hosts</span></div>
<div id="vv-ai-tok-stats"></div>
</div>
</div>
<div class="vv-ai-profiles">
<button class="vv-ai-prof active" data-prof="varaverk" type="button">Varaverk Assistant</button>
<button class="vv-ai-prof" data-prof="chat" type="button">General Chat</button>
@@ -360,6 +393,9 @@ if (is_dir('/var/log/varaverk')) {
const nStat = $('vv-ai-banner').children.length;
if (diag) diag.style.setProperty('--vv-diag-split',
nStat > 3 ? `3fr ${nStat - 3}fr` : '3fr 2fr');
const tok = document.querySelector('.vv-ai-tok');
if (tok) tok.style.setProperty('--vv-tok-split',
nStat > 2 ? `2fr ${nStat - 2}fr` : '2fr 3fr');
renderHealth(s.health || []);
renderModels(s.loaded, s.model);
@@ -413,6 +449,83 @@ if (is_dir('/var/log/varaverk')) {
.catch(() => {});
}
// ── Token accounting ────────────────────────────────────────────────────
// Fetched on load and after each completed turn, never on the 30s banner tick: the totals
// only move when a turn finishes, and the page is the thing that knows when that was.
let tokData = null, tokScope = 'all';
const num = n => (n || 0).toLocaleString();
function loadTokens() {
fetch(API + '?action=tokens').then(r => r.json())
.then(d => { if (d.ok) { tokData = d.tokens; renderTokens(); } })
.catch(() => {});
}
function renderTokens() {
if (!tokData) return;
const hosts = tokData.hosts || {};
// A host with no rows reads "not collected here", never 0. Each host writes to its own
// data/ and nothing syncs it, so a zero would claim the partner did no work when the
// truth is that this host cannot see the partner's ledger at all.
let hh = `<div class="vv-ai-hostrow${tokScope === 'all' ? ' active' : ''}" data-scope="all">`
+ `<span class="vv-ai-hostrow-n">All hosts</span>`
+ `<span class="vv-ai-hostrow-v">${num(tokData.all.total)}</span></div>`;
Object.keys(hosts).forEach(id => {
const x = hosts[id];
hh += `<div class="vv-ai-hostrow${tokScope === id ? ' active' : ''}" data-scope="${esc(id)}">`
+ `<span class="vv-ai-hostrow-n">${esc(x.name)}</span>`
+ `<span class="vv-ai-hostrow-h">${esc(id)}${x.self ? ' · this host' : ''}</span>`
+ (x.seen ? `<span class="vv-ai-hostrow-v">${num(x.all.total)}</span>`
: `<span class="vv-ai-hostrow-v none">not collected here</span>`)
+ `</div>`;
});
$('vv-ai-tok-hosts').innerHTML = hh;
const sel = tokScope === 'all' ? tokData : hosts[tokScope];
$('vv-ai-tok-scope').textContent =
tokScope === 'all' ? 'all hosts' : (hosts[tokScope] ? hosts[tokScope].name : tokScope);
if (!sel || !sel.all.turns) {
$('vv-ai-tok-stats').innerHTML = tokScope === 'all'
? '<div class="vv-ai-none">no turns recorded yet — ask something below</div>'
: '<div class="vv-ai-none">no rows from this host in the local ledger</div>';
return;
}
const cell = (label, b) =>
`<div><div class="vv-ai-tok-l">${label}</div>`
+ `<div class="vv-ai-tok-v">${num(b.total)}</div>`
+ `<div class="vv-ai-tok-s">${num(b.turns)} turn${b.turns === 1 ? '' : 's'}`
+ ` · ${num(b.prompt)} in / ${num(b.completion)} out</div></div>`;
let html = `<div class="vv-ai-tok-grid">`
+ cell('Today', sel.today) + cell('Last 7 days', sel.week) + cell('All time', sel.all)
+ `</div>`;
const foot = [];
if (tokData.first) foot.push(`since ${esc(tokData.first)} · ${tokData.days} day${tokData.days === 1 ? '' : 's'}`);
if (tokData.best_tok_s) foot.push(`best ${tokData.best_tok_s} tok/s`);
// Profile and source splits are whole-ledger figures, so they are shown only under the
// all-hosts scope rather than sitting under a host heading they do not describe.
if (tokScope === 'all') {
const by = o => Object.keys(o || {}).map(k => `${esc(k)} ${num(o[k])}`).join(' · ');
if (Object.keys(tokData.profiles || {}).length) foot.push('profile: ' + by(tokData.profiles));
if (Object.keys(tokData.sources || {}).length) foot.push('source: ' + by(tokData.sources));
}
if (foot.length) html += `<div class="vv-ai-tok-foot">`
+ foot.map(f => `<span>${f}</span>`).join('') + `</div>`;
$('vv-ai-tok-stats').innerHTML = html;
}
$('vv-ai-tok-hosts').addEventListener('click', e => {
const row = e.target.closest('.vv-ai-hostrow');
if (!row) return;
tokScope = row.dataset.scope;
renderTokens();
});
// ── Minimal markdown, applied strictly after escaping ───────────────────
function fmt(text) {
let h = esc(text);
@@ -594,7 +707,7 @@ if (is_dir('/var/log/varaverk')) {
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
finish(); loadBanner(); return;
finish(); loadBanner(); loadTokens(); return;
}
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
phase(j.status === 'generating'
@@ -732,5 +845,6 @@ if (is_dir('/var/log/varaverk')) {
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(pendingTimer); };
loadBanner();
loadTokens();
})();
</script>