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

198 lines
8.7 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 };
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() : '';
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 renderTokenUsageAnalysis(res) {
const host = document.getElementById('settings-token-analysis');
if (!host) return;
host.replaceChildren();
if (!res || !res.summary) {
host.textContent = tokenUsageText('dashboard.usageUnavailable', null, '用量统计暂不可用');
return;
}
const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN');
const label = key => tokenUsageText('dashboard.' + key, null, key);
const metrics = document.createElement('div');
metrics.className = 'token-usage-metrics';
tokenUsageMetrics(res.summary).forEach(([key, value]) => {
const card = document.createElement('div');
const title = document.createElement('span');
title.textContent = label(key);
const count = document.createElement('strong');
count.textContent = typeof value === 'string' ? value : exact(value);
card.append(title, count);
metrics.append(card);
});
host.append(metrics);
if (!tokenUsageCount(res.summary.events)) {
const empty = document.createElement('p');
empty.textContent = tokenUsageText('dashboard.noTokenUsageYet', null, '暂无用量');
host.append(empty);
return;
}
// Tables use text nodes: provider model names and conversation IDs are untrusted.
const table = (titleKey, headings, rows) => {
const section = document.createElement('section');
const title = document.createElement('h4');
title.textContent = label(titleKey);
const wrap = document.createElement('div');
wrap.className = 'token-usage-table-wrap';
const grid = document.createElement('table');
const caption = document.createElement('caption');
caption.className = 'sr-only';
caption.textContent = title.textContent;
const head = document.createElement('thead');
const hr = document.createElement('tr');
headings.forEach(key => {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = label(key);
hr.append(th);
});
head.append(hr);
const body = document.createElement('tbody');
rows.forEach(values => {
const tr = document.createElement('tr');
values.forEach(value => {
const td = document.createElement('td');
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 => [row.label || row.key,
tokenUsageCount(res.summary.totalTokens) > 0 ? (tokenUsageCount(row.totalTokens) / tokenUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)]));
const trendTitle = document.createElement('h4');
trendTitle.textContent = label('usageTrend');
const trend = document.createElement('div');
trend.className = 'token-usage-trend';
const days = (res.byDay || []).slice().reverse();
const peak = Math.max(1, ...days.map(row => tokenUsageCount(row.totalTokens)));
days.forEach(row => {
const bar = document.createElement('div');
bar.className = 'token-usage-day';
const count = document.createElement('span');
count.textContent = formatTokenUsageCompact(row.totalTokens);
const fill = document.createElement('div');
fill.className = 'token-usage-day-bar';
fill.style.height = Math.max(1, tokenUsageCount(row.totalTokens) / peak * 90) + 'px';
const date = document.createElement('span');
date.textContent = String(row.key || '').slice(5);
bar.title = row.key + ': ' + exact(row.totalTokens);
bar.append(count, fill, date);
trend.append(bar);
});
host.append(trendTitle, trend);
table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)]));
table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => {
const time = new Date(row.createdAt);
return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—',
row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)];
}));
}