diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md index f8c844b2..98247821 100644 --- a/docs/zh-CN/token-usage-monitoring.md +++ b/docs/zh-CN/token-usage-monitoring.md @@ -29,7 +29,7 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本 ## 功能入口与数据流 -点击首页「Token 用量」卡片或「查看用量详情」展开面板。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。沿用首页自动刷新和请求取消机制。 +点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天调用次数、今日用量与缓存命中率(缓存读取 Token / 输入 Token)。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。 diff --git a/web/static/css/style.css b/web/static/css/style.css index 100b2dd9..7b1576ac 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -47221,9 +47221,12 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; } } /* Provider-reported token analysis, scoped by the dashboard project selector. */ +.dashboard-kpi-sub--tokens { row-gap: 2px; } +#dashboard-kpi-token-cache-text { flex-basis: 100%; } .token-usage-panel { margin-bottom: 20px; padding: 18px 22px; } -.token-usage-panel > summary { cursor: pointer; } +.token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; } +.token-usage-toolbar label { white-space: nowrap; } .token-usage-toolbar select { padding: 6px 10px; border: 1px solid var(--border-color, #dce2ea); border-radius: 6px; background: var(--bg-secondary, #fff); color: inherit; } .token-usage-note { opacity: .72; font-size: 12px; line-height: 1.7; margin: 14px 0; } .token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 25c5a3c2..9f3f913c 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -287,6 +287,7 @@ "toolCalls": "Tool invocations", "successRate": "Tool success rate", "tokenUsage": "Token usage", + "usageCacheHitRate": "Cache hit {{rate}}", "tokenUsageSub": "Last {{days}} days: {{calls}} calls · Today {{today}}", "noTokenUsageYet": "No usage yet", "clickToViewTasks": "Click to view tasks", @@ -1969,6 +1970,7 @@ "settings": { "title": "System settings", "nav": { + "tokenUsage": "Token usage", "basic": "Basic", "hitl": "Human-in-the-loop", "knowledge": "Knowledge base", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 25c0928d..667c550e 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -287,6 +287,7 @@ "toolCalls": "Вызовы инструментов", "successRate": "Успешность инструментов", "tokenUsage": "Расход токенов", + "usageCacheHitRate": "Попадания в кэш {{rate}}", "tokenUsageSub": "За {{days}} дней {{calls}} вызовов · сегодня {{today}}", "noTokenUsageYet": "Расхода пока нет", "clickToViewTasks": "Открыть задачи", @@ -1982,6 +1983,7 @@ "settings": { "title": "Системные настройки", "nav": { + "tokenUsage": "Расход токенов", "basic": "Основные", "hitl": "Человек в контуре", "knowledge": "База знаний", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 00f8d90e..a758dbd3 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -287,6 +287,7 @@ "toolCalls": "工具调用次数", "successRate": "工具执行成功率", "tokenUsage": "Token 用量", + "usageCacheHitRate": "缓存命中 {{rate}}", "tokenUsageSub": "近 {{days}} 天 {{calls}} 次调用 · 今日 {{today}}", "noTokenUsageYet": "暂无用量", "clickToViewTasks": "点击查看任务管理", @@ -1957,6 +1958,7 @@ "settings": { "title": "系统设置", "nav": { + "tokenUsage": "Token 用量", "basic": "基本设置", "hitl": "人机协同", "knowledge": "知识库", diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index e45a774a..eec890d3 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -17,7 +17,6 @@ var DASHBOARD_SEVERITY_STATUS_FILTER_STORAGE_KEY = 'cyberstrike.dashboard.severi var DASHBOARD_SEVERITY_STATUS_FILTER_VALUES = ['', 'open', 'confirmed', 'fixed', 'ignored', 'false_positive']; var dashboardState = { - tokenDays: 7, currentController: null, // 当前正在进行的 fetch 的 AbortController pollTimer: null, // 自动轮询的 setInterval id staleTimer: null, // 过期检查的 setInterval id @@ -73,6 +72,7 @@ async function refreshDashboard() { setKpiSubText('dashboard-kpi-tools-sub-text', '…'); setKpiSubText('dashboard-kpi-rate-sub-text', '…'); setKpiSubText('dashboard-kpi-token-sub-text', '…'); + setKpiSubText('dashboard-kpi-token-cache-text', '…'); hideEl('dashboard-kpi-vuln-critical-badge'); hideEl('dashboard-alert-banner'); setRecentVulnsLoading(); @@ -163,7 +163,7 @@ async function refreshDashboard() { fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')), fetchJson('/api/projects/dashboard-summary?fact_limit=10'), selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null), - fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=' + dashboardState.tokenDays + '&limit=20')) + fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=20')) ]); // 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果 @@ -441,8 +441,9 @@ async function refreshDashboard() { setDashboardOverviewPlaceholder('-'); setEl('dashboard-kpi-success-rate', '-'); setEl('dashboard-kpi-tools-calls', '-'); - renderDashboardTokenAnalysis(null); setEl('dashboard-kpi-token-usage', '-'); + setKpiSubText('dashboard-kpi-token-sub-text', '-'); + setKpiSubText('dashboard-kpi-token-cache-text', '-'); setKpiSubText('dashboard-kpi-tasks-sub-text', '-'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); setKpiSubText('dashboard-kpi-tools-sub-text', '-'); @@ -710,13 +711,15 @@ function setKpiRateBadge(id, rate, failedCount) { } function renderDashboardTokenUsage(res) { - renderDashboardTokenAnalysis(res); const summary = res && res.summary ? res.summary : null; if (!summary) { setEl('dashboard-kpi-token-usage', '-'); setKpiSubText('dashboard-kpi-token-sub-text', '-'); + setKpiSubText('dashboard-kpi-token-cache-text', '-'); return; } + const cacheRate = tokenUsageCacheRate(summary); + setKpiSubText('dashboard-kpi-token-cache-text', dt('dashboard.usageCacheHitRate', { rate: cacheRate }, '缓存命中 ' + cacheRate)); const total = Number(summary.totalTokens || 0); const calls = Number(summary.modelCalls || 0); const today = res && res.today ? Number(res.today.totalTokens || 0) : 0; @@ -728,10 +731,10 @@ function renderDashboardTokenUsage(res) { setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total)); setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.tokenUsageSub', { - days: dashboardState.tokenDays, + days: 7, today: formatTokenUsageCompact(today), calls: Number.isFinite(calls) ? calls : 0 - }, '近 ' + dashboardState.tokenDays + ' 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); + }, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); } function formatTokenUsageCompact(num) { @@ -2528,136 +2531,3 @@ document.addEventListener('click', function (ev) { var banner = document.getElementById('dashboard-alert-banner'); if (banner) banner.hidden = true; }); - -function openDashboardTokenUsage() { - const panel = document.getElementById('dashboard-token-details'); - if (!panel) return; - panel.open = true; - panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); -} - -function changeDashboardTokenPeriod(value) { - const days = Number(value); - if (![7, 30, 90].includes(days)) return; - dashboardState.tokenDays = days; - const host = document.getElementById('dashboard-token-analysis'); - if (host) host.textContent = '…'; - refreshDashboard(); -} - -function dashboardUsageCount(value) { - const n = Number(value); - return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0; -} - -function dashboardUsageMetrics(summary) { - const input = dashboardUsageCount(summary.promptTokens); - const cached = dashboardUsageCount(summary.cachedTokens); - return [ - ['usageTotal', summary.totalTokens], - ['usageInput', input], - ['usageOutput', summary.completionTokens], - ['usageCacheRead', cached], - ['usageCacheWrite', summary.cacheWriteTokens], - ['usageReasoning', summary.reasoningTokens], - ['usageCalls', summary.modelCalls], - ['usageCacheRate', input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—'] - ]; -} - -function renderDashboardTokenAnalysis(res) { - const host = document.getElementById('dashboard-token-analysis'); - if (!host) return; - host.replaceChildren(); - if (!res || !res.summary) { - host.textContent = dt('dashboard.usageUnavailable', null, '用量统计暂不可用'); - return; - } - const exact = value => dashboardUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); - const label = key => dt('dashboard.' + key, null, key); - const metrics = document.createElement('div'); - metrics.className = 'token-usage-metrics'; - dashboardUsageMetrics(res.summary).forEach(([key, value]) => { - const card = document.createElement('div'); - const title = document.createElement('span'); - title.textContent = label(key); - const count = document.createElement('strong'); - count.textContent = typeof value === 'string' ? value : exact(value); - card.append(title, count); - metrics.append(card); - }); - host.append(metrics); - if (!dashboardUsageCount(res.summary.events)) { - const empty = document.createElement('p'); - empty.textContent = dt('dashboard.noTokenUsageYet', null, '暂无用量'); - host.append(empty); - return; - } - // Tables use text nodes: provider model names and conversation IDs are untrusted. - const table = (titleKey, headings, rows) => { - const section = document.createElement('section'); - const title = document.createElement('h4'); - title.textContent = label(titleKey); - const wrap = document.createElement('div'); - wrap.className = 'token-usage-table-wrap'; - const grid = document.createElement('table'); - const caption = document.createElement('caption'); - caption.className = 'sr-only'; - caption.textContent = title.textContent; - const head = document.createElement('thead'); - const hr = document.createElement('tr'); - headings.forEach(key => { - const th = document.createElement('th'); - th.scope = 'col'; - th.textContent = label(key); - hr.append(th); - }); - head.append(hr); - const body = document.createElement('tbody'); - rows.forEach(values => { - const tr = document.createElement('tr'); - values.forEach(value => { - const td = document.createElement('td'); - td.textContent = String(value == null ? '' : value); - tr.append(td); - }); - body.append(tr); - }); - grid.append(caption, head, body); - wrap.append(grid); - section.append(title, wrap); - host.append(section); - }; - const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), - exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; - const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls']; - table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key, - dashboardUsageCount(res.summary.totalTokens) > 0 ? (dashboardUsageCount(row.totalTokens) / dashboardUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)])); - const trendTitle = document.createElement('h4'); - trendTitle.textContent = label('usageTrend'); - const trend = document.createElement('div'); - trend.className = 'token-usage-trend'; - const days = (res.byDay || []).slice().reverse(); - const peak = Math.max(1, ...days.map(row => dashboardUsageCount(row.totalTokens))); - days.forEach(row => { - const bar = document.createElement('div'); - bar.className = 'token-usage-day'; - const count = document.createElement('span'); - count.textContent = formatTokenUsageCompact(row.totalTokens); - const fill = document.createElement('div'); - fill.className = 'token-usage-day-bar'; - fill.style.height = Math.max(1, dashboardUsageCount(row.totalTokens) / peak * 90) + 'px'; - const date = document.createElement('span'); - date.textContent = String(row.key || '').slice(5); - bar.title = row.key + ': ' + exact(row.totalTokens); - bar.append(count, fill, date); - trend.append(bar); - }); - host.append(trendTitle, trend); - table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)])); - table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => { - const time = new Date(row.createdAt); - return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', - row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)]; - })); -} diff --git a/web/static/js/router.js b/web/static/js/router.js index ddb3955d..b98c613c 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -1,9 +1,9 @@ // 页面路由管理 let currentPage = null; -/** chat、漏洞管理页在切换时保留当前 hash 上的查询串(如 ?conversation= / ?conversation_id=) */ +/** 对话、漏洞管理和设置页切换时保留查询串(会话 ID 或设置分区)。 */ function buildHashForPage(pageId) { - if (pageId !== 'chat' && pageId !== 'vulnerabilities') { + if (pageId !== 'chat' && pageId !== 'vulnerabilities' && pageId !== 'settings') { return pageId; } const full = window.location.hash.slice(1); @@ -111,7 +111,7 @@ function initRouter() { let pageId = hashParts[0].replace(/^#?\//, ''); if (pageId === 'c2') pageId = 'c2-listeners'; // 一键更新已并进系统设置页的分区菜单:#system-update 这条老深链落到设置页的那一区。 - const settingsSection = pageId === 'system-update' ? 'update' : ''; + const settingsSection = pageId === 'system-update' ? 'update' : pageId === 'settings' && new URLSearchParams(hashParts.slice(1).join('?')).get('section') === 'token-usage' ? 'token-usage' : ''; if (settingsSection) pageId = 'settings'; if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); @@ -541,6 +541,12 @@ async function initPage(pageId) { if (updateSection && updateSection.classList.contains('active') && typeof loadUpdateConsole === 'function') { loadUpdateConsole(); } + // Returning through the regular settings nav reloads a retained usage panel. + // Deep links are loaded by switchSettingsSection after switchPage returns. + const tokenSection = document.getElementById('settings-section-token-usage'); + if (tokenSection && tokenSection.classList.contains('active') && window.location.hash !== '#settings?section=token-usage' && typeof refreshTokenUsage === 'function') { + refreshTokenUsage(); + } break; case 'roles-management': // 初始化角色管理页面 @@ -622,6 +628,9 @@ async function initPage(pageId) { if (pageId !== 'settings' && typeof stopUpdatePolling === 'function') { stopUpdatePolling(); } + if (pageId !== 'settings' && typeof stopTokenUsageRequests === 'function') { + stopTokenUsageRequests(); + } } // 页面加载完成后初始化路由 @@ -639,7 +648,7 @@ document.addEventListener('DOMContentLoaded', function() { if (pageId === 'c2') pageId = 'c2-listeners'; // 与首屏解析同一套别名:#system-update 落到系统设置页的「一键更新」分区。 - const settingsSection = pageId === 'system-update' ? 'update' : ''; + const settingsSection = pageId === 'system-update' ? 'update' : pageId === 'settings' && new URLSearchParams(hashParts.slice(1).join('?')).get('section') === 'token-usage' ? 'token-usage' : ''; if (settingsSection) pageId = 'settings'; if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 9e371efd..0eb2eab9 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -661,6 +661,13 @@ function switchSettingsSection(section) { return; } + // Unknown hash sections must not hide every settings panel. + const requestedContent = document.getElementById(`settings-section-${section}`); + if (!requestedContent) return; + if (window.location.hash.split('?')[0] === '#settings') { + history.replaceState(null, '', section === 'token-usage' ? '#settings?section=token-usage' : '#settings'); + } + // 更新导航项状态 document.querySelectorAll('.settings-nav-item').forEach(item => { item.classList.remove('active'); @@ -678,6 +685,11 @@ function switchSettingsSection(section) { if (activeContent) { activeContent.classList.add('active'); initSettingsCustomSelects(activeContent); + } + if (section === 'token-usage' && typeof refreshTokenUsage === 'function') { + refreshTokenUsage(); + } else if (typeof stopTokenUsageRequests === 'function') { + stopTokenUsageRequests(); } if (section === 'robots' && typeof window.loadVulnerabilityAlertSubscription === 'function') { window.loadVulnerabilityAlertSubscription(); diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs index a6c61896..de24409d 100644 --- a/web/static/js/token-usage-analysis.test.cjs +++ b/web/static/js/token-usage-analysis.test.cjs @@ -2,41 +2,79 @@ const fs = require('node:fs'); const vm = require('node:vm'); const test = require('node:test'); const assert = require('node:assert/strict'); -const source = fs.readFileSync('web/static/js/dashboard.js', 'utf8'); +const source = fs.readFileSync('web/static/js/token-usage.js', 'utf8'); const harness = () => { - const context = { document: {}, dashboardState: { tokenDays: 7 }, refreshDashboard() {} }; + const context = { document: { addEventListener() {} }, window: {}, AbortController }; vm.createContext(context); - vm.runInContext(source.slice(source.indexOf('function openDashboardTokenUsage()')), context); + vm.runInContext(source, context); return context; }; test('cache and reasoning details do not inflate total or double-count input', () => { const ctx = harness(); - const metrics = ctx.dashboardUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 }); + const metrics = ctx.tokenUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 }); const values = Object.fromEntries(metrics); assert.equal(values.usageTotal, 120); assert.equal(values.usageCacheWrite, 30); assert.equal(values.usageCacheRate, '40.0%'); - assert.equal(Object.fromEntries(ctx.dashboardUsageMetrics({})).usageCacheRate, '—'); - assert.equal(ctx.dashboardUsageCount(Infinity), 0); - assert.equal(ctx.dashboardUsageCount(-12), 0); + assert.equal(Object.fromEntries(ctx.tokenUsageMetrics({})).usageCacheRate, '—'); + assert.equal(ctx.tokenUsageCount(Infinity), 0); + assert.equal(ctx.tokenUsageCount(-12), 0); }); test('period selection refreshes once and rejects arbitrary periods', () => { const ctx = harness(); let calls = 0; - ctx.refreshDashboard = () => { calls++; }; + ctx.refreshTokenUsage = () => { calls++; }; ctx.document.getElementById = () => ({ textContent: '' }); - ctx.changeDashboardTokenPeriod('30'); - ctx.changeDashboardTokenPeriod('999'); - assert.equal(ctx.dashboardState.tokenDays, 30); + ctx.changeTokenUsagePeriod('30'); + ctx.changeTokenUsagePeriod('999'); + assert.equal(ctx.tokenUsageState.days, 30); assert.equal(calls, 1); }); +test('cache hit ratio uses input tokens, clamps invalid detail and handles missing input', () => { + const ctx = harness(); + assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 1000, cachedTokens: 930, totalTokens: 1100 }), '93.0%'); + assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 1000, cachedTokens: 0 }), '0.0%'); + assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 10, cachedTokens: 30 }), '100.0%'); + assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 0, cachedTokens: 30 }), '—'); + assert.equal(ctx.tokenUsageCacheRate(null), '—'); +}); + +test('detail requests stay project scoped and discard late results after a period change', async () => { + const ctx = harness(); + const host = { setAttribute() {}, removeAttribute() {}, textContent: '' }; + ctx.document.getElementById = id => id === 'settings-token-analysis' ? host : null; + ctx.getActiveProjectId = () => 'project / sample'; + const requests = []; + ctx.apiFetch = (url, options) => new Promise(resolve => requests.push({ url, options, resolve })); + const rendered = []; + ctx.renderTokenUsageAnalysis = value => rendered.push(value); + const first = ctx.refreshTokenUsage(); + ctx.tokenUsageState.days = 30; + const second = ctx.refreshTokenUsage(); + assert.ok(requests[0].options.signal.aborted); + assert.match(requests[1].url, /days=30.*project_id=project%20%2F%20sample/); + requests[1].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 30 } }) }); + await second; + requests[0].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 7 } }) }); + await first; + assert.equal(rendered.length, 1); + assert.equal(rendered[0].summary.totalTokens, 30); + const pending = ctx.refreshTokenUsage(); + ctx.stopTokenUsageRequests(); + requests[2].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 90 } }) }); + await pending; + assert.equal(rendered.length, 1, 'leaving the section must invalidate outstanding results'); +}); + test('all supported languages expose usage labels and interpolation placeholders', () => { for (const locale of ['zh-CN', 'en-US', 'ru-RU']) { const data = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8')); - for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend']) assert.ok(data.dashboard[key]); + for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]); + assert.ok(data.settings.nav.tokenUsage); + assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/); assert.match(data.dashboard.tokenUsageSub, /\{\{days\}\}/); assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/); } diff --git a/web/static/js/token-usage.js b/web/static/js/token-usage.js new file mode 100644 index 00000000..34a2da73 --- /dev/null +++ b/web/static/js/token-usage.js @@ -0,0 +1,197 @@ +// Token detail data belongs to the settings section; the dashboard keeps a +// separate fixed seven-day summary. +var tokenUsageState = { days: 7, controller: null, requestSequence: 0, result: null }; + +function tokenUsageText(key, opts, fallback) { + if (typeof window.t === 'function') { + const text = window.t(key, opts); + if (text && text !== key) return text; + } + return fallback == null ? key : fallback; +} + +function openDashboardTokenUsage() { + if (window.location.hash === '#settings?section=token-usage') { + switchPage('settings'); + switchSettingsSection('token-usage'); + } else { + window.location.hash = 'settings?section=token-usage'; + } +} + +function stopTokenUsageRequests() { + tokenUsageState.requestSequence++; + if (tokenUsageState.controller) tokenUsageState.controller.abort(); + tokenUsageState.controller = null; +} + +async function refreshTokenUsage() { + const host = document.getElementById('settings-token-analysis'); + if (!host) return; + stopTokenUsageRequests(); + const sequence = tokenUsageState.requestSequence; + const controller = new AbortController(); + tokenUsageState.controller = controller; + host.setAttribute('aria-busy', 'true'); + host.textContent = tokenUsageText('common.loading', null, '加载中…'); + const select = document.getElementById('settings-token-days'); + if (select) select.value = String(tokenUsageState.days); + let url = '/api/usage/tokens?days=' + tokenUsageState.days + '&limit=20'; + const projectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() : ''; + if (projectId) url += '&project_id=' + encodeURIComponent(projectId); + try { + const response = await apiFetch(url, { signal: controller.signal }); + const result = response && response.ok ? await response.json() : null; + if (sequence !== tokenUsageState.requestSequence) return; + tokenUsageState.result = result; + renderTokenUsageAnalysis(result); + } catch (_) { + if (sequence !== tokenUsageState.requestSequence) return; + tokenUsageState.result = null; + renderTokenUsageAnalysis(null); + } finally { + if (sequence === tokenUsageState.requestSequence) { + tokenUsageState.controller = null; + host.removeAttribute('aria-busy'); + } + } +} + +function changeTokenUsagePeriod(value) { + const days = Number(value); + if (![7, 30, 90].includes(days)) return; + tokenUsageState.days = days; + refreshTokenUsage(); +} + +function tokenUsageCacheRate(summary) { + const input = tokenUsageCount(summary && summary.promptTokens); + const cached = tokenUsageCount(summary && summary.cachedTokens); + return input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—'; +} + +function tokenUsageCount(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0; +} + +function tokenUsageMetrics(summary) { + const input = tokenUsageCount(summary.promptTokens); + const cached = tokenUsageCount(summary.cachedTokens); + return [ + ['usageTotal', summary.totalTokens], + ['usageInput', input], + ['usageOutput', summary.completionTokens], + ['usageCacheRead', cached], + ['usageCacheWrite', summary.cacheWriteTokens], + ['usageReasoning', summary.reasoningTokens], + ['usageCalls', summary.modelCalls], + ['usageCacheRate', tokenUsageCacheRate(summary)] + ]; +} + +document.addEventListener('languagechange', function () { + if (tokenUsageState.controller) return; + const page = document.getElementById('page-settings'); + const section = document.getElementById('settings-section-token-usage'); + if (page && page.classList.contains('active') && section && section.classList.contains('active')) { + renderTokenUsageAnalysis(tokenUsageState.result); + } +}); + +function renderTokenUsageAnalysis(res) { + const host = document.getElementById('settings-token-analysis'); + if (!host) return; + host.replaceChildren(); + if (!res || !res.summary) { + host.textContent = tokenUsageText('dashboard.usageUnavailable', null, '用量统计暂不可用'); + return; + } + const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); + const label = key => tokenUsageText('dashboard.' + key, null, key); + const metrics = document.createElement('div'); + metrics.className = 'token-usage-metrics'; + tokenUsageMetrics(res.summary).forEach(([key, value]) => { + const card = document.createElement('div'); + const title = document.createElement('span'); + title.textContent = label(key); + const count = document.createElement('strong'); + count.textContent = typeof value === 'string' ? value : exact(value); + card.append(title, count); + metrics.append(card); + }); + host.append(metrics); + if (!tokenUsageCount(res.summary.events)) { + const empty = document.createElement('p'); + empty.textContent = tokenUsageText('dashboard.noTokenUsageYet', null, '暂无用量'); + host.append(empty); + return; + } + // Tables use text nodes: provider model names and conversation IDs are untrusted. + const table = (titleKey, headings, rows) => { + const section = document.createElement('section'); + const title = document.createElement('h4'); + title.textContent = label(titleKey); + const wrap = document.createElement('div'); + wrap.className = 'token-usage-table-wrap'; + const grid = document.createElement('table'); + const caption = document.createElement('caption'); + caption.className = 'sr-only'; + caption.textContent = title.textContent; + const head = document.createElement('thead'); + const hr = document.createElement('tr'); + headings.forEach(key => { + const th = document.createElement('th'); + th.scope = 'col'; + th.textContent = label(key); + hr.append(th); + }); + head.append(hr); + const body = document.createElement('tbody'); + rows.forEach(values => { + const tr = document.createElement('tr'); + values.forEach(value => { + const td = document.createElement('td'); + td.textContent = String(value == null ? '' : value); + tr.append(td); + }); + body.append(tr); + }); + grid.append(caption, head, body); + wrap.append(grid); + section.append(title, wrap); + host.append(section); + }; + const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), + exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; + const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls']; + table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key, + tokenUsageCount(res.summary.totalTokens) > 0 ? (tokenUsageCount(row.totalTokens) / tokenUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)])); + const trendTitle = document.createElement('h4'); + trendTitle.textContent = label('usageTrend'); + const trend = document.createElement('div'); + trend.className = 'token-usage-trend'; + const days = (res.byDay || []).slice().reverse(); + const peak = Math.max(1, ...days.map(row => tokenUsageCount(row.totalTokens))); + days.forEach(row => { + const bar = document.createElement('div'); + bar.className = 'token-usage-day'; + const count = document.createElement('span'); + count.textContent = formatTokenUsageCompact(row.totalTokens); + const fill = document.createElement('div'); + fill.className = 'token-usage-day-bar'; + fill.style.height = Math.max(1, tokenUsageCount(row.totalTokens) / peak * 90) + 'px'; + const date = document.createElement('span'); + date.textContent = String(row.key || '').slice(5); + bar.title = row.key + ': ' + exact(row.totalTokens); + bar.append(count, fill, date); + trend.append(bar); + }); + host.append(trendTitle, trend); + table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)])); + table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => { + const time = new Date(row.createdAt); + return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', + row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)]; + })); +} diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index a3bf65b4..1e42d24b 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -435,7 +435,7 @@ test('the console lives inside the settings page as its own section', () => { // Both hash entry points carry the alias, and the retired page id is gone from the whitelists: // #system-update has to land on 系统设置 with its update section selected. - const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : '';/g)]; + const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : /g)]; assert.equal(aliases.length, 2, 'both hash parsers must normalize #system-update into the settings section'); const selects = [...router.matchAll(/if \(settingsSection && typeof switchSettingsSection === 'function'\) \{[\s\S]{0,80}?switchSettingsSection\(settingsSection\);/g)]; assert.equal(selects.length, 2, 'both hash paths must open the update section after switching the page'); diff --git a/web/templates/index.html b/web/templates/index.html index 3ced8a4a..ad0c85d9 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -39,14 +39,14 @@ } })(); - + - +
-
-
+
- + -
-
- 查看用量详情 -
- - -
-

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

-
-
@@ -3615,6 +3603,9 @@
基本设置
+
+ Token 用量 +
人机协同
@@ -3650,6 +3641,23 @@
+
+
+

Token 用量

+ +
+
+ + +
+

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

+
+
+
@@ -7009,14 +7017,15 @@ - + - + +