mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-20 14:37:20 +02:00
feat: refine token analytics with interactive trends and cache breakdown
This commit is contained in:
1 parent
4badef67c1
commit
56d29b7054
8 files changed
+310
-96
No files matched your search
@@ -31,6 +31,20 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本
|
||||
|
||||
点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天总用量,副标仅显示缓存命中率(缓存读取 Token / 输入 Token),调用次数和每日用量保留在详情页。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。
|
||||
|
||||
详情页采用四项核心指标(总量、输入、输出、缓存读取占输入)与四项次级指标(调用、缓存读取、缓存写入、推理)的层级布局。趋势可切换总 Token、模型调用与缓存命中率,鼠标悬停或键盘聚焦柱形可查看日期与精确数字;切换图表指标不再请求 API。缓存环形图拆分缓存读取与其余输入,不把缓存或推理再次叠加到总量。模型明细展示占比条,每日数字明细默认折叠,最近运行区分完整与部分记录。统计口径可展开查看。
|
||||
|
||||
每日图表沿用 API 返回的服务端日期,只补齐已返回日期之间无记录的空档,不根据浏览器时区猜测查询终点。小屏图表和宽表在自己的区域内横向滚动。没有记录和无法获取统计分别展示空状态与不可用状态。
|
||||
|
||||
### UI 调研依据(2026-10-12)
|
||||
|
||||
| 参考 | 采用的设计 |
|
||||
| --- | --- |
|
||||
| [DeepSeek Context Caching](https://api-docs.deepseek.com/guides/kv_cache/) | 使用其明确的缓存命中/未命中输入定义,设计缓存构成视图;比例按 Token 而非请求次数计算 |
|
||||
| [LiteLLM Usage](https://docs.litellm.ai/docs/proxy/ui/chat_ui) | 周期选择、核心统计与使用日志分层;本项目保留已有数据范围,不新增无法准确计量的费用或成功率 |
|
||||
| [Langfuse Custom Dashboards](https://langfuse.com/docs/metrics/features/custom-dashboards) | 将汇总、时间趋势、模型分布拆为独立可读区域,并让趋势指标可切换 |
|
||||
|
||||
DeepSeek 和 GLM 控制台的完整页面本轮未能公开读取,因此本轮视觉设计以能核实的开源面板为依据。没有声称复刻它们当前登录后的界面。项目也没有新增供应商账单、预算或节省金额推算。
|
||||
|
||||
运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。
|
||||
|
||||
新增 `model_token_usage.cache_write_tokens INTEGER NOT NULL DEFAULT 0`。启动时现有声明式 SQLite schema reconciliation 自动补列;旧数据库无需手工执行迁移。现有事件 ID 幂等写入、外键删除关联和历史回填继续生效。日分组按服务本地时区显示,与首页「今日」边界一致。
|
||||
|
||||
+73
-19
@@ -47220,25 +47220,79 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
|
||||
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-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; }
|
||||
.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; }
|
||||
/* Provider-reported token analytics in System Settings. */
|
||||
.token-usage-panel { --token-cache-color: #0d9488; max-width: 1200px; margin: 0 auto 24px; }
|
||||
html[data-theme="dark"] .token-usage-panel { --token-cache-color: #2dd4bf; }
|
||||
.token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.token-usage-subtitle { font-size: 13px; color: var(--text-secondary); margin: 8px 0 0; }
|
||||
.token-usage-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.token-usage-scope { font-size: 11px; padding: 4px 8px; border-radius: 6px; color: var(--text-secondary); background: var(--bg-secondary); }
|
||||
.token-usage-toolbar label { white-space: nowrap; font-size: 13px; }
|
||||
.token-usage-toolbar select { padding: 7px 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary); color: var(--text-primary); }
|
||||
.token-usage-note { color: var(--text-secondary); font-size: 12px; line-height: 1.7; margin: 16px 0 20px; }
|
||||
.token-usage-note summary { cursor: pointer; width: fit-content; }
|
||||
.token-usage-note p { margin: 8px 0 0; max-width: 960px; }
|
||||
.token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.token-usage-metric { min-width: 0; padding: 16px; border: 1px solid var(--border-color); border-radius: 12px; }
|
||||
.token-usage-metric span { display: block; color: var(--text-secondary); font-size: 12px; }
|
||||
.token-usage-metric strong { display: block; margin-top: 10px; font-size: clamp(20px, 1.65vw, 28px); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; letter-spacing: -.025em; }
|
||||
.token-usage-primary [data-metric="usageTotal"] { background: rgba(0, 102, 255, .05); border-color: rgba(0, 102, 255, .18); }
|
||||
.token-usage-primary [data-metric="usageTotal"] strong { color: var(--accent-color); }
|
||||
.token-usage-primary [data-metric="usageCacheRate"] strong { color: var(--token-cache-color); }
|
||||
.token-usage-secondary { margin: 12px 0 24px; gap: 0; padding: 14px 0; border: 1px solid var(--border-color); border-radius: 12px; }
|
||||
.token-usage-secondary .token-usage-metric { border: 0; border-radius: 0; padding: 0 16px; }
|
||||
.token-usage-secondary .token-usage-metric + .token-usage-metric { border-left: 1px solid var(--border-color); }
|
||||
.token-usage-secondary strong { margin-top: 6px; font-size: 18px; }
|
||||
.token-usage-charts { display: grid; grid-template-columns: minmax(0, 2fr) minmax(235px, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.token-usage-chart-card, .token-usage-data-card { min-width: 0; border: 1px solid var(--border-color); border-radius: 12px; padding: 18px; }
|
||||
.token-usage-panel h4 { margin: 0 0 16px; font-size: 14px; }
|
||||
.token-usage-chart-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
|
||||
.token-usage-chart-head h4 { margin: 0; }
|
||||
.token-usage-chart-tabs { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.token-usage-chart-tabs button { cursor: pointer; border: 0; border-radius: 6px; padding: 6px 8px; font: inherit; font-size: 11px; color: var(--text-secondary); background: transparent; }
|
||||
.token-usage-chart-tabs button[aria-pressed="true"] { color: var(--accent-color); background: rgba(0, 102, 255, .08); }
|
||||
.token-usage-chart-tabs button:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }
|
||||
.token-usage-trend-svg { display: block; width: 100%; height: auto; overflow: visible; }
|
||||
.token-usage-svg-wrap { overflow-x: auto; }
|
||||
.token-usage-chart-grid { stroke: var(--border-color); stroke-dasharray: 3 4; }
|
||||
.token-usage-chart-label { fill: var(--text-secondary); font-size: 11px; }
|
||||
.token-usage-chart-tooltip { font-size: 11px; color: var(--text-secondary); line-height: 1.6; min-height: 35px; margin: 8px 0 0; }
|
||||
.token-usage-chart-bar { fill: var(--accent-color); opacity: .8; }
|
||||
.token-usage-chart-bar:hover, .token-usage-chart-bar:focus { opacity: 1; outline: none; stroke: var(--text-primary); stroke-width: 1; }
|
||||
.token-usage-cache-ring { --cache-ratio: 0%; position: relative; width: 148px; height: 148px; margin: 8px auto 18px; border-radius: 50%; background: conic-gradient(var(--token-cache-color) var(--cache-ratio), var(--border-color) 0); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; }
|
||||
.token-usage-cache-ring::before { content: ''; position: absolute; inset: 12px; border-radius: 50%; background: var(--bg-primary); }
|
||||
.token-usage-cache-ring strong, .token-usage-cache-ring span { position: relative; }
|
||||
.token-usage-cache-ring strong { font-size: 28px; font-variant-numeric: tabular-nums; }
|
||||
.token-usage-cache-ring span { color: var(--text-secondary); font-size: 12px; }
|
||||
.token-usage-cache-legend { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; margin: 10px 0; }
|
||||
.token-usage-cache-legend > span::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 2px; background: var(--border-color); margin-right: 7px; }
|
||||
.token-usage-cache-legend.is-cached > span::before { background: var(--token-cache-color); }
|
||||
.token-usage-cache-legend strong { font-variant-numeric: tabular-nums; }
|
||||
.token-usage-chart-note { font-size: 11px; line-height: 1.6; color: var(--text-secondary); margin: 14px 0 0; }
|
||||
.token-usage-data-card { margin-bottom: 16px; }
|
||||
.token-usage-data-card > summary { cursor: pointer; font-size: 14px; font-weight: 600; }
|
||||
.token-usage-data-card[open] > summary { margin-bottom: 16px; }
|
||||
.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, .token-usage-table-wrap td { padding: 12px 10px; text-align: right; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
|
||||
.token-usage-table-wrap th { font-weight: 500; color: var(--text-secondary); background: var(--bg-secondary); }
|
||||
.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; }
|
||||
.token-usage-table-wrap td:first-child { max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.token-usage-table-wrap tbody tr:last-child td { border-bottom: 0; }
|
||||
.token-usage-share { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.token-usage-share-track { width: 48px; height: 4px; border-radius: 4px; background: var(--border-color); overflow: hidden; }
|
||||
.token-usage-share-track > span { display: block; height: 100%; background: var(--accent-color); }
|
||||
.token-usage-status { display: inline-block; padding: 3px 7px; border-radius: 5px; background: var(--bg-secondary); color: var(--text-secondary); }
|
||||
.token-usage-status.is-final { background: rgba(13, 148, 136, .1); color: var(--token-cache-color); }
|
||||
.token-usage-status.is-partial { background: rgba(217, 119, 6, .1); color: #d97706; }
|
||||
.token-usage-empty { text-align: center; padding: 36px 16px; color: var(--text-secondary); font-size: 13px; }
|
||||
@media (max-width: 1000px) { .token-usage-charts { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 640px) {
|
||||
.token-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.token-usage-metric { padding: 14px; }
|
||||
.token-usage-secondary { gap: 14px 0; }
|
||||
.token-usage-secondary .token-usage-metric:nth-child(3) { border-left: 0; }
|
||||
.token-usage-chart-card, .token-usage-data-card { padding: 14px; }
|
||||
.token-usage-subtitle { max-width: 220px; line-height: 1.6; }
|
||||
.token-usage-trend-svg { min-width: 480px; }
|
||||
}
|
||||
@@ -277,6 +277,19 @@
|
||||
"capabilities": "Capabilities",
|
||||
"administration": "Administration"
|
||||
},
|
||||
"tokenUsageUI": {
|
||||
"chartHint": "Hover or focus a bar for exact usage",
|
||||
"scopeProject": "Current project",
|
||||
"scopeAccessible": "Accessible conversations",
|
||||
"subtitle": "Explore model consumption, caching and usage trends",
|
||||
"scopeNote": "Scope and counting rules",
|
||||
"trendMetric": "Trend metric",
|
||||
"noDailyData": "No daily usage records",
|
||||
"cacheComposition": "Input cache breakdown",
|
||||
"cacheHit": "Cache hit rate",
|
||||
"uncachedInput": "Input without cache reads",
|
||||
"cacheNote": "Cache reads are part of input, not added to the total again. Cache writes are listed separately."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"refresh": "Refresh",
|
||||
|
||||
@@ -277,6 +277,19 @@
|
||||
"capabilities": "Возможности",
|
||||
"administration": "Администрирование"
|
||||
},
|
||||
"tokenUsageUI": {
|
||||
"chartHint": "Наведите или выберите столбец для точных значений",
|
||||
"scopeProject": "Текущий проект",
|
||||
"scopeAccessible": "Доступные диалоги",
|
||||
"subtitle": "Расход моделей, использование кэша и динамика",
|
||||
"scopeNote": "Область и правила подсчёта",
|
||||
"trendMetric": "Показатель графика",
|
||||
"noDailyData": "Нет ежедневных данных",
|
||||
"cacheComposition": "Кэш входных токенов",
|
||||
"cacheHit": "Доля попаданий в кэш",
|
||||
"uncachedInput": "Вход без чтения кэша",
|
||||
"cacheNote": "Чтение кэша входит во входные токены и не прибавляется к итогу. Запись кэша указана отдельно."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Обзор",
|
||||
"refresh": "Обновить",
|
||||
|
||||
@@ -277,6 +277,19 @@
|
||||
"capabilities": "能力中心",
|
||||
"administration": "平台管理"
|
||||
},
|
||||
"tokenUsageUI": {
|
||||
"chartHint": "悬停或聚焦柱形查看精确用量",
|
||||
"scopeProject": "当前项目",
|
||||
"scopeAccessible": "可访问会话",
|
||||
"subtitle": "查看模型消耗、缓存利用与使用趋势",
|
||||
"scopeNote": "统计口径与说明",
|
||||
"trendMetric": "趋势指标",
|
||||
"noDailyData": "暂无每日用量记录",
|
||||
"cacheComposition": "输入缓存构成",
|
||||
"cacheHit": "缓存命中率",
|
||||
"uncachedInput": "未命中缓存的输入",
|
||||
"cacheNote": "缓存读取是输入的一部分,不重复计入总量。缓存写入单独列示。"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
"refresh": "刷新",
|
||||
|
||||
@@ -42,6 +42,17 @@ test('cache hit ratio uses input tokens, clamps invalid detail and handles missi
|
||||
assert.equal(ctx.tokenUsageCacheRate(null), '—');
|
||||
});
|
||||
|
||||
test('daily trends retain server day keys and fill only gaps within returned history', () => {
|
||||
const ctx = harness();
|
||||
const rows = ctx.tokenUsageTimeline([{ key: '2026-10-12', totalTokens: 12 }, { key: '2026-10-10', totalTokens: 10 }, { key: 'invalid', totalTokens: 20 }]);
|
||||
assert.deepEqual(Array.from(rows, row => row.key), ['2026-10-10', '2026-10-11', '2026-10-12']);
|
||||
assert.equal(rows[1].totalTokens, 0);
|
||||
assert.equal(rows[1].events, 0);
|
||||
assert.equal(rows[2].totalTokens, 12);
|
||||
assert.equal(ctx.tokenUsageTimeline([]).length, 0);
|
||||
assert.equal(ctx.tokenUsageTimeline([{ key: '2026-10-10', totalTokens: 10 }]).length, 1);
|
||||
});
|
||||
|
||||
test('detail requests stay project scoped and discard late results after a period change', async () => {
|
||||
const ctx = harness();
|
||||
const host = { setAttribute() {}, removeAttribute() {}, textContent: '' };
|
||||
@@ -74,6 +85,7 @@ test('all supported languages expose usage labels and interpolation placeholders
|
||||
const data = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8'));
|
||||
for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]);
|
||||
assert.ok(data.settings.nav.tokenUsage);
|
||||
for (const key of ['subtitle', 'scopeNote', 'trendMetric', 'cacheComposition', 'cacheHit', 'uncachedInput', 'cacheNote', 'scopeProject', 'scopeAccessible', 'noDailyData']) assert.ok(data.tokenUsageUI[key]);
|
||||
assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/);
|
||||
assert.match(data.dashboard.tokenUsageSub, /\{\{rate\}\}/);
|
||||
assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/);
|
||||
|
||||
+166
-72
@@ -1,6 +1,6 @@
|
||||
// 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 };
|
||||
var tokenUsageState = { days: 7, controller: null, requestSequence: 0, result: null, trendMetric: 'totalTokens' };
|
||||
|
||||
function tokenUsageText(key, opts, fallback) {
|
||||
if (typeof window.t === 'function') {
|
||||
@@ -38,6 +38,8 @@ async function refreshTokenUsage() {
|
||||
if (select) select.value = String(tokenUsageState.days);
|
||||
let url = '/api/usage/tokens?days=' + tokenUsageState.days + '&limit=20';
|
||||
const projectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() : '';
|
||||
const scope = document.getElementById('settings-token-scope');
|
||||
if (scope) scope.textContent = tokenUsageText('tokenUsageUI.' + (projectId ? 'scopeProject' : 'scopeAccessible'), null, projectId ? '当前项目' : '可访问会话');
|
||||
if (projectId) url += '&project_id=' + encodeURIComponent(projectId);
|
||||
try {
|
||||
const response = await apiFetch(url, { signal: controller.signal });
|
||||
@@ -99,60 +101,167 @@ document.addEventListener('languagechange', function () {
|
||||
}
|
||||
});
|
||||
|
||||
function tokenUsageNode(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
// Fill only gaps between returned dates. The API's server-local day keys are
|
||||
// authoritative; do not invent a browser-timezone endpoint for the period.
|
||||
function tokenUsageTimeline(rows) {
|
||||
const dated = (rows || []).filter(row => /^\d{4}-\d{2}-\d{2}$/.test(row.key || '')).sort((a, b) => a.key.localeCompare(b.key));
|
||||
if (!dated.length) return [];
|
||||
const byDay = new Map(dated.map(row => [row.key, row]));
|
||||
const start = Date.parse(dated[0].key + 'T00:00:00Z');
|
||||
const end = Date.parse(dated[dated.length - 1].key + 'T00:00:00Z');
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return dated;
|
||||
const days = [];
|
||||
for (let time = start; time <= end && days.length < 365; time += 86400000) {
|
||||
const key = new Date(time).toISOString().slice(0, 10);
|
||||
days.push(byDay.get(key) || { key, events: 0, totalTokens: 0, modelCalls: 0, promptTokens: 0 });
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function setTokenUsageTrend(metric) {
|
||||
if (!['totalTokens', 'modelCalls', 'cacheRate'].includes(metric)) return;
|
||||
tokenUsageState.trendMetric = metric;
|
||||
renderTokenUsageAnalysis(tokenUsageState.result);
|
||||
const button = document.querySelector('[data-token-trend="' + metric + '"]');
|
||||
if (button) button.focus();
|
||||
}
|
||||
|
||||
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, '暂无用量');
|
||||
const ui = key => tokenUsageText('tokenUsageUI.' + key, null, key);
|
||||
const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN');
|
||||
if (!res || !res.summary) {
|
||||
const empty = tokenUsageNode('div', 'token-usage-empty', label('usageUnavailable'));
|
||||
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);
|
||||
});
|
||||
const summary = res.summary;
|
||||
const metrics = new Map(tokenUsageMetrics(summary));
|
||||
const primary = tokenUsageNode('div', 'token-usage-metrics token-usage-primary');
|
||||
const secondary = tokenUsageNode('div', 'token-usage-metrics token-usage-secondary');
|
||||
const primaryKeys = ['usageTotal', 'usageInput', 'usageOutput', 'usageCacheRate'];
|
||||
[...primaryKeys, 'usageCalls', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning'].forEach(key => {
|
||||
const value = metrics.get(key);
|
||||
const card = tokenUsageNode('div', 'token-usage-metric');
|
||||
card.dataset.metric = key;
|
||||
card.append(tokenUsageNode('span', '', label(key)), tokenUsageNode('strong', '', typeof value === 'string' ? value : exact(value)));
|
||||
(primaryKeys.includes(key) ? primary : secondary).append(card);
|
||||
});
|
||||
host.append(primary, secondary);
|
||||
if (!tokenUsageCount(summary.events)) {
|
||||
host.append(tokenUsageNode('div', 'token-usage-empty', label('noTokenUsageYet')));
|
||||
return;
|
||||
}
|
||||
|
||||
const chartLayout = tokenUsageNode('div', 'token-usage-charts');
|
||||
const chart = tokenUsageNode('section', 'token-usage-chart-card');
|
||||
const chartHead = tokenUsageNode('div', 'token-usage-chart-head');
|
||||
chartHead.append(tokenUsageNode('h4', '', label('usageTrend')));
|
||||
const tabs = tokenUsageNode('div', 'token-usage-chart-tabs');
|
||||
tabs.setAttribute('role', 'group');
|
||||
tabs.setAttribute('aria-label', ui('trendMetric'));
|
||||
for (const [metric, key] of [['totalTokens', 'usageTotal'], ['modelCalls', 'usageCalls'], ['cacheRate', 'usageCacheRate']]) {
|
||||
const button = tokenUsageNode('button', '', label(key));
|
||||
button.type = 'button';
|
||||
button.dataset.tokenTrend = metric;
|
||||
button.setAttribute('aria-pressed', String(tokenUsageState.trendMetric === metric));
|
||||
button.addEventListener('click', () => setTokenUsageTrend(metric));
|
||||
tabs.append(button);
|
||||
}
|
||||
chartHead.append(tabs);
|
||||
chart.append(chartHead);
|
||||
const tooltip = tokenUsageNode('p', 'token-usage-chart-tooltip', ui('chartHint'));
|
||||
tooltip.setAttribute('aria-live', 'polite');
|
||||
const days = tokenUsageTimeline(res.byDay);
|
||||
const metric = tokenUsageState.trendMetric;
|
||||
const valueOf = row => metric === 'cacheRate' ? (tokenUsageCount(row.promptTokens) ? Math.min(tokenUsageCount(row.cachedTokens), tokenUsageCount(row.promptTokens)) / tokenUsageCount(row.promptTokens) * 100 : 0) : tokenUsageCount(row[metric]);
|
||||
const peak = metric === 'cacheRate' ? 100 : Math.max(1, ...days.map(valueOf));
|
||||
const svgNS = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(svgNS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 640 230');
|
||||
svg.setAttribute('role', 'group');
|
||||
svg.setAttribute('aria-label', label('usageTrend'));
|
||||
svg.classList.add('token-usage-trend-svg');
|
||||
const element = (tag, attrs, text) => {
|
||||
const node = document.createElementNS(svgNS, tag);
|
||||
Object.entries(attrs).forEach(([key, value]) => node.setAttribute(key, String(value)));
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
for (let tick = 0; tick <= 4; tick++) {
|
||||
const y = 18 + tick * 44;
|
||||
svg.append(element('line', { x1: 54, x2: 624, y1: y, y2: y, class: 'token-usage-chart-grid' }));
|
||||
svg.append(element('text', { x: 46, y: y + 4, 'text-anchor': 'end', class: 'token-usage-chart-label' }, metric === 'cacheRate' ? (100 - tick * 25) + '%' : formatTokenUsageCompact(peak * (4 - tick) / 4)));
|
||||
}
|
||||
const slot = 570 / Math.max(days.length, 1);
|
||||
const every = Math.max(1, Math.ceil(days.length / 7));
|
||||
days.forEach((row, index) => {
|
||||
const x = 54 + slot * (index + .5);
|
||||
const height = valueOf(row) / peak * 176;
|
||||
const detail = row.key + ' · ' + label('usageTotal') + ' ' + exact(row.totalTokens) + ' · ' + label('usageCalls') + ' ' + exact(row.modelCalls) + ' · ' + label('usageCacheRate') + ' ' + tokenUsageCacheRate(row);
|
||||
const bar = element('rect', { x: x - Math.min(24, slot * .55) / 2, y: 194 - height, width: Math.min(24, slot * .55), height: Math.max(height, 1), rx: 3, class: 'token-usage-chart-bar', tabindex: 0, 'aria-label': detail });
|
||||
bar.append(element('title', {}, detail));
|
||||
for (const event of ['mouseenter', 'focus']) bar.addEventListener(event, () => { tooltip.textContent = detail; });
|
||||
for (const event of ['mouseleave', 'blur']) bar.addEventListener(event, () => { tooltip.textContent = ui('chartHint'); });
|
||||
svg.append(bar);
|
||||
if (index % every === 0 || index === days.length - 1) {
|
||||
svg.append(element('text', { x, y: 218, 'text-anchor': 'middle', class: 'token-usage-chart-label' }, row.key.slice(5)));
|
||||
}
|
||||
});
|
||||
const plot = tokenUsageNode('div', 'token-usage-svg-wrap');
|
||||
plot.append(svg);
|
||||
chart.append(plot);
|
||||
chart.append(tooltip);
|
||||
if (!days.length) chart.append(tokenUsageNode('p', 'token-usage-empty', ui('noDailyData')));
|
||||
chartLayout.append(chart);
|
||||
|
||||
const cache = tokenUsageNode('section', 'token-usage-chart-card token-usage-cache-card');
|
||||
cache.append(tokenUsageNode('h4', '', ui('cacheComposition')));
|
||||
const input = tokenUsageCount(summary.promptTokens);
|
||||
const cached = Math.min(input, tokenUsageCount(summary.cachedTokens));
|
||||
const ratio = input ? cached / input * 100 : 0;
|
||||
const ring = tokenUsageNode('div', 'token-usage-cache-ring');
|
||||
ring.style.setProperty('--cache-ratio', ratio + '%');
|
||||
ring.append(tokenUsageNode('strong', '', tokenUsageCacheRate(summary)), tokenUsageNode('span', '', ui('cacheHit')));
|
||||
cache.append(ring);
|
||||
for (const [key, value, className] of [['usageCacheRead', cached, 'is-cached'], ['uncachedInput', input - cached, 'is-uncached']]) {
|
||||
const line = tokenUsageNode('div', 'token-usage-cache-legend ' + className);
|
||||
line.append(tokenUsageNode('span', '', key === 'uncachedInput' ? ui(key) : label(key)), tokenUsageNode('strong', '', exact(value)));
|
||||
cache.append(line);
|
||||
}
|
||||
cache.append(tokenUsageNode('p', 'token-usage-chart-note', ui('cacheNote')));
|
||||
chartLayout.append(cache);
|
||||
host.append(chartLayout);
|
||||
|
||||
// Provider labels are untrusted: render all values as text or constructed
|
||||
// nodes, never as HTML. Full precision is retained in every table.
|
||||
const table = (titleKey, headings, rows, collapsed = false) => {
|
||||
const section = tokenUsageNode(collapsed ? 'details' : 'section', 'token-usage-data-card');
|
||||
const title = tokenUsageNode(collapsed ? 'summary' : 'h4', '', label(titleKey));
|
||||
const wrap = tokenUsageNode('div', 'token-usage-table-wrap');
|
||||
const grid = tokenUsageNode('table');
|
||||
const caption = tokenUsageNode('caption', 'sr-only', label(titleKey));
|
||||
const head = tokenUsageNode('thead');
|
||||
const hr = tokenUsageNode('tr');
|
||||
headings.forEach(key => { const th = tokenUsageNode('th', '', label(key)); th.scope = 'col'; hr.append(th); });
|
||||
head.append(hr);
|
||||
const body = document.createElement('tbody');
|
||||
const body = tokenUsageNode('tbody');
|
||||
rows.forEach(values => {
|
||||
const tr = document.createElement('tr');
|
||||
const tr = tokenUsageNode('tr');
|
||||
values.forEach(value => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = String(value == null ? '' : value);
|
||||
const td = tokenUsageNode('td');
|
||||
if (value && value.nodeType) td.append(value);
|
||||
else td.textContent = String(value == null ? '' : value);
|
||||
tr.append(td);
|
||||
});
|
||||
body.append(tr);
|
||||
@@ -162,36 +271,21 @@ function renderTokenUsageAnalysis(res) {
|
||||
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 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('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => {
|
||||
const ratio = tokenUsageCount(summary.totalTokens) ? tokenUsageCount(row.totalTokens) / tokenUsageCount(summary.totalTokens) * 100 : 0;
|
||||
const share = tokenUsageNode('div', 'token-usage-share');
|
||||
share.append(tokenUsageNode('span', '', ratio.toFixed(1) + '%'));
|
||||
const track = tokenUsageNode('span', 'token-usage-share-track');
|
||||
const fill = tokenUsageNode('span'); fill.style.width = Math.min(100, ratio) + '%'; track.append(fill); share.append(track);
|
||||
return [row.label || row.key, share, ...counts(row)];
|
||||
}));
|
||||
table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)]), true);
|
||||
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)];
|
||||
const status = tokenUsageNode('span', 'token-usage-status', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'));
|
||||
if (row.reason === 'final' || row.reason === 'partial') status.classList.add('is-' + row.reason);
|
||||
return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', row.model || '—', status, exact(row.totalTokens), exact(row.modelCalls)];
|
||||
}));
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20261011-token-settings-compact">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20261012-token-analytics">
|
||||
<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">
|
||||
@@ -3642,8 +3642,8 @@
|
||||
<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><h3 data-i18n="settings.nav.tokenUsage">Token 用量</h3><p class="token-usage-subtitle" data-i18n="tokenUsageUI.subtitle">查看模型消耗、缓存利用与使用趋势</p></div>
|
||||
<button id="settings-token-refresh" 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>
|
||||
@@ -3652,8 +3652,9 @@
|
||||
<option value="30" data-i18n="dashboard.usage30Days">近 30 天</option>
|
||||
<option value="90" data-i18n="dashboard.usage90Days">近 90 天</option>
|
||||
</select>
|
||||
<span id="settings-token-scope" class="token-usage-scope"></span>
|
||||
</div>
|
||||
<p class="token-usage-note" data-i18n="dashboard.usageScope">统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。</p>
|
||||
<details class="token-usage-note"><summary data-i18n="tokenUsageUI.scopeNote">统计口径与说明</summary><p data-i18n="dashboard.usageScope">统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。</p></details>
|
||||
<div id="settings-token-analysis" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
@@ -7023,7 +7024,7 @@
|
||||
<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/token-usage.js?v=20261011-token-settings"></script>
|
||||
<script src="/static/js/token-usage.js?v=20261012-token-analytics"></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>
|
||||
|
||||
Reference in new issue
Block a user