Record token usage per turn and show daily, weekly and all-time totals by host
This commit is contained in:
@@ -225,3 +225,38 @@ No cron entry and no `DAILY_MAINTENANCE_SCRIPTS` line are needed; the daily pull
|
|||||||
|
|
||||||
An incremental run on an unchanged repo is ~70 ms, so a daily entry costs effectively nothing
|
An incremental run on an unchanged repo is ~70 ms, so a daily entry costs effectively nothing
|
||||||
and a pull that changed twelve files costs a few seconds.
|
and a pull that changed twelve files costs a few seconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ━━━ TOKEN ACCOUNTING ━━━
|
||||||
|
|
||||||
|
Every completed `ask` appends one row to `AI_TOKEN_DB` (`data/ai_token_history.db`):
|
||||||
|
|
||||||
|
```
|
||||||
|
date|time|host|profile|source|prompt_tokens|completion_tokens|tok_s
|
||||||
|
2026-08-04|22:03:51|host1|varaverk|cli|2041|318|61.4
|
||||||
|
```
|
||||||
|
|
||||||
|
Both paths write it — this CLI (`source=cli`) and the WebGUI worker (`source=webgui`) — so the
|
||||||
|
totals are not quietly the tab's alone. `ai_query.sh` passes `--token-db` and `--token-host`;
|
||||||
|
called by hand without them, `cli.js` simply skips the row rather than guessing a path, because
|
||||||
|
this file never reads conf itself.
|
||||||
|
|
||||||
|
Read it on the plugin's AI tab, which aggregates today / last 7 days / all time, per host. Or
|
||||||
|
straight from the shell, since it is just a delimited file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# tokens used today
|
||||||
|
awk -F'|' -v d="$(date +%F)" '$1==d {p+=$6; c+=$7} END {print p+c}' data/ai_token_history.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**The host column is where the turn ran, not where the file is read.** Each host keeps its own
|
||||||
|
`data/` and nothing syncs it, so a host only ever sees its own rows — the partner shows as
|
||||||
|
"not collected here" on the tab, never as zero. Carrying a partner's totals would mean extending
|
||||||
|
the partnership payload fetch; the column exists so that stays a display change rather than a
|
||||||
|
migration.
|
||||||
|
|
||||||
|
Pruning is by row count (`AI_TOKEN_RETAIN_ROWS`, default 20000) and happens on write, but only
|
||||||
|
once the file passes a size threshold — an ordinary turn costs a `stat()` and an append. The CLI
|
||||||
|
deliberately does not prune: duplicating a read-modify-write of the whole file in a second
|
||||||
|
language is how the two drift apart.
|
||||||
|
|||||||
+5
-1
@@ -198,8 +198,12 @@ if [[ "$SEARCH_ONLY" == true ]]; then
|
|||||||
"--k=${AI_SEARCH_K:-8}" "--per-file=${AI_SEARCH_PER_FILE:-3}"
|
"--k=${AI_SEARCH_K:-8}" "--per-file=${AI_SEARCH_PER_FILE:-3}"
|
||||||
else
|
else
|
||||||
[[ -z "$GEN_MODEL" ]] && { error "${MY_ID}_OLLAMA_MODEL is empty — needed for generation"; exit 1; }
|
[[ -z "$GEN_MODEL" ]] && { error "${MY_ID}_OLLAMA_MODEL is empty — needed for generation"; exit 1; }
|
||||||
|
# Token accounting. Passed in rather than re-read in node, so the conf stays the shell's job
|
||||||
|
# and cli.js keeps taking everything it needs as arguments. Omitting either flag simply
|
||||||
|
# skips the row — the CLI must still work when called by hand outside this wrapper.
|
||||||
node --no-warnings "$CLI" ask "${_args[@]}" \
|
node --no-warnings "$CLI" ask "${_args[@]}" \
|
||||||
"--model=${GEN_MODEL}" "--embed-model=${EMBED_MODEL}" \
|
"--model=${GEN_MODEL}" "--embed-model=${EMBED_MODEL}" \
|
||||||
"--k=${AI_SEARCH_K:-8}" "--per-file=${AI_SEARCH_PER_FILE:-3}" \
|
"--k=${AI_SEARCH_K:-8}" "--per-file=${AI_SEARCH_PER_FILE:-3}" \
|
||||||
"--timeout=$(( ${AI_REQUEST_TIMEOUT:-240} * 1000 ))"
|
"--timeout=$(( ${AI_REQUEST_TIMEOUT:-240} * 1000 ))" \
|
||||||
|
"--token-db=${AI_TOKEN_DB:-}" "--token-host=$(echo "$MY_ID" | tr '[:upper:]' '[:lower:]')"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
// are.
|
// are.
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
const { buildIndex } = require('./index.js');
|
const { buildIndex } = require('./index.js');
|
||||||
const { search } = require('./search.js');
|
const { search } = require('./search.js');
|
||||||
|
|
||||||
@@ -24,6 +25,27 @@ function flag(name) {
|
|||||||
return process.argv.includes(`--${name}`);
|
return process.argv.includes(`--${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token accounting. Writes the same row shape as the WebGUI worker into the same file — one
|
||||||
|
// ledger for both paths, or the totals quietly come to mean "whatever the tab happened to do".
|
||||||
|
// Skipped silently when the caller passes neither flag, because cli.js has to stay runnable by
|
||||||
|
// hand. Best-effort: a failed append must never cost a caller an answer it already has.
|
||||||
|
//
|
||||||
|
// Trimming is deliberately not done here. The PHP side prunes on write, and duplicating a
|
||||||
|
// read-modify-write of the whole file in a second language is how the two drift apart.
|
||||||
|
function recordTokens(profile, prompt, completion, tokS) {
|
||||||
|
const db = arg('token-db', ''), host = arg('token-host', '');
|
||||||
|
if (!db || !host || (prompt <= 0 && completion <= 0)) return;
|
||||||
|
const d = new Date();
|
||||||
|
const p2 = n => String(n).padStart(2, '0');
|
||||||
|
const row = [
|
||||||
|
`${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`,
|
||||||
|
`${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`,
|
||||||
|
host, profile, 'cli', prompt, completion,
|
||||||
|
tokS === null ? '' : tokS.toFixed(1),
|
||||||
|
].join('|') + '\n';
|
||||||
|
try { fs.appendFileSync(db, row); } catch { /* accounting is not the answer */ }
|
||||||
|
}
|
||||||
|
|
||||||
function fail(msg, code = 1) {
|
function fail(msg, code = 1) {
|
||||||
console.error(msg);
|
console.error(msg);
|
||||||
process.exit(code);
|
process.exit(code);
|
||||||
@@ -165,6 +187,11 @@ ANSWER`;
|
|||||||
if (!res.ok) fail(`generation HTTP ${res.status}`, 2);
|
if (!res.ok) fail(`generation HTTP ${res.status}`, 2);
|
||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
|
|
||||||
|
// 'varaverk' rather than a CLI-specific name: this path retrieves and cites, so it is the
|
||||||
|
// same kind of turn the tab's default profile runs, and the two should aggregate together.
|
||||||
|
recordTokens('varaverk', j.prompt_eval_count || 0, j.eval_count || 0,
|
||||||
|
j.eval_duration > 0 ? (j.eval_count / (j.eval_duration / 1e9)) : null);
|
||||||
|
|
||||||
if (flag('json')) {
|
if (flag('json')) {
|
||||||
console.log(JSON.stringify({ answer: j.response, sources: r.results.map(x => ({ path: x.path, section: x.section, heading: x.heading, score: x.score })) }));
|
console.log(JSON.stringify({ answer: j.response, sources: r.results.map(x => ({ path: x.path, section: x.section, heading: x.heading, score: x.score })) }));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1644,6 +1644,23 @@
|
|||||||
AI_MEMORY_FILE="$DATA_DIR/ai_memory.md"
|
AI_MEMORY_FILE="$DATA_DIR/ai_memory.md"
|
||||||
AI_MEMORY_MAX_CHARS=4000 # ~1000 tokens — truncated with a notice if exceeded
|
AI_MEMORY_MAX_CHARS=4000 # ~1000 tokens — truncated with a notice if exceeded
|
||||||
|
|
||||||
|
# ━━━ AI Token Accounting ━━━
|
||||||
|
# One row per completed turn, appended by whichever path ran it — the WebGUI worker and the
|
||||||
|
# ai_query.sh CLI both write here, so the totals are not silently the tab's alone. Format is
|
||||||
|
# pipe-delimited to match the other data/*.db files:
|
||||||
|
#
|
||||||
|
# date|time|host|profile|source|prompt_tokens|completion_tokens|tok_s
|
||||||
|
#
|
||||||
|
# The host column records where the turn ran, not where it is read. Each host keeps its own
|
||||||
|
# data/ and nothing syncs it, so a host only ever sees its own rows — the column is there so
|
||||||
|
# the file is already shaped right if the partner payload fetch is ever extended to carry it.
|
||||||
|
# A column added later cannot be backfilled.
|
||||||
|
#
|
||||||
|
# Retention is by row count rather than age: pruning is considered only when the file passes a
|
||||||
|
# size threshold, so an ordinary turn costs one stat() and an append.
|
||||||
|
AI_TOKEN_DB="$DATA_DIR/ai_token_history.db"
|
||||||
|
AI_TOKEN_RETAIN_ROWS=20000 # oldest rows dropped past this — years of ordinary use
|
||||||
|
|
||||||
# ━━━ AI Feature Toggles ━━━
|
# ━━━ AI Feature Toggles ━━━
|
||||||
# Tier 1 is narration — it cannot change a decision. Tier 2 adds context to a decision a script
|
# Tier 1 is narration — it cannot change a decision. Tier 2 adds context to a decision a script
|
||||||
# already made. Tier 3 assists a human. Enable in that order, and give each one weeks.
|
# already made. Tier 3 assists a human. Enable in that order, and give each one weeks.
|
||||||
|
|||||||
@@ -363,6 +363,11 @@ if ($profile === 'code' && preg_match_all('/```(?:\w+)?\n(.*?)```/s', $answer, $
|
|||||||
|
|
||||||
$evalCount = (int)($d['eval_count'] ?? 0);
|
$evalCount = (int)($d['eval_count'] ?? 0);
|
||||||
$evalNs = (int)($d['eval_duration'] ?? 0);
|
$evalNs = (int)($d['eval_duration'] ?? 0);
|
||||||
|
$tokS = $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null;
|
||||||
|
|
||||||
|
// Accounting before the job file is written, so a turn is counted even if the tab has already
|
||||||
|
// been closed and nobody ever reads the result. Best-effort by contract — it cannot throw.
|
||||||
|
vv_ai_token_record($profile, 'webgui', (int)($d['prompt_eval_count'] ?? 0), $evalCount, $tokS);
|
||||||
|
|
||||||
jw($jobFile, [
|
jw($jobFile, [
|
||||||
'status' => 'done',
|
'status' => 'done',
|
||||||
@@ -374,6 +379,6 @@ jw($jobFile, [
|
|||||||
'retrieve_ms' => (int)round($tRetrieve * 1000),
|
'retrieve_ms' => (int)round($tRetrieve * 1000),
|
||||||
'generate_ms' => (int)round((microtime(true) - $t1) * 1000),
|
'generate_ms' => (int)round((microtime(true) - $t1) * 1000),
|
||||||
'tokens' => $evalCount,
|
'tokens' => $evalCount,
|
||||||
'tok_s' => $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null,
|
'tok_s' => $tokS,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -134,6 +134,15 @@ if ($action === 'stats') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── tokens ────────────────────────────────────────────────────────────────────
|
||||||
|
// Separate from stats rather than folded into it. stats is polled every 30 seconds by every
|
||||||
|
// open tab; this reads a file that grows without bound between prunes. The totals only move
|
||||||
|
// when a turn completes, and the page knows exactly when that happened, so it asks then.
|
||||||
|
if ($action === 'tokens') {
|
||||||
|
echo json_encode(['ok' => true, 'tokens' => vv_ai_token_stats()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// ── poll ──────────────────────────────────────────────────────────────────────
|
// ── poll ──────────────────────────────────────────────────────────────────────
|
||||||
if ($action === 'poll') {
|
if ($action === 'poll') {
|
||||||
$token = trim($_GET['token'] ?? '');
|
$token = trim($_GET['token'] ?? '');
|
||||||
|
|||||||
@@ -554,6 +554,132 @@ function vv_ai_memory_write(string $text): array {
|
|||||||
return ['ok' => true, 'chars' => $len];
|
return ['ok' => true, 'chars' => $len];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Token accounting ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// One appended row per completed turn: date|time|host|profile|source|prompt|completion|tok_s.
|
||||||
|
// Pipe-delimited under data/ to match arr_cleanup_stats.db and bandwidth_history.db rather than
|
||||||
|
// inventing a format for one feature.
|
||||||
|
//
|
||||||
|
// Recording is best-effort and never raises. A turn that answered correctly must not be reported
|
||||||
|
// as failed because its accounting row could not be written — the number is the cheap part here
|
||||||
|
// and the answer is the expensive one.
|
||||||
|
|
||||||
|
function vv_ai_token_db(): string {
|
||||||
|
$p = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR,
|
||||||
|
trim(vv_conf_vars()['AI_TOKEN_DB'] ?? ''));
|
||||||
|
return $p !== '' ? $p : DATA_DIR . '/ai_token_history.db';
|
||||||
|
}
|
||||||
|
|
||||||
|
function vv_ai_token_retain(): int {
|
||||||
|
$n = (int)(vv_conf_vars()['AI_TOKEN_RETAIN_ROWS'] ?? 20000);
|
||||||
|
return max(500, min($n, 500000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOCK_EX because two turns can finish together — the composer allows a second question while
|
||||||
|
// the first is still generating, and each runs in its own detached worker.
|
||||||
|
function vv_ai_token_record(string $profile, string $source, int $prompt, int $completion, ?float $tokS): void {
|
||||||
|
if ($prompt <= 0 && $completion <= 0) return; // nothing generated; not a turn worth a row
|
||||||
|
|
||||||
|
$db = vv_ai_token_db();
|
||||||
|
$dir = dirname($db);
|
||||||
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return;
|
||||||
|
|
||||||
|
$row = implode('|', [
|
||||||
|
date('Y-m-d'), date('H:i:s'),
|
||||||
|
vv_detect_host(),
|
||||||
|
preg_replace('/[^a-z0-9_-]/i', '', $profile) ?: 'unknown',
|
||||||
|
preg_replace('/[^a-z0-9_-]/i', '', $source) ?: 'unknown',
|
||||||
|
max(0, $prompt), max(0, $completion),
|
||||||
|
$tokS === null ? '' : number_format($tokS, 1, '.', ''),
|
||||||
|
]) . "\n";
|
||||||
|
|
||||||
|
@file_put_contents($db, $row, FILE_APPEND | LOCK_EX);
|
||||||
|
vv_ai_token_prune($db);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruning is gated on filesize rather than done every turn: filesize() is a stat, whereas
|
||||||
|
// reading and rewriting the whole file is not something a chat turn should pay for per message.
|
||||||
|
// The threshold is derived from the row cap so raising the cap does not silently disable it.
|
||||||
|
function vv_ai_token_prune(string $db): void {
|
||||||
|
$keep = vv_ai_token_retain();
|
||||||
|
if ((int)@filesize($db) < $keep * 60) return; // ~60 bytes a row, generously
|
||||||
|
|
||||||
|
$rows = @file($db, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
if (!is_array($rows) || count($rows) <= $keep) return;
|
||||||
|
|
||||||
|
$tmp = $db . '.vv.tmp';
|
||||||
|
if (@file_put_contents($tmp, implode("\n", array_slice($rows, -$keep)) . "\n") === false) return;
|
||||||
|
@rename($tmp, $db);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregates the file into today / last 7 days / all time, both overall and per host.
|
||||||
|
//
|
||||||
|
// Every detected host is present in 'hosts' whether or not it has rows, with 'seen' telling the
|
||||||
|
// two apart. A host with no rows is not the same fact as a host with zero tokens, and the page
|
||||||
|
// must be able to say "nothing collected here" rather than draw a confident 0.
|
||||||
|
function vv_ai_token_stats(): array {
|
||||||
|
$db = vv_ai_token_db();
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$week = date('Y-m-d', strtotime('-6 days')); // 7 days inclusive of today
|
||||||
|
|
||||||
|
$blank = ['turns' => 0, 'prompt' => 0, 'completion' => 0, 'total' => 0];
|
||||||
|
$out = [
|
||||||
|
'exists' => file_exists($db),
|
||||||
|
'today' => $blank, 'week' => $blank, 'all' => $blank,
|
||||||
|
'hosts' => [], 'profiles' => [], 'sources' => [],
|
||||||
|
'first' => null, 'last' => null, 'best_tok_s' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (vv_known_hosts() as $id => $name) {
|
||||||
|
$out['hosts'][$id] = ['name' => $name, 'seen' => false, 'self' => $id === vv_detect_host(),
|
||||||
|
'today' => $blank, 'week' => $blank, 'all' => $blank];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fh = @fopen($db, 'r');
|
||||||
|
if (!$fh) return $out;
|
||||||
|
|
||||||
|
$add = function (array $b, int $p, int $c): array {
|
||||||
|
$b['turns']++; $b['prompt'] += $p; $b['completion'] += $c; $b['total'] += $p + $c;
|
||||||
|
return $b;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (($line = fgets($fh)) !== false) {
|
||||||
|
$f = explode('|', rtrim($line, "\r\n"));
|
||||||
|
if (count($f) < 8) continue; // partial write, or a format change
|
||||||
|
[$date, , $host, $profile, $source, $p, $c, $tokS] = $f;
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) continue;
|
||||||
|
$p = (int)$p; $c = (int)$c;
|
||||||
|
|
||||||
|
if (!isset($out['hosts'][$host])) {
|
||||||
|
// A host in the file that conf no longer lists — renamed, or a row copied in. Shown
|
||||||
|
// rather than dropped, so the totals always reconcile with the per-host rows.
|
||||||
|
$out['hosts'][$host] = ['name' => $host, 'seen' => false, 'self' => false,
|
||||||
|
'today' => $blank, 'week' => $blank, 'all' => $blank];
|
||||||
|
}
|
||||||
|
$out['hosts'][$host]['seen'] = true;
|
||||||
|
|
||||||
|
foreach ([['all', true], ['week', $date >= $week], ['today', $date === $today]] as [$k, $hit]) {
|
||||||
|
if (!$hit) continue;
|
||||||
|
$out[$k] = $add($out[$k], $p, $c);
|
||||||
|
$out['hosts'][$host][$k] = $add($out['hosts'][$host][$k], $p, $c);
|
||||||
|
}
|
||||||
|
|
||||||
|
$out['profiles'][$profile] = ($out['profiles'][$profile] ?? 0) + $p + $c;
|
||||||
|
$out['sources'][$source] = ($out['sources'][$source] ?? 0) + $p + $c;
|
||||||
|
if ($tokS !== '' && (float)$tokS > (float)($out['best_tok_s'] ?? 0)) $out['best_tok_s'] = (float)$tokS;
|
||||||
|
// Min/max rather than first-row/last-row. Appends are chronological in practice, but a
|
||||||
|
// file that has been hand-edited, merged or restored out of order would otherwise report
|
||||||
|
// a span that reads backwards — and the row cap means the oldest row is not permanent.
|
||||||
|
if ($out['first'] === null || $date < $out['first']) $out['first'] = $date;
|
||||||
|
if ($out['last'] === null || $date > $out['last']) $out['last'] = $date;
|
||||||
|
}
|
||||||
|
fclose($fh);
|
||||||
|
|
||||||
|
arsort($out['profiles']);
|
||||||
|
arsort($out['sources']);
|
||||||
|
$out['days'] = $out['first'] ? max(1, (int)((strtotime($today) - strtotime($out['first'])) / 86400) + 1) : 0;
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
function vv_ai_job_dir(): string {
|
function vv_ai_job_dir(): string {
|
||||||
if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true);
|
if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true);
|
||||||
return VV_AI_JOB_DIR;
|
return VV_AI_JOB_DIR;
|
||||||
|
|||||||
+115
-1
@@ -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-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; }
|
.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-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; }
|
.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;} }
|
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
|
||||||
@@ -213,6 +235,17 @@ if (is_dir('/var/log/varaverk')) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="vv-ai-profiles">
|
||||||
<button class="vv-ai-prof active" data-prof="varaverk" type="button">Varaverk Assistant</button>
|
<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>
|
<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;
|
const nStat = $('vv-ai-banner').children.length;
|
||||||
if (diag) diag.style.setProperty('--vv-diag-split',
|
if (diag) diag.style.setProperty('--vv-diag-split',
|
||||||
nStat > 3 ? `3fr ${nStat - 3}fr` : '3fr 2fr');
|
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 || []);
|
renderHealth(s.health || []);
|
||||||
renderModels(s.loaded, s.model);
|
renderModels(s.loaded, s.model);
|
||||||
@@ -413,6 +449,83 @@ if (is_dir('/var/log/varaverk')) {
|
|||||||
.catch(() => {});
|
.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 ───────────────────
|
// ── Minimal markdown, applied strictly after escaping ───────────────────
|
||||||
function fmt(text) {
|
function fmt(text) {
|
||||||
let h = esc(text);
|
let h = esc(text);
|
||||||
@@ -594,7 +707,7 @@ if (is_dir('/var/log/varaverk')) {
|
|||||||
fetch(API, { method: 'POST',
|
fetch(API, { method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||||
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
|
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; }
|
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
|
||||||
phase(j.status === 'generating'
|
phase(j.status === 'generating'
|
||||||
@@ -732,5 +845,6 @@ if (is_dir('/var/log/varaverk')) {
|
|||||||
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(pendingTimer); };
|
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(pendingTimer); };
|
||||||
|
|
||||||
loadBanner();
|
loadBanner();
|
||||||
|
loadTokens();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user