Files
CyberStrikeAI/web/static/js/token-usage.js
T

292 lines
15 KiB
JavaScript

// Token detail data belongs to the settings section; the dashboard keeps a
// separate fixed seven-day summary.
var tokenUsageState = { days: 7, controller: null, requestSequence: 0, result: null, trendMetric: 'totalTokens' };
function tokenUsageText(key, opts, fallback) {
if (typeof window.t === 'function') {
const text = window.t(key, opts);
if (text && text !== key) return text;
}
return fallback == null ? key : fallback;
}
function openDashboardTokenUsage() {
if (window.location.hash === '#settings?section=token-usage') {
switchPage('settings');
switchSettingsSection('token-usage');
} else {
window.location.hash = 'settings?section=token-usage';
}
}
function stopTokenUsageRequests() {
tokenUsageState.requestSequence++;
if (tokenUsageState.controller) tokenUsageState.controller.abort();
tokenUsageState.controller = null;
}
async function refreshTokenUsage() {
const host = document.getElementById('settings-token-analysis');
if (!host) return;
stopTokenUsageRequests();
const sequence = tokenUsageState.requestSequence;
const controller = new AbortController();
tokenUsageState.controller = controller;
host.setAttribute('aria-busy', 'true');
host.textContent = tokenUsageText('common.loading', null, '加载中…');
const select = document.getElementById('settings-token-days');
if (select) select.value = String(tokenUsageState.days);
let url = '/api/usage/tokens?days=' + tokenUsageState.days + '&limit=20';
const projectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() : '';
const scope = document.getElementById('settings-token-scope');
if (scope) scope.textContent = tokenUsageText('tokenUsageUI.' + (projectId ? 'scopeProject' : 'scopeAccessible'), null, projectId ? '当前项目' : '可访问会话');
if (projectId) url += '&project_id=' + encodeURIComponent(projectId);
try {
const response = await apiFetch(url, { signal: controller.signal });
const result = response && response.ok ? await response.json() : null;
if (sequence !== tokenUsageState.requestSequence) return;
tokenUsageState.result = result;
renderTokenUsageAnalysis(result);
} catch (_) {
if (sequence !== tokenUsageState.requestSequence) return;
tokenUsageState.result = null;
renderTokenUsageAnalysis(null);
} finally {
if (sequence === tokenUsageState.requestSequence) {
tokenUsageState.controller = null;
host.removeAttribute('aria-busy');
}
}
}
function changeTokenUsagePeriod(value) {
const days = Number(value);
if (![7, 30, 90].includes(days)) return;
tokenUsageState.days = days;
refreshTokenUsage();
}
function tokenUsageCacheRate(summary) {
const input = tokenUsageCount(summary && summary.promptTokens);
const cached = tokenUsageCount(summary && summary.cachedTokens);
return input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—';
}
function tokenUsageCount(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
}
function tokenUsageMetrics(summary) {
const input = tokenUsageCount(summary.promptTokens);
const cached = tokenUsageCount(summary.cachedTokens);
return [
['usageTotal', summary.totalTokens],
['usageInput', input],
['usageOutput', summary.completionTokens],
['usageCacheRead', cached],
['usageCacheWrite', summary.cacheWriteTokens],
['usageReasoning', summary.reasoningTokens],
['usageCalls', summary.modelCalls],
['usageCacheRate', tokenUsageCacheRate(summary)]
];
}
document.addEventListener('languagechange', function () {
if (tokenUsageState.controller) return;
const page = document.getElementById('page-settings');
const section = document.getElementById('settings-section-token-usage');
if (page && page.classList.contains('active') && section && section.classList.contains('active')) {
renderTokenUsageAnalysis(tokenUsageState.result);
}
});
function tokenUsageNode(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
// Fill only gaps between returned dates. The API's server-local day keys are
// authoritative; do not invent a browser-timezone endpoint for the period.
function tokenUsageTimeline(rows) {
const dated = (rows || []).filter(row => /^\d{4}-\d{2}-\d{2}$/.test(row.key || '')).sort((a, b) => a.key.localeCompare(b.key));
if (!dated.length) return [];
const byDay = new Map(dated.map(row => [row.key, row]));
const start = Date.parse(dated[0].key + 'T00:00:00Z');
const end = Date.parse(dated[dated.length - 1].key + 'T00:00:00Z');
if (!Number.isFinite(start) || !Number.isFinite(end)) return dated;
const days = [];
for (let time = start; time <= end && days.length < 365; time += 86400000) {
const key = new Date(time).toISOString().slice(0, 10);
days.push(byDay.get(key) || { key, events: 0, totalTokens: 0, modelCalls: 0, promptTokens: 0 });
}
return days;
}
function setTokenUsageTrend(metric) {
if (!['totalTokens', 'modelCalls', 'cacheRate'].includes(metric)) return;
tokenUsageState.trendMetric = metric;
renderTokenUsageAnalysis(tokenUsageState.result);
const button = document.querySelector('[data-token-trend="' + metric + '"]');
if (button) button.focus();
}
function renderTokenUsageAnalysis(res) {
const host = document.getElementById('settings-token-analysis');
if (!host) return;
host.replaceChildren();
const label = key => tokenUsageText('dashboard.' + key, null, key);
const ui = key => tokenUsageText('tokenUsageUI.' + key, null, key);
const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN');
if (!res || !res.summary) {
const empty = tokenUsageNode('div', 'token-usage-empty', label('usageUnavailable'));
host.append(empty);
return;
}
const summary = res.summary;
const metrics = new Map(tokenUsageMetrics(summary));
const primary = tokenUsageNode('div', 'token-usage-metrics token-usage-primary');
const secondary = tokenUsageNode('div', 'token-usage-metrics token-usage-secondary');
const primaryKeys = ['usageTotal', 'usageInput', 'usageOutput', 'usageCacheRate'];
[...primaryKeys, 'usageCalls', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning'].forEach(key => {
const value = metrics.get(key);
const card = tokenUsageNode('div', 'token-usage-metric');
card.dataset.metric = key;
card.append(tokenUsageNode('span', '', label(key)), tokenUsageNode('strong', '', typeof value === 'string' ? value : exact(value)));
(primaryKeys.includes(key) ? primary : secondary).append(card);
});
host.append(primary, secondary);
if (!tokenUsageCount(summary.events)) {
host.append(tokenUsageNode('div', 'token-usage-empty', label('noTokenUsageYet')));
return;
}
const chartLayout = tokenUsageNode('div', 'token-usage-charts');
const chart = tokenUsageNode('section', 'token-usage-chart-card');
const chartHead = tokenUsageNode('div', 'token-usage-chart-head');
chartHead.append(tokenUsageNode('h4', '', label('usageTrend')));
const tabs = tokenUsageNode('div', 'token-usage-chart-tabs');
tabs.setAttribute('role', 'group');
tabs.setAttribute('aria-label', ui('trendMetric'));
for (const [metric, key] of [['totalTokens', 'usageTotal'], ['modelCalls', 'usageCalls'], ['cacheRate', 'usageCacheRate']]) {
const button = tokenUsageNode('button', '', label(key));
button.type = 'button';
button.dataset.tokenTrend = metric;
button.setAttribute('aria-pressed', String(tokenUsageState.trendMetric === metric));
button.addEventListener('click', () => setTokenUsageTrend(metric));
tabs.append(button);
}
chartHead.append(tabs);
chart.append(chartHead);
const tooltip = tokenUsageNode('p', 'token-usage-chart-tooltip', ui('chartHint'));
tooltip.setAttribute('aria-live', 'polite');
const days = tokenUsageTimeline(res.byDay);
const metric = tokenUsageState.trendMetric;
const valueOf = row => metric === 'cacheRate' ? (tokenUsageCount(row.promptTokens) ? Math.min(tokenUsageCount(row.cachedTokens), tokenUsageCount(row.promptTokens)) / tokenUsageCount(row.promptTokens) * 100 : 0) : tokenUsageCount(row[metric]);
const peak = metric === 'cacheRate' ? 100 : Math.max(1, ...days.map(valueOf));
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 640 230');
svg.setAttribute('role', 'group');
svg.setAttribute('aria-label', label('usageTrend'));
svg.classList.add('token-usage-trend-svg');
const element = (tag, attrs, text) => {
const node = document.createElementNS(svgNS, tag);
Object.entries(attrs).forEach(([key, value]) => node.setAttribute(key, String(value)));
if (text != null) node.textContent = text;
return node;
};
for (let tick = 0; tick <= 4; tick++) {
const y = 18 + tick * 44;
svg.append(element('line', { x1: 54, x2: 624, y1: y, y2: y, class: 'token-usage-chart-grid' }));
svg.append(element('text', { x: 46, y: y + 4, 'text-anchor': 'end', class: 'token-usage-chart-label' }, metric === 'cacheRate' ? (100 - tick * 25) + '%' : formatTokenUsageCompact(peak * (4 - tick) / 4)));
}
const slot = 570 / Math.max(days.length, 1);
const every = Math.max(1, Math.ceil(days.length / 7));
days.forEach((row, index) => {
const x = 54 + slot * (index + .5);
const height = valueOf(row) / peak * 176;
const detail = row.key + ' · ' + label('usageTotal') + ' ' + exact(row.totalTokens) + ' · ' + label('usageCalls') + ' ' + exact(row.modelCalls) + ' · ' + label('usageCacheRate') + ' ' + tokenUsageCacheRate(row);
const bar = element('rect', { x: x - Math.min(24, slot * .55) / 2, y: 194 - height, width: Math.min(24, slot * .55), height: Math.max(height, 1), rx: 3, class: 'token-usage-chart-bar', tabindex: 0, 'aria-label': detail });
bar.append(element('title', {}, detail));
for (const event of ['mouseenter', 'focus']) bar.addEventListener(event, () => { tooltip.textContent = detail; });
for (const event of ['mouseleave', 'blur']) bar.addEventListener(event, () => { tooltip.textContent = ui('chartHint'); });
svg.append(bar);
if (index % every === 0 || index === days.length - 1) {
svg.append(element('text', { x, y: 218, 'text-anchor': 'middle', class: 'token-usage-chart-label' }, row.key.slice(5)));
}
});
const plot = tokenUsageNode('div', 'token-usage-svg-wrap');
plot.append(svg);
chart.append(plot);
chart.append(tooltip);
if (!days.length) chart.append(tokenUsageNode('p', 'token-usage-empty', ui('noDailyData')));
chartLayout.append(chart);
const cache = tokenUsageNode('section', 'token-usage-chart-card token-usage-cache-card');
cache.append(tokenUsageNode('h4', '', ui('cacheComposition')));
const input = tokenUsageCount(summary.promptTokens);
const cached = Math.min(input, tokenUsageCount(summary.cachedTokens));
const ratio = input ? cached / input * 100 : 0;
const ring = tokenUsageNode('div', 'token-usage-cache-ring');
ring.style.setProperty('--cache-ratio', ratio + '%');
ring.append(tokenUsageNode('strong', '', tokenUsageCacheRate(summary)), tokenUsageNode('span', '', ui('cacheHit')));
cache.append(ring);
for (const [key, value, className] of [['usageCacheRead', cached, 'is-cached'], ['uncachedInput', input - cached, 'is-uncached']]) {
const line = tokenUsageNode('div', 'token-usage-cache-legend ' + className);
line.append(tokenUsageNode('span', '', key === 'uncachedInput' ? ui(key) : label(key)), tokenUsageNode('strong', '', exact(value)));
cache.append(line);
}
cache.append(tokenUsageNode('p', 'token-usage-chart-note', ui('cacheNote')));
chartLayout.append(cache);
host.append(chartLayout);
// Provider labels are untrusted: render all values as text or constructed
// nodes, never as HTML. Full precision is retained in every table.
const table = (titleKey, headings, rows, collapsed = false) => {
const section = tokenUsageNode(collapsed ? 'details' : 'section', 'token-usage-data-card');
const title = tokenUsageNode(collapsed ? 'summary' : 'h4', '', label(titleKey));
const wrap = tokenUsageNode('div', 'token-usage-table-wrap');
const grid = tokenUsageNode('table');
const caption = tokenUsageNode('caption', 'sr-only', label(titleKey));
const head = tokenUsageNode('thead');
const hr = tokenUsageNode('tr');
headings.forEach(key => { const th = tokenUsageNode('th', '', label(key)); th.scope = 'col'; hr.append(th); });
head.append(hr);
const body = tokenUsageNode('tbody');
rows.forEach(values => {
const tr = tokenUsageNode('tr');
values.forEach(value => {
const td = tokenUsageNode('td');
if (value && value.nodeType) td.append(value);
else td.textContent = String(value == null ? '' : value);
tr.append(td);
});
body.append(tr);
});
grid.append(caption, head, body);
wrap.append(grid);
section.append(title, wrap);
host.append(section);
};
const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)];
const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls'];
table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => {
const ratio = tokenUsageCount(summary.totalTokens) ? tokenUsageCount(row.totalTokens) / tokenUsageCount(summary.totalTokens) * 100 : 0;
const share = tokenUsageNode('div', 'token-usage-share');
share.append(tokenUsageNode('span', '', ratio.toFixed(1) + '%'));
const track = tokenUsageNode('span', 'token-usage-share-track');
const fill = tokenUsageNode('span'); fill.style.width = Math.min(100, ratio) + '%'; track.append(fill); share.append(track);
return [row.label || row.key, share, ...counts(row)];
}));
table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)]), true);
table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => {
const time = new Date(row.createdAt);
const status = tokenUsageNode('span', 'token-usage-status', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'));
if (row.reason === 'final' || row.reason === 'partial') status.classList.add('is-' + row.reason);
return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', row.model || '—', status, exact(row.totalTokens), exact(row.modelCalls)];
}));
}