mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-20 06:27:53 +02:00
feat: move token details to settings and show dashboard cache hit rate
This commit is contained in:
1 parent
b3d3f48c40
commit
4eda42dc10
12 files changed
+320
-176
No files matched your search
@@ -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`、对话用量接口 → 首页与聊天摘要。
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "База знаний",
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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\}\}/);
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
}));
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user