feat: move token details to settings and show dashboard cache hit rate

This commit is contained in:
Sycun committed 2026-10-11 23:51:01 +08:00
1 parent b3d3f48c40
commit 4eda42dc10
12 files changed
+320 -176

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本
## 功能入口与数据流
点击首页「Token 用量」卡片或「查看用量详情」展开面板。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。沿用首页自动刷新和请求取消机制。
点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天调用次数、今日用量与缓存命中率(缓存读取 Token / 输入 Token)。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。
运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。
+4 -1
View File
@@ -47221,9 +47221,12 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
}
/* Provider-reported token analysis, scoped by the dashboard project selector. */
.dashboard-kpi-sub--tokens { row-gap: 2px; }
#dashboard-kpi-token-cache-text { flex-basis: 100%; }
.token-usage-panel { margin-bottom: 20px; padding: 18px 22px; }
.token-usage-panel > summary { cursor: pointer; }
.token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; }
.token-usage-toolbar label { white-space: nowrap; }
.token-usage-toolbar select { padding: 6px 10px; border: 1px solid var(--border-color, #dce2ea); border-radius: 6px; background: var(--bg-secondary, #fff); color: inherit; }
.token-usage-note { opacity: .72; font-size: 12px; line-height: 1.7; margin: 14px 0; }
.token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
+2
View File
@@ -287,6 +287,7 @@
"toolCalls": "Tool invocations",
"successRate": "Tool success rate",
"tokenUsage": "Token usage",
"usageCacheHitRate": "Cache hit {{rate}}",
"tokenUsageSub": "Last {{days}} days: {{calls}} calls · Today {{today}}",
"noTokenUsageYet": "No usage yet",
"clickToViewTasks": "Click to view tasks",
@@ -1969,6 +1970,7 @@
"settings": {
"title": "System settings",
"nav": {
"tokenUsage": "Token usage",
"basic": "Basic",
"hitl": "Human-in-the-loop",
"knowledge": "Knowledge base",
+2
View File
@@ -287,6 +287,7 @@
"toolCalls": "Вызовы инструментов",
"successRate": "Успешность инструментов",
"tokenUsage": "Расход токенов",
"usageCacheHitRate": "Попадания в кэш {{rate}}",
"tokenUsageSub": "За {{days}} дней {{calls}} вызовов · сегодня {{today}}",
"noTokenUsageYet": "Расхода пока нет",
"clickToViewTasks": "Открыть задачи",
@@ -1982,6 +1983,7 @@
"settings": {
"title": "Системные настройки",
"nav": {
"tokenUsage": "Расход токенов",
"basic": "Основные",
"hitl": "Человек в контуре",
"knowledge": "База знаний",
+2
View File
@@ -287,6 +287,7 @@
"toolCalls": "工具调用次数",
"successRate": "工具执行成功率",
"tokenUsage": "Token 用量",
"usageCacheHitRate": "缓存命中 {{rate}}",
"tokenUsageSub": "近 {{days}} 天 {{calls}} 次调用 · 今日 {{today}}",
"noTokenUsageYet": "暂无用量",
"clickToViewTasks": "点击查看任务管理",
@@ -1957,6 +1958,7 @@
"settings": {
"title": "系统设置",
"nav": {
"tokenUsage": "Token 用量",
"basic": "基本设置",
"hitl": "人机协同",
"knowledge": "知识库",
+9 -139
View File
@@ -17,7 +17,6 @@ var DASHBOARD_SEVERITY_STATUS_FILTER_STORAGE_KEY = 'cyberstrike.dashboard.severi
var DASHBOARD_SEVERITY_STATUS_FILTER_VALUES = ['', 'open', 'confirmed', 'fixed', 'ignored', 'false_positive'];
var dashboardState = {
tokenDays: 7,
currentController: null, // 当前正在进行的 fetch 的 AbortController
pollTimer: null, // 自动轮询的 setInterval id
staleTimer: null, // 过期检查的 setInterval id
@@ -73,6 +72,7 @@ async function refreshDashboard() {
setKpiSubText('dashboard-kpi-tools-sub-text', '…');
setKpiSubText('dashboard-kpi-rate-sub-text', '…');
setKpiSubText('dashboard-kpi-token-sub-text', '…');
setKpiSubText('dashboard-kpi-token-cache-text', '…');
hideEl('dashboard-kpi-vuln-critical-badge');
hideEl('dashboard-alert-banner');
setRecentVulnsLoading();
@@ -163,7 +163,7 @@ async function refreshDashboard() {
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null),
fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=' + dashboardState.tokenDays + '&limit=20'))
fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=20'))
]);
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
@@ -441,8 +441,9 @@ async function refreshDashboard() {
setDashboardOverviewPlaceholder('-');
setEl('dashboard-kpi-success-rate', '-');
setEl('dashboard-kpi-tools-calls', '-');
renderDashboardTokenAnalysis(null);
setEl('dashboard-kpi-token-usage', '-');
setKpiSubText('dashboard-kpi-token-sub-text', '-');
setKpiSubText('dashboard-kpi-token-cache-text', '-');
setKpiSubText('dashboard-kpi-tasks-sub-text', '-');
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
@@ -710,13 +711,15 @@ function setKpiRateBadge(id, rate, failedCount) {
}
function renderDashboardTokenUsage(res) {
renderDashboardTokenAnalysis(res);
const summary = res && res.summary ? res.summary : null;
if (!summary) {
setEl('dashboard-kpi-token-usage', '-');
setKpiSubText('dashboard-kpi-token-sub-text', '-');
setKpiSubText('dashboard-kpi-token-cache-text', '-');
return;
}
const cacheRate = tokenUsageCacheRate(summary);
setKpiSubText('dashboard-kpi-token-cache-text', dt('dashboard.usageCacheHitRate', { rate: cacheRate }, '缓存命中 ' + cacheRate));
const total = Number(summary.totalTokens || 0);
const calls = Number(summary.modelCalls || 0);
const today = res && res.today ? Number(res.today.totalTokens || 0) : 0;
@@ -728,10 +731,10 @@ function renderDashboardTokenUsage(res) {
setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total));
setKpiSubText('dashboard-kpi-token-sub-text',
dt('dashboard.tokenUsageSub', {
days: dashboardState.tokenDays,
days: 7,
today: formatTokenUsageCompact(today),
calls: Number.isFinite(calls) ? calls : 0
}, '近 ' + dashboardState.tokenDays + ' 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today)));
}, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today)));
}
function formatTokenUsageCompact(num) {
@@ -2528,136 +2531,3 @@ document.addEventListener('click', function (ev) {
var banner = document.getElementById('dashboard-alert-banner');
if (banner) banner.hidden = true;
});
function openDashboardTokenUsage() {
const panel = document.getElementById('dashboard-token-details');
if (!panel) return;
panel.open = true;
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function changeDashboardTokenPeriod(value) {
const days = Number(value);
if (![7, 30, 90].includes(days)) return;
dashboardState.tokenDays = days;
const host = document.getElementById('dashboard-token-analysis');
if (host) host.textContent = '…';
refreshDashboard();
}
function dashboardUsageCount(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
}
function dashboardUsageMetrics(summary) {
const input = dashboardUsageCount(summary.promptTokens);
const cached = dashboardUsageCount(summary.cachedTokens);
return [
['usageTotal', summary.totalTokens],
['usageInput', input],
['usageOutput', summary.completionTokens],
['usageCacheRead', cached],
['usageCacheWrite', summary.cacheWriteTokens],
['usageReasoning', summary.reasoningTokens],
['usageCalls', summary.modelCalls],
['usageCacheRate', input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—']
];
}
function renderDashboardTokenAnalysis(res) {
const host = document.getElementById('dashboard-token-analysis');
if (!host) return;
host.replaceChildren();
if (!res || !res.summary) {
host.textContent = dt('dashboard.usageUnavailable', null, '用量统计暂不可用');
return;
}
const exact = value => dashboardUsageCount(value).toLocaleString(window.__locale || 'zh-CN');
const label = key => dt('dashboard.' + key, null, key);
const metrics = document.createElement('div');
metrics.className = 'token-usage-metrics';
dashboardUsageMetrics(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 (!dashboardUsageCount(res.summary.events)) {
const empty = document.createElement('p');
empty.textContent = dt('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,
dashboardUsageCount(res.summary.totalTokens) > 0 ? (dashboardUsageCount(row.totalTokens) / dashboardUsageCount(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 => dashboardUsageCount(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, dashboardUsageCount(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)];
}));
}
+13 -4
View File
@@ -1,9 +1,9 @@
// 页面路由管理
let currentPage = null;
/** chat、漏洞管理页在切换时保留当前 hash 上的查询串(如 ?conversation= / ?conversation_id=) */
/** 对话、漏洞管理和设置页切换时保留查询串(会话 ID 或设置分区)。 */
function buildHashForPage(pageId) {
if (pageId !== 'chat' && pageId !== 'vulnerabilities') {
if (pageId !== 'chat' && pageId !== 'vulnerabilities' && pageId !== 'settings') {
return pageId;
}
const full = window.location.hash.slice(1);
@@ -111,7 +111,7 @@ function initRouter() {
let pageId = hashParts[0].replace(/^#?\//, '');
if (pageId === 'c2') pageId = 'c2-listeners';
// 一键更新已并进系统设置页的分区菜单:#system-update 这条老深链落到设置页的那一区。
const settingsSection = pageId === 'system-update' ? 'update' : '';
const settingsSection = pageId === 'system-update' ? 'update' : pageId === 'settings' && new URLSearchParams(hashParts.slice(1).join('?')).get('section') === 'token-usage' ? 'token-usage' : '';
if (settingsSection) pageId = 'settings';
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
@@ -541,6 +541,12 @@ async function initPage(pageId) {
if (updateSection && updateSection.classList.contains('active') && typeof loadUpdateConsole === 'function') {
loadUpdateConsole();
}
// Returning through the regular settings nav reloads a retained usage panel.
// Deep links are loaded by switchSettingsSection after switchPage returns.
const tokenSection = document.getElementById('settings-section-token-usage');
if (tokenSection && tokenSection.classList.contains('active') && window.location.hash !== '#settings?section=token-usage' && typeof refreshTokenUsage === 'function') {
refreshTokenUsage();
}
break;
case 'roles-management':
// 初始化角色管理页面
@@ -622,6 +628,9 @@ async function initPage(pageId) {
if (pageId !== 'settings' && typeof stopUpdatePolling === 'function') {
stopUpdatePolling();
}
if (pageId !== 'settings' && typeof stopTokenUsageRequests === 'function') {
stopTokenUsageRequests();
}
}
// 页面加载完成后初始化路由
@@ -639,7 +648,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (pageId === 'c2') pageId = 'c2-listeners';
// 与首屏解析同一套别名:#system-update 落到系统设置页的「一键更新」分区。
const settingsSection = pageId === 'system-update' ? 'update' : '';
const settingsSection = pageId === 'system-update' ? 'update' : pageId === 'settings' && new URLSearchParams(hashParts.slice(1).join('?')).get('section') === 'token-usage' ? 'token-usage' : '';
if (settingsSection) pageId = 'settings';
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
+12
View File
@@ -661,6 +661,13 @@ function switchSettingsSection(section) {
return;
}
// Unknown hash sections must not hide every settings panel.
const requestedContent = document.getElementById(`settings-section-${section}`);
if (!requestedContent) return;
if (window.location.hash.split('?')[0] === '#settings') {
history.replaceState(null, '', section === 'token-usage' ? '#settings?section=token-usage' : '#settings');
}
// 更新导航项状态
document.querySelectorAll('.settings-nav-item').forEach(item => {
item.classList.remove('active');
@@ -678,6 +685,11 @@ function switchSettingsSection(section) {
if (activeContent) {
activeContent.classList.add('active');
initSettingsCustomSelects(activeContent);
}
if (section === 'token-usage' && typeof refreshTokenUsage === 'function') {
refreshTokenUsage();
} else if (typeof stopTokenUsageRequests === 'function') {
stopTokenUsageRequests();
}
if (section === 'robots' && typeof window.loadVulnerabilityAlertSubscription === 'function') {
window.loadVulnerabilityAlertSubscription();
+50 -12
View File
@@ -2,41 +2,79 @@ const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const source = fs.readFileSync('web/static/js/dashboard.js', 'utf8');
const source = fs.readFileSync('web/static/js/token-usage.js', 'utf8');
const harness = () => {
const context = { document: {}, dashboardState: { tokenDays: 7 }, refreshDashboard() {} };
const context = { document: { addEventListener() {} }, window: {}, AbortController };
vm.createContext(context);
vm.runInContext(source.slice(source.indexOf('function openDashboardTokenUsage()')), context);
vm.runInContext(source, context);
return context;
};
test('cache and reasoning details do not inflate total or double-count input', () => {
const ctx = harness();
const metrics = ctx.dashboardUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 });
const metrics = ctx.tokenUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 });
const values = Object.fromEntries(metrics);
assert.equal(values.usageTotal, 120);
assert.equal(values.usageCacheWrite, 30);
assert.equal(values.usageCacheRate, '40.0%');
assert.equal(Object.fromEntries(ctx.dashboardUsageMetrics({})).usageCacheRate, '—');
assert.equal(ctx.dashboardUsageCount(Infinity), 0);
assert.equal(ctx.dashboardUsageCount(-12), 0);
assert.equal(Object.fromEntries(ctx.tokenUsageMetrics({})).usageCacheRate, '—');
assert.equal(ctx.tokenUsageCount(Infinity), 0);
assert.equal(ctx.tokenUsageCount(-12), 0);
});
test('period selection refreshes once and rejects arbitrary periods', () => {
const ctx = harness();
let calls = 0;
ctx.refreshDashboard = () => { calls++; };
ctx.refreshTokenUsage = () => { calls++; };
ctx.document.getElementById = () => ({ textContent: '' });
ctx.changeDashboardTokenPeriod('30');
ctx.changeDashboardTokenPeriod('999');
assert.equal(ctx.dashboardState.tokenDays, 30);
ctx.changeTokenUsagePeriod('30');
ctx.changeTokenUsagePeriod('999');
assert.equal(ctx.tokenUsageState.days, 30);
assert.equal(calls, 1);
});
test('cache hit ratio uses input tokens, clamps invalid detail and handles missing input', () => {
const ctx = harness();
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 1000, cachedTokens: 930, totalTokens: 1100 }), '93.0%');
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 1000, cachedTokens: 0 }), '0.0%');
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 10, cachedTokens: 30 }), '100.0%');
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 0, cachedTokens: 30 }), '—');
assert.equal(ctx.tokenUsageCacheRate(null), '—');
});
test('detail requests stay project scoped and discard late results after a period change', async () => {
const ctx = harness();
const host = { setAttribute() {}, removeAttribute() {}, textContent: '' };
ctx.document.getElementById = id => id === 'settings-token-analysis' ? host : null;
ctx.getActiveProjectId = () => 'project / sample';
const requests = [];
ctx.apiFetch = (url, options) => new Promise(resolve => requests.push({ url, options, resolve }));
const rendered = [];
ctx.renderTokenUsageAnalysis = value => rendered.push(value);
const first = ctx.refreshTokenUsage();
ctx.tokenUsageState.days = 30;
const second = ctx.refreshTokenUsage();
assert.ok(requests[0].options.signal.aborted);
assert.match(requests[1].url, /days=30.*project_id=project%20%2F%20sample/);
requests[1].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 30 } }) });
await second;
requests[0].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 7 } }) });
await first;
assert.equal(rendered.length, 1);
assert.equal(rendered[0].summary.totalTokens, 30);
const pending = ctx.refreshTokenUsage();
ctx.stopTokenUsageRequests();
requests[2].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 90 } }) });
await pending;
assert.equal(rendered.length, 1, 'leaving the section must invalidate outstanding results');
});
test('all supported languages expose usage labels and interpolation placeholders', () => {
for (const locale of ['zh-CN', 'en-US', 'ru-RU']) {
const data = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8'));
for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend']) assert.ok(data.dashboard[key]);
for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]);
assert.ok(data.settings.nav.tokenUsage);
assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/);
assert.match(data.dashboard.tokenUsageSub, /\{\{days\}\}/);
assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/);
}
+197
View File
@@ -0,0 +1,197 @@
// 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)];
}));
}
+1 -1
View File
@@ -435,7 +435,7 @@ test('the console lives inside the settings page as its own section', () => {
// Both hash entry points carry the alias, and the retired page id is gone from the whitelists:
// #system-update has to land on 系统设置 with its update section selected.
const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : '';/g)];
const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : /g)];
assert.equal(aliases.length, 2, 'both hash parsers must normalize #system-update into the settings section');
const selects = [...router.matchAll(/if \(settingsSection && typeof switchSettingsSection === 'function'\) \{[\s\S]{0,80}?switchSettingsSection\(settingsSection\);/g)];
assert.equal(selects.length, 2, 'both hash paths must open the update section after switching the page');
+27 -18
View File
@@ -39,14 +39,14 @@
}
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20261010-layout-overflow-1">
<link rel="stylesheet" href="/static/css/style.css?v=20261011-token-settings">
<link rel="stylesheet" href="/static/css/tool-guard.css?v=20260907-7">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<!-- 移动端样式层:全部规则限定在 767.98px 以下,桌面端零影响 -->
<link rel="stylesheet" href="/static/css/mobile.css?v=20261007-glass-1">
<script src="/static/js/router.js?v=20261009-retest-1"></script>
<script src="/static/js/router.js?v=20261011-token-settings"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -562,24 +562,12 @@
<span class="dashboard-kpi-icon dashboard-kpi-icon-tokens" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/><path d="M8 3 6 21"/><path d="m18 3-2 18"/></svg></span>
</div>
<div class="dashboard-kpi-value" id="dashboard-kpi-token-usage">-</div>
<div class="dashboard-kpi-sub">
<div class="dashboard-kpi-sub dashboard-kpi-sub--tokens">
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-token-sub-text">-</span>
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-token-cache-text">-</span>
</div>
</div>
</div>
<details class="dashboard-section token-usage-panel" id="dashboard-token-details">
<summary class="dashboard-section-title" data-i18n="dashboard.usageView">查看用量详情</summary>
<div class="token-usage-toolbar">
<label for="dashboard-token-days" data-i18n="dashboard.usagePeriod">统计周期</label>
<select id="dashboard-token-days" onchange="changeDashboardTokenPeriod(this.value)">
<option value="7" data-i18n="dashboard.usage7Days">近 7 天</option>
<option value="30" data-i18n="dashboard.usage30Days">近 30 天</option>
<option value="90" data-i18n="dashboard.usage90Days">近 90 天</option>
</select>
</div>
<p class="token-usage-note" data-i18n="dashboard.usageScope">统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。</p>
<div id="dashboard-token-analysis" aria-live="polite"></div>
</details>
<!-- 两列主内容区 -->
<div class="dashboard-grid">
<div class="dashboard-main">
@@ -3615,6 +3603,9 @@
<div class="settings-nav-item active" data-section="basic" onclick="switchSettingsSection('basic')">
<span data-i18n="settings.nav.basic">基本设置</span>
</div>
<div class="settings-nav-item" data-section="token-usage" role="button" tabindex="0" onclick="switchSettingsSection('token-usage')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchSettingsSection('token-usage'); }">
<span data-i18n="settings.nav.tokenUsage">Token 用量</span>
</div>
<div class="settings-nav-item" data-section="hitl" onclick="switchSettingsSection('hitl')">
<span data-i18n="settings.nav.hitl">人机协同</span>
</div>
@@ -3650,6 +3641,23 @@
<!-- 右侧内容区域 -->
<div class="settings-content">
<div id="settings-section-token-usage" class="settings-section-content token-usage-panel">
<div class="settings-section-header token-usage-header">
<h3 data-i18n="settings.nav.tokenUsage">Token 用量</h3>
<button type="button" class="btn-secondary" onclick="refreshTokenUsage()" data-i18n="common.refresh">刷新</button>
</div>
<div class="token-usage-toolbar">
<label for="settings-token-days" data-i18n="dashboard.usagePeriod">统计周期</label>
<select id="settings-token-days" onchange="changeTokenUsagePeriod(this.value)">
<option value="7" data-i18n="dashboard.usage7Days">近 7 天</option>
<option value="30" data-i18n="dashboard.usage30Days">近 30 天</option>
<option value="90" data-i18n="dashboard.usage90Days">近 90 天</option>
</select>
</div>
<p class="token-usage-note" data-i18n="dashboard.usageScope">统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。</p>
<div id="settings-token-analysis" aria-live="polite"></div>
</div>
<!-- 基本设置 -->
<div id="settings-section-basic" class="settings-section-content active">
<div class="settings-section-header">
@@ -7009,14 +7017,15 @@
<script src="/static/js/info-collect.js?v=20260717-1"></script>
<script src="/static/js/assets.js?v=20260717-7"></script>
<script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js?v=20261011-token-usage"></script>
<script src="/static/js/dashboard.js?v=20261011-token-settings"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20261011-token-usage"></script>
<script src="/static/js/chat.js?v=20261011-token-usage"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/tool-guard.js?v=20261010-conflict"></script>
<script src="/static/js/settings.js?v=20260717-1"></script>
<script src="/static/js/token-usage.js?v=20261011-token-settings"></script>
<script src="/static/js/settings.js?v=20261011-token-settings"></script>
<script src="/static/js/audit-datetime-picker.js"></script>
<script src="/static/js/audit.js"></script>
<script src="/static/js/storage.js"></script>