mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-20 06:27:53 +02:00
feat: add detailed token usage monitoring and cache write metrics
This commit is contained in:
1 parent
deda642bd6
commit
b3d3f48c40
26 files changed
+666
-45
No files matched your search
@@ -47219,3 +47219,25 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
|
||||
.update-adopt-confirm-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Provider-reported token analysis, scoped by the dashboard project selector. */
|
||||
.token-usage-panel { margin-bottom: 20px; padding: 18px 22px; }
|
||||
.token-usage-panel > summary { cursor: pointer; }
|
||||
.token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; }
|
||||
.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; }
|
||||
.token-usage-metrics > div { padding: 12px; border: 1px solid var(--border-color, #dce2ea); border-radius: 8px; min-width: 0; }
|
||||
.token-usage-metrics span { display: block; font-size: 12px; opacity: .7; }
|
||||
.token-usage-metrics strong { display: block; margin-top: 6px; font-size: 20px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.token-usage-table-wrap { overflow-x: auto; }
|
||||
.token-usage-table-wrap table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.token-usage-table-wrap th, .token-usage-table-wrap td { padding: 10px; text-align: right; border-bottom: 1px solid var(--border-color, #dce2ea); white-space: nowrap; }
|
||||
.token-usage-table-wrap th:first-child, .token-usage-table-wrap td:first-child { text-align: left; }
|
||||
.token-usage-table-wrap td:first-child { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
||||
@media (max-width: 640px) { .token-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .token-usage-panel { padding: 14px; } }
|
||||
.token-usage-trend { display: flex; align-items: flex-end; gap: 8px; overflow-x: auto; padding: 12px 0; }
|
||||
.token-usage-day { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; flex: 1 0 38px; height: 128px; gap: 6px; font-size: 11px; }
|
||||
.token-usage-day-bar { width: 70%; max-width: 48px; background: var(--accent-color, #3b82f6); border-radius: 4px 4px 0 0; }
|
||||
|
||||
.token-usage-panel h4 { margin: 20px 0 8px; }
|
||||
@@ -287,7 +287,7 @@
|
||||
"toolCalls": "Tool invocations",
|
||||
"successRate": "Tool success rate",
|
||||
"tokenUsage": "Token usage",
|
||||
"tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}",
|
||||
"tokenUsageSub": "Last {{days}} days: {{calls}} calls · Today {{today}}",
|
||||
"noTokenUsageYet": "No usage yet",
|
||||
"clickToViewTasks": "Click to view tasks",
|
||||
"clickToViewChat": "Click to view conversations",
|
||||
@@ -433,7 +433,33 @@
|
||||
"mcpPartialDown_other": "{{count}} stopped",
|
||||
"mcpAllDown": "All stopped",
|
||||
"noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat",
|
||||
"startScanBtn": "Go to chat to scan"
|
||||
"startScanBtn": "Go to chat to scan",
|
||||
"usageView": "View token usage",
|
||||
"usagePeriod": "Period",
|
||||
"usage7Days": "Last 7 days",
|
||||
"usage30Days": "Last 30 days",
|
||||
"usage90Days": "Last 90 days",
|
||||
"usageTotal": "Total tokens",
|
||||
"usageInput": "Input tokens",
|
||||
"usageOutput": "Output tokens",
|
||||
"usageCacheRead": "Cache read",
|
||||
"usageCacheWrite": "Cache write",
|
||||
"usageReasoning": "Reasoning tokens",
|
||||
"usageCalls": "Model calls",
|
||||
"usageCacheRate": "Cache read / input",
|
||||
"usageByModel": "Usage by model (up to 20)",
|
||||
"usageByDay": "Daily usage",
|
||||
"usageRecent": "Recent runs (up to 20)",
|
||||
"usageDate": "Date",
|
||||
"usageTime": "Time",
|
||||
"usageModel": "Model",
|
||||
"usageStatus": "End reason",
|
||||
"usageUnavailable": "Token usage is unavailable",
|
||||
"usageScope": "Provider-reported usage from recorded Agent runs, including sub-agents. Cache reads and writes are part of input; reasoning is part of output. These details are not added to totals again. Unreported or uncollected usage is excluded. This is not a provider bill. Models are grouped by run configuration; runs with model failover may contain mixed usage.",
|
||||
"usageShare": "Token share",
|
||||
"usageTrend": "Daily token trend",
|
||||
"usageFinal": "Completed",
|
||||
"usagePartial": "Partial usage"
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projects",
|
||||
@@ -795,7 +821,7 @@
|
||||
"turnDurationHours": "{{hours}} hr {{minutes}} min",
|
||||
"turnProcessAria": "{{state}}; expand or collapse execution details",
|
||||
"turnTokenUsageLabel": "{{tokens}} tokens",
|
||||
"turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls)",
|
||||
"turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls) · Cache write {{cacheWrite}}",
|
||||
"turnNumber": "Turn {{number}}",
|
||||
"turnPending": "Processing…",
|
||||
"expandDetailLazyHint": "Expand details (loads iteration details on click)",
|
||||
@@ -979,7 +1005,8 @@
|
||||
"hitlTimeoutUnlimited": "No limit",
|
||||
"hitlTimeoutHint": "Unanswered requests are rejected automatically when time expires; approval cards show the countdown.",
|
||||
"hitlStatusOff": "Human-in-the-loop: Off",
|
||||
"rolePanelTitle": "Select role"
|
||||
"rolePanelTitle": "Select role",
|
||||
"einoUsageCacheWriteTokens": "Cache write tokens"
|
||||
},
|
||||
"toolGuard": {
|
||||
"invalidTestResponse": "The server returned an invalid test result",
|
||||
@@ -3722,7 +3749,6 @@
|
||||
"hitlPolicy": "Tool approval",
|
||||
"hitlHint": "Applies to all tasks in this queue, including tasks added later. Uses the global whitelist, audit strategy and timeout.",
|
||||
"hitlSubtaskHint": "New tasks use the queue’s tool approval setting. Change it in queue details.",
|
||||
|
||||
"title": "New task",
|
||||
"queueTitle": "Queue title",
|
||||
"queueTitlePlaceholder": "Enter queue title (optional, for identification and filtering)",
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
"toolCalls": "Вызовы инструментов",
|
||||
"successRate": "Успешность инструментов",
|
||||
"tokenUsage": "Расход токенов",
|
||||
"tokenUsageSub": "За 7 дней {{calls}} вызовов · сегодня {{today}}",
|
||||
"tokenUsageSub": "За {{days}} дней {{calls}} вызовов · сегодня {{today}}",
|
||||
"noTokenUsageYet": "Расхода пока нет",
|
||||
"clickToViewTasks": "Открыть задачи",
|
||||
"clickToViewChat": "Открыть диалоги",
|
||||
@@ -445,7 +445,33 @@
|
||||
"recoApproveHitl": "Одобрить запросы HITL: {{count}}",
|
||||
"recoRestartMcp": "Проверить не запущенные внешние MCP: {{count}}",
|
||||
"recoCheckMonitor": "Разобрать неудачные вызовы инструментов: {{count}}",
|
||||
"mcpPartialDown": "Не запущено: {{count}}"
|
||||
"mcpPartialDown": "Не запущено: {{count}}",
|
||||
"usageView": "Расход токенов",
|
||||
"usagePeriod": "Период",
|
||||
"usage7Days": "За 7 дней",
|
||||
"usage30Days": "За 30 дней",
|
||||
"usage90Days": "За 90 дней",
|
||||
"usageTotal": "Всего токенов",
|
||||
"usageInput": "Входные токены",
|
||||
"usageOutput": "Выходные токены",
|
||||
"usageCacheRead": "Чтение кэша",
|
||||
"usageCacheWrite": "Запись кэша",
|
||||
"usageReasoning": "Токены рассуждения",
|
||||
"usageCalls": "Вызовы модели",
|
||||
"usageCacheRate": "Чтение кэша / ввод",
|
||||
"usageByModel": "По моделям (до 20)",
|
||||
"usageByDay": "По дням",
|
||||
"usageRecent": "Последние запуски (до 20)",
|
||||
"usageDate": "Дата",
|
||||
"usageTime": "Время",
|
||||
"usageModel": "Модель",
|
||||
"usageStatus": "Причина завершения",
|
||||
"usageUnavailable": "Статистика недоступна",
|
||||
"usageShare": "Доля токенов",
|
||||
"usageTrend": "Расход по дням",
|
||||
"usageScope": "Данные провайдера из сохранённых запусков Agent, включая субагентов. Чтение и запись кэша входят во ввод, рассуждение — в вывод; они не прибавляются к сумме повторно. Неполученные данные исключены. Это не счёт провайдера. Группировка по модели в конфигурации запуска; при переключении модели расход может быть смешан.",
|
||||
"usageFinal": "Завершено",
|
||||
"usagePartial": "Частичный расход"
|
||||
},
|
||||
"projects": {
|
||||
"title": "Проекты",
|
||||
@@ -807,7 +833,7 @@
|
||||
"turnDurationHours": "{{hours}} ч {{minutes}} мин",
|
||||
"turnProcessAria": "{{state}}; развернуть или свернуть подробности выполнения",
|
||||
"turnTokenUsageLabel": "{{tokens}} токенов",
|
||||
"turnTokenUsageTitle": "Расход токенов: {{total}} (ввод {{prompt}}, вывод {{completion}}, кэш {{cached}}, рассуждение {{reasoning}}, вызовов {{calls}})",
|
||||
"turnTokenUsageTitle": "Расход токенов: {{total}} (ввод {{prompt}}, вывод {{completion}}, кэш {{cached}}, рассуждение {{reasoning}}, вызовов {{calls}}) · Запись кэша {{cacheWrite}}",
|
||||
"turnNumber": "Ход {{number}}",
|
||||
"turnPending": "Обработка…",
|
||||
"expandDetailLazyHint": "Развернуть подробности (итерации загружаются по щелчку)",
|
||||
@@ -991,7 +1017,8 @@
|
||||
"hitlTimeoutUnlimited": "Без ограничения",
|
||||
"hitlTimeoutHint": "Запросы без ответа автоматически отклоняются по истечении времени; на карточках одобрения виден отсчёт.",
|
||||
"hitlStatusOff": "Контроль человеком: выкл.",
|
||||
"rolePanelTitle": "Выбор роли"
|
||||
"rolePanelTitle": "Выбор роли",
|
||||
"einoUsageCacheWriteTokens": "Токены записи кэша"
|
||||
},
|
||||
"toolGuard": {
|
||||
"invalidTestResponse": "Сервер вернул некорректный результат проверки",
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
"toolCalls": "工具调用次数",
|
||||
"successRate": "工具执行成功率",
|
||||
"tokenUsage": "Token 用量",
|
||||
"tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}",
|
||||
"tokenUsageSub": "近 {{days}} 天 {{calls}} 次调用 · 今日 {{today}}",
|
||||
"noTokenUsageYet": "暂无用量",
|
||||
"clickToViewTasks": "点击查看任务管理",
|
||||
"clickToViewChat": "点击查看对话",
|
||||
@@ -421,7 +421,33 @@
|
||||
"mcpPartialDown": "{{count}} 个未运行",
|
||||
"mcpAllDown": "全部未运行",
|
||||
"noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里",
|
||||
"startScanBtn": "前往对话发起扫描"
|
||||
"startScanBtn": "前往对话发起扫描",
|
||||
"usageView": "查看用量详情",
|
||||
"usagePeriod": "统计周期",
|
||||
"usage7Days": "近 7 天",
|
||||
"usage30Days": "近 30 天",
|
||||
"usage90Days": "近 90 天",
|
||||
"usageTotal": "总 Token",
|
||||
"usageInput": "输入 Token",
|
||||
"usageOutput": "输出 Token",
|
||||
"usageCacheRead": "缓存读取",
|
||||
"usageCacheWrite": "缓存写入",
|
||||
"usageReasoning": "推理 Token",
|
||||
"usageCalls": "模型调用",
|
||||
"usageCacheRate": "缓存读取占输入",
|
||||
"usageByModel": "模型用量(最多 20 项)",
|
||||
"usageByDay": "每日用量",
|
||||
"usageRecent": "最近运行(最多 20 项)",
|
||||
"usageDate": "日期",
|
||||
"usageTime": "时间",
|
||||
"usageModel": "模型",
|
||||
"usageStatus": "结束原因",
|
||||
"usageUnavailable": "用量统计暂不可用",
|
||||
"usageScope": "统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。 模型按运行配置归类,切换模型的运行可能混合用量。",
|
||||
"usageShare": "用量占比",
|
||||
"usageTrend": "每日用量趋势",
|
||||
"usageFinal": "已完成",
|
||||
"usagePartial": "部分用量"
|
||||
},
|
||||
"projects": {
|
||||
"title": "项目管理",
|
||||
@@ -783,7 +809,7 @@
|
||||
"turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟",
|
||||
"turnProcessAria": "{{state}},展开或收起执行过程",
|
||||
"turnTokenUsageLabel": "{{tokens}} tokens",
|
||||
"turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次)",
|
||||
"turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次) · 缓存写入 {{cacheWrite}}",
|
||||
"turnNumber": "第 {{number}} 轮",
|
||||
"turnPending": "正在处理…",
|
||||
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
|
||||
@@ -967,7 +993,8 @@
|
||||
"hitlTimeoutUnlimited": "不限制",
|
||||
"hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。",
|
||||
"hitlStatusOff": "人机协同:关闭",
|
||||
"rolePanelTitle": "选择角色"
|
||||
"rolePanelTitle": "选择角色",
|
||||
"einoUsageCacheWriteTokens": "缓存写入 tokens"
|
||||
},
|
||||
"toolGuard": {
|
||||
"invalidTestResponse": "服务端返回的测试结果格式不正确",
|
||||
@@ -3710,7 +3737,6 @@
|
||||
"hitlPolicy": "工具审批",
|
||||
"hitlHint": "队列内所有任务(含后续添加的任务)使用此设置;白名单、审计策略和等待时限沿用全局配置。",
|
||||
"hitlSubtaskHint": "新增任务沿用队列的工具审批设置,可在队列详情中修改。",
|
||||
|
||||
"title": "新建任务",
|
||||
"queueTitle": "任务标题",
|
||||
"queueTitlePlaceholder": "请输入任务标题(可选,用于标识和筛选)",
|
||||
|
||||
@@ -341,7 +341,7 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
|
||||
|
||||
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
|
||||
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
|
||||
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260907-blocked-1');
|
||||
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20261011-token-usage');
|
||||
|
||||
assert.notEqual(scrollIndex, -1);
|
||||
assert.notEqual(monitorIndex, -1);
|
||||
|
||||
@@ -4945,6 +4945,7 @@ function normalizeAssistantTurnTokenUsage(data) {
|
||||
completionTokens: assistantTurnUsageNumber(source.completionTokens),
|
||||
totalTokens: assistantTurnUsageNumber(source.totalTokens),
|
||||
cachedTokens: assistantTurnUsageNumber(source.cachedTokens),
|
||||
cacheWriteTokens: assistantTurnUsageNumber(source.cacheWriteTokens),
|
||||
reasoningTokens: assistantTurnUsageNumber(source.reasoningTokens),
|
||||
model: source.model != null ? String(source.model).trim() : ''
|
||||
};
|
||||
@@ -4962,6 +4963,7 @@ function mergeAssistantTurnTokenUsage(target, usage) {
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
model: ''
|
||||
};
|
||||
@@ -4970,6 +4972,7 @@ function mergeAssistantTurnTokenUsage(target, usage) {
|
||||
out.completionTokens += usage.completionTokens || 0;
|
||||
out.totalTokens += usage.totalTokens || 0;
|
||||
out.cachedTokens += usage.cachedTokens || 0;
|
||||
out.cacheWriteTokens += usage.cacheWriteTokens || 0;
|
||||
out.reasoningTokens += usage.reasoningTokens || 0;
|
||||
if (!out.model && usage.model) out.model = usage.model;
|
||||
return out.totalTokens > 0 ? out : null;
|
||||
@@ -4998,6 +5001,7 @@ function setAssistantTurnTokenUsage(messageElementOrId, usage) {
|
||||
delete messageElement.dataset.turnCompletionTokens;
|
||||
delete messageElement.dataset.turnTotalTokens;
|
||||
delete messageElement.dataset.turnCachedTokens;
|
||||
delete messageElement.dataset.turnCacheWriteTokens;
|
||||
delete messageElement.dataset.turnReasoningTokens;
|
||||
delete messageElement.dataset.turnModel;
|
||||
syncAssistantTurnSummary(messageElement);
|
||||
@@ -5008,6 +5012,7 @@ function setAssistantTurnTokenUsage(messageElementOrId, usage) {
|
||||
messageElement.dataset.turnCompletionTokens = String(normalized.completionTokens || 0);
|
||||
messageElement.dataset.turnTotalTokens = String(normalized.totalTokens || 0);
|
||||
messageElement.dataset.turnCachedTokens = String(normalized.cachedTokens || 0);
|
||||
messageElement.dataset.turnCacheWriteTokens = String(normalized.cacheWriteTokens || 0);
|
||||
messageElement.dataset.turnReasoningTokens = String(normalized.reasoningTokens || 0);
|
||||
if (normalized.model) {
|
||||
messageElement.dataset.turnModel = normalized.model;
|
||||
@@ -5025,6 +5030,7 @@ function getAssistantTurnTokenUsage(messageElement) {
|
||||
completionTokens: messageElement.dataset.turnCompletionTokens,
|
||||
totalTokens: messageElement.dataset.turnTotalTokens,
|
||||
cachedTokens: messageElement.dataset.turnCachedTokens,
|
||||
cacheWriteTokens: messageElement.dataset.turnCacheWriteTokens,
|
||||
reasoningTokens: messageElement.dataset.turnReasoningTokens,
|
||||
model: messageElement.dataset.turnModel
|
||||
});
|
||||
@@ -5051,6 +5057,7 @@ function formatAssistantTurnTokenUsageTitle(usage) {
|
||||
prompt: formatAssistantTurnTokenCount(safeUsage.promptTokens),
|
||||
completion: formatAssistantTurnTokenCount(safeUsage.completionTokens),
|
||||
cached: formatAssistantTurnTokenCount(safeUsage.cachedTokens),
|
||||
cacheWrite: formatAssistantTurnTokenCount(safeUsage.cacheWriteTokens),
|
||||
reasoning: formatAssistantTurnTokenCount(safeUsage.reasoningTokens),
|
||||
calls: formatAssistantTurnTokenCount(safeUsage.modelCalls),
|
||||
model: safeUsage.model || ''
|
||||
|
||||
+139
-2
@@ -17,6 +17,7 @@ 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
|
||||
@@ -162,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=7&limit=5'))
|
||||
fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=' + dashboardState.tokenDays + '&limit=20'))
|
||||
]);
|
||||
|
||||
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
|
||||
@@ -440,6 +441,7 @@ async function refreshDashboard() {
|
||||
setDashboardOverviewPlaceholder('-');
|
||||
setEl('dashboard-kpi-success-rate', '-');
|
||||
setEl('dashboard-kpi-tools-calls', '-');
|
||||
renderDashboardTokenAnalysis(null);
|
||||
setEl('dashboard-kpi-token-usage', '-');
|
||||
setKpiSubText('dashboard-kpi-tasks-sub-text', '-');
|
||||
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
|
||||
@@ -708,6 +710,7 @@ 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', '-');
|
||||
@@ -725,9 +728,10 @@ function renderDashboardTokenUsage(res) {
|
||||
setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total));
|
||||
setKpiSubText('dashboard-kpi-token-sub-text',
|
||||
dt('dashboard.tokenUsageSub', {
|
||||
days: dashboardState.tokenDays,
|
||||
today: formatTokenUsageCompact(today),
|
||||
calls: Number.isFinite(calls) ? calls : 0
|
||||
}, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today)));
|
||||
}, '近 ' + dashboardState.tokenDays + ' 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today)));
|
||||
}
|
||||
|
||||
function formatTokenUsageCompact(num) {
|
||||
@@ -2524,3 +2528,136 @@ 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)];
|
||||
}));
|
||||
}
|
||||
@@ -107,7 +107,7 @@ test('Agent 审查不进入人工审批弹窗、倒计时和项目计数', () =>
|
||||
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
|
||||
assert.match(handler, /CreatePendingInterrupt\([\s\S]{0,260}reviewer string/);
|
||||
assert.match(handler, /reviewer != "audit_agent"[\s\S]{0,120}m\.pending\[id\] = p/);
|
||||
assert.match(logsHandler, /ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'/);
|
||||
assert.match(fs.readFileSync('internal/database/schemas/application.sql', 'utf8'), /reviewer TEXT NOT NULL DEFAULT 'human'/);
|
||||
assert.match(logsHandler, /status = 'pending' AND COALESCE\(reviewer,'human'\) = 'human'/);
|
||||
assert.match(monitor, /function isAgentReviewedHitl\(data\)/);
|
||||
assert.match(monitor, /if \(!data\.resolved && !isAgentReviewedHitl\(data\)\)/);
|
||||
@@ -293,7 +293,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
|
||||
assert.match(chat, /let loadConversationAbortController = null/);
|
||||
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
|
||||
assert.match(chat, /signal: conversationLoadController\.signal/);
|
||||
assert.match(template, /monitor\.js\?v=20260907-blocked-1/);
|
||||
assert.match(template, /monitor\.js\?v=20261011-token-usage/);
|
||||
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
|
||||
assert.match(template, /chat\.js\?v=[A-Za-z0-9._-]+/);
|
||||
assert.match(template, /style\.css\?v=[A-Za-z0-9._-]+/);
|
||||
|
||||
@@ -2889,6 +2889,9 @@ function formatEinoUsageSummaryMessage(data) {
|
||||
if (Number(d.cachedTokens || 0) > 0) {
|
||||
rows.push([label('chat.einoUsageCachedTokens', '缓存 tokens'), d.cachedTokens]);
|
||||
}
|
||||
if (Number(d.cacheWriteTokens || 0) > 0) {
|
||||
rows.push([label('chat.einoUsageCacheWriteTokens', '缓存写入 tokens'), d.cacheWriteTokens]);
|
||||
}
|
||||
if (Number(d.reasoningTokens || 0) > 0) {
|
||||
rows.push([label('chat.einoUsageReasoningTokens', '推理 tokens'), d.reasoningTokens]);
|
||||
}
|
||||
@@ -6734,7 +6737,7 @@ function addTimelineItem(timeline, type, options) {
|
||||
}
|
||||
if (type === 'eino_usage_summary' && options.data) {
|
||||
const d = options.data;
|
||||
['modelCalls', 'promptTokens', 'completionTokens', 'totalTokens', 'cachedTokens', 'reasoningTokens'].forEach(function (key) {
|
||||
['modelCalls', 'promptTokens', 'completionTokens', 'totalTokens', 'cachedTokens', 'cacheWriteTokens', 'reasoningTokens'].forEach(function (key) {
|
||||
if (d[key] != null) {
|
||||
item.dataset[key] = String(d[key]);
|
||||
}
|
||||
@@ -9651,6 +9654,7 @@ function refreshProgressAndTimelineI18n() {
|
||||
completionTokens: item.dataset.completionTokens,
|
||||
totalTokens: item.dataset.totalTokens,
|
||||
cachedTokens: item.dataset.cachedTokens,
|
||||
cacheWriteTokens: item.dataset.cacheWriteTokens,
|
||||
reasoningTokens: item.dataset.reasoningTokens
|
||||
};
|
||||
titleSpan.textContent = formatEinoUsageSummaryTitle(usageData);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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 harness = () => {
|
||||
const context = { document: {}, dashboardState: { tokenDays: 7 }, refreshDashboard() {} };
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source.slice(source.indexOf('function openDashboardTokenUsage()')), 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 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);
|
||||
});
|
||||
|
||||
test('period selection refreshes once and rejects arbitrary periods', () => {
|
||||
const ctx = harness();
|
||||
let calls = 0;
|
||||
ctx.refreshDashboard = () => { calls++; };
|
||||
ctx.document.getElementById = () => ({ textContent: '' });
|
||||
ctx.changeDashboardTokenPeriod('30');
|
||||
ctx.changeDashboardTokenPeriod('999');
|
||||
assert.equal(ctx.dashboardState.tokenDays, 30);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
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]);
|
||||
assert.match(data.dashboard.tokenUsageSub, /\{\{days\}\}/);
|
||||
assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/);
|
||||
}
|
||||
});
|
||||
@@ -117,12 +117,14 @@ test('助手轮次摘要会显示持久化 token 用量', () => {
|
||||
promptTokens: 1200,
|
||||
completionTokens: 34,
|
||||
cachedTokens: 200,
|
||||
cacheWriteTokens: 100,
|
||||
reasoningTokens: 12,
|
||||
modelCalls: 1,
|
||||
model: 'deepseek-v3',
|
||||
});
|
||||
|
||||
assert.equal(message.dataset.turnTotalTokens, '1234');
|
||||
assert.equal(message.dataset.turnCacheWriteTokens, '100');
|
||||
assert.match(message.label.innerHTML, /1\.2K tokens/);
|
||||
assert.match(message.label.innerHTML, /turn-process-token-chip/);
|
||||
});
|
||||
|
||||
@@ -2203,6 +2203,9 @@ function formatWebshellEinoUsageSummaryMessage(data) {
|
||||
if (Number(d.cachedTokens || 0) > 0) {
|
||||
rows.push([wsTOr('chat.einoUsageCachedTokens', '缓存 tokens'), d.cachedTokens]);
|
||||
}
|
||||
if (Number(d.cacheWriteTokens || 0) > 0) {
|
||||
rows.push([wsTOr('chat.einoUsageCacheWriteTokens', '缓存写入 tokens'), d.cacheWriteTokens]);
|
||||
}
|
||||
if (Number(d.reasoningTokens || 0) > 0) {
|
||||
rows.push([wsTOr('chat.einoUsageReasoningTokens', '推理 tokens'), d.reasoningTokens]);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user