diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md
index 80458a69..9a60fd48 100644
--- a/docs/zh-CN/token-usage-monitoring.md
+++ b/docs/zh-CN/token-usage-monitoring.md
@@ -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 幂等写入、外键删除关联和历史回填继续生效。日分组按服务本地时区显示,与首页「今日」边界一致。
diff --git a/web/static/css/style.css b/web/static/css/style.css
index 3258149f..a316b5d1 100644
--- a/web/static/css/style.css
+++ b/web/static/css/style.css
@@ -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; }
+}
diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json
index 932ccf16..e6817386 100644
--- a/web/static/i18n/en-US.json
+++ b/web/static/i18n/en-US.json
@@ -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",
diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json
index 9e833896..23463753 100644
--- a/web/static/i18n/ru-RU.json
+++ b/web/static/i18n/ru-RU.json
@@ -277,6 +277,19 @@
"capabilities": "Возможности",
"administration": "Администрирование"
},
+ "tokenUsageUI": {
+ "chartHint": "Наведите или выберите столбец для точных значений",
+ "scopeProject": "Текущий проект",
+ "scopeAccessible": "Доступные диалоги",
+ "subtitle": "Расход моделей, использование кэша и динамика",
+ "scopeNote": "Область и правила подсчёта",
+ "trendMetric": "Показатель графика",
+ "noDailyData": "Нет ежедневных данных",
+ "cacheComposition": "Кэш входных токенов",
+ "cacheHit": "Доля попаданий в кэш",
+ "uncachedInput": "Вход без чтения кэша",
+ "cacheNote": "Чтение кэша входит во входные токены и не прибавляется к итогу. Запись кэша указана отдельно."
+ },
"dashboard": {
"title": "Обзор",
"refresh": "Обновить",
diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json
index e53ee222..b1b6d7f4 100644
--- a/web/static/i18n/zh-CN.json
+++ b/web/static/i18n/zh-CN.json
@@ -277,6 +277,19 @@
"capabilities": "能力中心",
"administration": "平台管理"
},
+ "tokenUsageUI": {
+ "chartHint": "悬停或聚焦柱形查看精确用量",
+ "scopeProject": "当前项目",
+ "scopeAccessible": "可访问会话",
+ "subtitle": "查看模型消耗、缓存利用与使用趋势",
+ "scopeNote": "统计口径与说明",
+ "trendMetric": "趋势指标",
+ "noDailyData": "暂无每日用量记录",
+ "cacheComposition": "输入缓存构成",
+ "cacheHit": "缓存命中率",
+ "uncachedInput": "未命中缓存的输入",
+ "cacheNote": "缓存读取是输入的一部分,不重复计入总量。缓存写入单独列示。"
+ },
"dashboard": {
"title": "仪表盘",
"refresh": "刷新",
diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs
index 86b98ddb..9c7e5f8d 100644
--- a/web/static/js/token-usage-analysis.test.cjs
+++ b/web/static/js/token-usage-analysis.test.cjs
@@ -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\}\}/);
diff --git a/web/static/js/token-usage.js b/web/static/js/token-usage.js
index 34a2da73..ae55fcd9 100644
--- a/web/static/js/token-usage.js
+++ b/web/static/js/token-usage.js
@@ -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)];
}));
}
diff --git a/web/templates/index.html b/web/templates/index.html
index 147d3cee..8698ffa3 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -39,7 +39,7 @@
}
})();
-
+
@@ -3642,8 +3642,8 @@
@@ -3652,8 +3652,9 @@
+
-
统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。
+
统计口径与说明
统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。
@@ -7023,7 +7024,7 @@
-
+