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 @@
-

Token 用量

- +

Token 用量

查看模型消耗、缓存利用与使用趋势

+
@@ -3652,8 +3652,9 @@ +
-

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

+
统计口径与说明

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

@@ -7023,7 +7024,7 @@ - +