mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-20 22:41:23 +02:00
198 lines
8.7 KiB
JavaScript
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)];
|
|
}));
|
|
}
|