agenthub/src/server/board/v1Budget.ts

300 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Verbatim port of the v1 budget-panel client JS (old src/server/board.ts,
* lines 14661750). Kept byte-identical on purpose: session/total baseline
* logic, donut signature throttling and the reset handler are battle-tested.
*
* Depends on page-scope helpers provided by the columns/data-layer port:
* esc, getJSON, hashColor, agentSpec, toast. Mount points expected in the DOM:
* #budgetRows, #budgetTotal, #budgetReset, [data-budget-mode].
*/
export const v1BudgetJs = `
// ── Roster ──────────────────────────────────────────────────────────────
// AGENTS backs the budget panel + resolves a title's "name:" prefix to a
// known agent when the architect drags a card into In Progress.
var AGENTS = [];
var BUDGET = null;
var budgetMode = localStorage.getItem('agenthub-budget-mode') || 'session';
var donutMetric = localStorage.getItem('agenthub-donut-metric') || 'tokens';
var lastDonutSignature = '';
var lastDonutAt = 0;
var DONUT_REFRESH_MS = 180000;
async function loadAgents() {
try {
AGENTS = await getJSON('/agents');
} catch (_) { AGENTS = []; }
}
// ── Budget panel ────────────────────────────────────────────────────────
function fmtTokens(n) {
n = Number(n) || 0;
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(Math.round(n));
}
function fmtEur(n) { return '\\u20ac' + (Number(n) || 0).toFixed(2); }
function maxTok(list) { return list.reduce(function(m, a) { return Math.max(m, a.tokens || 0); }, 0); }
function cloneAgent(a) {
return {
name: a.name,
role: a.role,
model: a.model,
kind: a.kind,
tokens: Number(a.tokens) || 0,
realTokens: Number(a.realTokens) || 0,
estimatedTokens: Number(a.estimatedTokens) || 0,
estimated: !!a.estimated,
costEur: Number(a.costEur) || 0,
taskCount: Number(a.taskCount) || 0
};
}
function currentSnapshot(rep) {
var byAgent = {};
(rep && rep.agents ? rep.agents : []).forEach(function(a) {
byAgent[a.name] = { tokens: Number(a.tokens) || 0, costEur: Number(a.costEur) || 0 };
});
return { createdAt: Date.now(), byAgent: byAgent };
}
function readBaseline() {
try { return JSON.parse(localStorage.getItem('agenthub-budget-baseline') || 'null'); } catch (_) { return null; }
}
function writeBaseline(rep) {
localStorage.setItem('agenthub-budget-baseline', JSON.stringify(currentSnapshot(rep)));
}
function applyBudgetMode(rep) {
var agents = (rep && rep.agents ? rep.agents : []).map(cloneAgent);
if (budgetMode === 'session') {
var base = readBaseline();
if (!base) { writeBaseline(rep); base = readBaseline(); }
agents.forEach(function(a) {
var b = base && base.byAgent ? base.byAgent[a.name] : null;
var baseTokens = b ? Number(b.tokens) || 0 : 0;
var baseCost = b ? Number(b.costEur) || 0 : 0;
a.tokens = Math.max(0, a.tokens - baseTokens);
a.costEur = Math.max(0, a.costEur - baseCost);
a.realTokens = Math.min(a.realTokens, a.tokens);
a.estimatedTokens = Math.max(0, a.tokens - a.realTokens);
a.estimated = a.estimated && a.estimatedTokens > 0;
});
}
var totals = {
tokens: agents.reduce(function(s, a) { return s + (a.tokens || 0); }, 0),
costEur: agents.reduce(function(s, a) { return s + (a.costEur || 0); }, 0),
estimated: agents.some(function(a) { return a.estimated; })
};
return { agents: agents, totals: totals };
}
function companySpec(kind) {
var key = String(kind || '').toLowerCase();
var map = {
anthropic: { name: 'Anthropic', color: '#D97757' },
openai: { name: 'OpenAI', color: '#10A37F' },
moonshot: { name: 'Moonshot', color: '#7C3AED' }
};
return map[key] || { name: key ? key.charAt(0).toUpperCase() + key.slice(1) : 'Unknown', color: hashColor(key || 'unknown') };
}
function donutPolar(cx, cy, r, angleDeg) {
var rad = angleDeg * Math.PI / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function donutArc(cx, cy, r, startDeg, endDeg) {
var start = donutPolar(cx, cy, r, startDeg);
var end = donutPolar(cx, cy, r, endDeg);
var largeArc = endDeg - startDeg > 180 ? 1 : 0;
return 'M ' + start.x.toFixed(2) + ' ' + start.y.toFixed(2) + ' A ' + r + ' ' + r + ' 0 ' + largeArc + ' 1 ' + end.x.toFixed(2) + ' ' + end.y.toFixed(2);
}
function aggregateByCompany(agents, field) {
var byKind = {};
(agents || []).forEach(function(a) {
var kind = String(a.kind || '').toLowerCase();
if (!kind || !(a.tokens > 0)) return;
var spec = companySpec(kind);
if (!byKind[kind]) byKind[kind] = { kind: kind, name: spec.name, color: spec.color, value: 0 };
byKind[kind].value += Number(a[field]) || 0;
});
return Object.keys(byKind).map(function(k) { return byKind[k]; }).sort(function(a, b) { return b.value - a.value; });
}
function halfDonut(title, rows, centerValue, centerLabel, formatValue) {
var total = rows.reduce(function(s, x) { return s + (x.value || 0); }, 0);
var safeTotal = Math.max(1, total);
var cx = 110, cy = 122, r = 90, stroke = 18;
var angle = 180;
var paths = '<path d="' + donutArc(cx, cy, r, 180, 360) + '" stroke="rgba(255,255,255,.09)" stroke-width="' + stroke + '" fill="none" stroke-linecap="round" />';
rows.forEach(function(row, idx) {
var start = angle;
var end = angle + 180 * ((row.value || 0) / safeTotal);
angle = end;
if (end <= start + 0.4) return;
paths += '<path class="donut-segment" d="' + donutArc(cx, cy, r, start, end) + '" stroke="' + esc(row.color) + '" stroke-width="' + stroke + '" fill="none" stroke-linecap="round" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100" style="animation-delay:' + (idx * 80) + 'ms" />';
});
if (!rows.length) paths += '<circle cx="' + cx + '" cy="' + (cy - r + stroke / 2) + '" r="3" fill="#94A3B8" />';
var legend = rows.length ? rows.map(function(row) {
var pct = total > 0 ? Math.round((row.value / total) * 100) : 0;
return '<div class="legend-row" style="color:' + esc(row.color) + '">' +
'<span class="legend-dot"></span><span class="legend-name">' + esc(row.name) + '</span>' +
'<span>' + esc(formatValue(row.value)) + ' · ' + pct + '%</span></div>';
}).join('') : '<div class="empty">no provider data</div>';
return '<div class="donut-card">' +
'<h3 class="donut-title">' + esc(title) + '</h3>' +
'<div class="donut-tabs"><button type="button" data-donut-tab="tokens" class="' + (donutMetric === 'tokens' ? 'active' : '') + '">Token</button><button type="button" data-donut-tab="cost" class="' + (donutMetric === 'cost' ? 'active' : '') + '">Kosten</button></div>' +
'<div class="half-donut">' +
'<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths +
'<text class="donut-value" x="110" y="102" text-anchor="middle" dominant-baseline="central" data-count-to="' + esc(centerValue) + '" data-count-format="' + (formatValue === fmtEur ? 'eur' : 'tokens') + '">0</text>' +
'<text class="donut-label" x="110" y="120" text-anchor="middle" dominant-baseline="central">' + esc(centerLabel) + '</text>' +
'</svg>' +
'</div>' +
'<div class="legend">' + legend + '</div>' +
'</div>';
}
function animateCounts(root) {
var nodes = (root || document).querySelectorAll('[data-count-to]');
nodes.forEach(function(node) {
var target = Number(node.getAttribute('data-count-to')) || 0;
var format = node.getAttribute('data-count-format');
var start = performance.now();
function step(now) {
var p = Math.min(1, (now - start) / 900);
var eased = 1 - Math.pow(1 - p, 3);
var val = target * eased;
node.textContent = format === 'eur' ? fmtEur(val) : fmtTokens(val);
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
}
function signatureFor(rows) {
return rows.map(function(r) { return r.kind + ':' + Math.round((Number(r.value) || 0) * 100); }).join('|');
}
function shouldRenderDonut(nextSig, force) {
if (force) return true;
var now = Date.now();
if (!lastDonutSignature || now - lastDonutAt > DONUT_REFRESH_MS) return true;
if (nextSig === lastDonutSignature) return false;
var prev = {};
lastDonutSignature.split('|').forEach(function(part) {
if (!part) return;
var bits = part.split(':');
prev[bits[0]] = Number(bits[1]) || 0;
});
var changed = false;
nextSig.split('|').forEach(function(part) {
if (!part) return;
var bits = part.split(':');
var oldVal = prev[bits[0]] || 0;
var newVal = Number(bits[1]) || 0;
if (Math.abs(newVal - oldVal) / Math.max(1, oldVal) > 0.10) changed = true;
});
return changed;
}
function renderDonut(active, force) {
var wrap = document.getElementById('donutWrap');
if (!wrap) return;
var field = donutMetric === 'cost' ? 'costEur' : 'tokens';
var companies = aggregateByCompany(active, field);
var sig = donutMetric + ':' + signatureFor(companies);
if (!shouldRenderDonut(sig, force)) return;
lastDonutSignature = sig;
lastDonutAt = Date.now();
var center = active.reduce(function(s, a) { return s + (Number(a[field]) || 0); }, 0);
wrap.innerHTML = halfDonut(donutMetric === 'cost' ? 'Cost by Company' : 'Tokens by Company', companies, center, donutMetric === 'cost' ? 'EUR' : 'tokens', donutMetric === 'cost' ? fmtEur : fmtTokens);
animateCounts(wrap);
}
function agentBarHtml(a, mx) {
var spec = agentSpec(a.name);
var est = a.estimated ? '<span class="est">~ estimated</span>' : 'real';
var pct = mx > 0 ? Math.round(((a.tokens || 0) / mx) * 1000) / 10 : 0;
var company = companySpec(a.kind);
return '<div class="agent-bar-row" data-agent-row="' + esc(a.name) + '">' +
'<div class="budget-agent"><span class="ba-core" style="background:' + esc(spec.color) + '">' + esc(spec.initials) + '</span>' +
'<span style="min-width:0"><span class="ba-name">' + esc(a.name) + '</span>' +
'<span class="ba-model">' + esc(company.name + (a.model ? ' · ' + a.model : '')) + '</span></span></div>' +
'<div class="agent-bar-cell"><div class="agent-bar-track" style="--bar-color:' + esc(company.color) + '"><span style="width:' + Math.min(100, pct) + '%"></span></div>' +
'<div class="agent-bar-meta"><span class="tok">' + fmtTokens(a.tokens) + ' tok</span><span>' + est + ' · ' + fmtEur(a.costEur) + '</span></div></div>' +
'</div>';
}
function renderAgentBars(active) {
var host = document.getElementById('agentBars');
if (!host) return;
var before = {};
host.querySelectorAll('[data-agent-row]').forEach(function(el) {
before[el.getAttribute('data-agent-row')] = el.getBoundingClientRect();
});
var mx = maxTok(active) || 1;
var sorted = active.slice().sort(function(a, b) { return (b.tokens || 0) - (a.tokens || 0) || a.name.localeCompare(b.name); });
host.innerHTML = '<h3>Token-Verbrauch pro Agent</h3>' + sorted.map(function(a) { return agentBarHtml(a, mx); }).join('');
host.querySelectorAll('[data-agent-row]').forEach(function(el) {
var key = el.getAttribute('data-agent-row');
var old = before[key];
if (!old) return;
var now = el.getBoundingClientRect();
var dy = old.top - now.top;
if (!dy) return;
el.style.transform = 'translateY(' + dy + 'px)';
requestAnimationFrame(function() { el.style.transform = ''; });
});
}
function updateBudgetButtons() {
document.querySelectorAll('[data-budget-mode]').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-budget-mode') === budgetMode);
});
document.querySelectorAll('[data-donut-tab]').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-donut-tab') === donutMetric);
});
}
function renderBudget(rep, opts) {
opts = opts || {};
BUDGET = rep;
var rows = document.getElementById('budgetRows');
var total = document.getElementById('budgetTotal');
if (!rows || !total) return;
var scoped = applyBudgetMode(rep);
var active = scoped.agents.filter(function(a) { return a.kind && a.tokens > 0; });
updateBudgetButtons();
if (!active.length) {
rows.innerHTML = '<div class="budget-empty">no real agent token data in this ' + (budgetMode === 'session' ? 'session' : 'total range') + '</div>';
total.textContent = '';
lastDonutSignature = '';
return;
}
if (!document.getElementById('donutWrap') || !document.getElementById('agentBars')) {
rows.innerHTML = '<div class="donut-grid"><div id="donutWrap"></div></div><div class="agent-bars" id="agentBars"></div>';
}
renderDonut(active, opts.forceDonut);
renderAgentBars(active);
total.innerHTML = (scoped.totals.estimated ? '~' : '') + fmtTokens(scoped.totals.tokens) +
' tok <small>\\u00b7 \\u2248 ' + fmtEur(scoped.totals.costEur) + '</small>';
}
async function refreshBudget() {
try { renderBudget(await getJSON('/budget')); } catch (_) {}
}
document.addEventListener('click', function(e) {
var modeBtn = e.target.closest && e.target.closest('[data-budget-mode]');
if (modeBtn) {
budgetMode = modeBtn.getAttribute('data-budget-mode') || 'session';
localStorage.setItem('agenthub-budget-mode', budgetMode);
lastDonutSignature = '';
if (BUDGET) renderBudget(BUDGET, { forceDonut: true });
return;
}
var tabBtn = e.target.closest && e.target.closest('[data-donut-tab]');
if (tabBtn) {
donutMetric = tabBtn.getAttribute('data-donut-tab') || 'tokens';
localStorage.setItem('agenthub-donut-metric', donutMetric);
if (BUDGET) renderBudget(BUDGET, { forceDonut: true });
return;
}
var reset = e.target.closest && e.target.closest('#budgetReset');
if (reset) {
if (BUDGET) {
writeBaseline(BUDGET);
budgetMode = 'session';
localStorage.setItem('agenthub-budget-mode', budgetMode);
lastDonutSignature = '';
renderBudget(BUDGET, { forceDonut: true });
toast('Token session reset');
}
}
});
`;