feat: Token 仪表盘支持全局统计,跨会话累计 Token 分析

This commit is contained in:
Metona Dev
2026-04-24 12:53:50 +08:00
parent 08193c5d8e
commit 5fafaa691f
7 changed files with 467 additions and 29 deletions
+307 -26
View File
@@ -1,19 +1,50 @@
/**
* TokenDashboard - Token 实时监控仪表盘
* 显示当前会话的 Token 消耗统计、趋势图和明细表
* 支持全局统计(跨会话累计)和当前会话统计双视图切换
*/
import { state, KEYS } from '../state/state.js';
import { formatTime } from '../utils/utils.js';
import { logError, logInfo } from '../services/log-service.js';
import type { ChatSession, ChatMessage } from '../types.js';
let dashboardModalEl: HTMLElement;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let viewMode: 'global' | 'session' = 'global';
/** 全局统计缓存 */
interface SessionTokenStat {
session_id: string;
title: string;
model: string;
created_at: number;
total_input: number;
total_output: number;
total_duration: number;
round_count: number;
}
interface GlobalTokenTotals {
total_input: number;
total_output: number;
total_duration: number;
total_rounds: number;
session_count: number;
}
interface AllSessionsTokenStats {
sessions: SessionTokenStat[];
totals: GlobalTokenTotals;
}
let globalStatsCache: AllSessionsTokenStats | null = null;
let globalStatsLoaded = false;
/** 打开仪表盘 */
export function openTokenDashboard(): void {
if (!dashboardModalEl) return;
dashboardModalEl.style.display = '';
globalStatsLoaded = false;
renderDashboard();
startAutoRefresh();
}
@@ -34,6 +65,23 @@ export function initTokenDashboard(): void {
dashboardModalEl.addEventListener('click', (e) => {
if (e.target === dashboardModalEl) closeTokenDashboard();
});
// 视图切换按钮事件
const toggleEl = dashboardModalEl.querySelector('#tdViewToggle')!;
toggleEl.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains('td-view-btn')) return;
const newView = target.dataset.view as 'global' | 'session';
if (newView && newView !== viewMode) {
viewMode = newView;
// 更新按钮 active 状态
toggleEl.querySelectorAll('.td-view-btn').forEach(btn => btn.classList.remove('active'));
target.classList.add('active');
// 切换时刷新
if (viewMode === 'global') globalStatsLoaded = false;
renderDashboard();
}
});
}
function startAutoRefresh(): void {
@@ -95,7 +143,194 @@ function formatTokenCount(n: number): string {
return String(n);
}
/** 获取全局统计数据(带缓存) */
async function fetchGlobalStats(): Promise<AllSessionsTokenStats | null> {
if (globalStatsLoaded && globalStatsCache) return globalStatsCache;
try {
const desktop = window.metonaDesktop;
if (!desktop?.db?.getAllTokenStats) return null;
const result = await desktop.db.getAllTokenStats();
if (result) {
globalStatsCache = result;
globalStatsLoaded = true;
return result;
}
} catch (err) {
logError('Token Dashboard: 获取全局统计失败', String(err));
}
return null;
}
function renderDashboard(): void {
if (viewMode === 'global') {
renderGlobalView();
} else {
renderSessionView();
}
}
/** 渲染全局统计视图 */
function renderGlobalView(): void {
fetchGlobalStats().then(stats => {
if (!stats) {
renderGlobalFallback();
return;
}
renderGlobalOverview(stats);
renderGlobalChart(stats);
renderGlobalTable(stats);
});
}
/** 全局统计获取失败时的降级处理 */
function renderGlobalFallback(): void {
const overviewEl = dashboardModalEl.querySelector('#tdOverview')!;
overviewEl.innerHTML = `
<div class="td-stat-card">
<div class="td-stat-icon">🌍</div>
<div class="td-stat-value">-</div>
<div class="td-stat-label">全局统计加载中…</div>
</div>
`;
const chartEl = dashboardModalEl.querySelector('#tdChart')!;
chartEl.innerHTML = '<div class="td-empty">正在加载全局数据…</div>';
const tableEl = dashboardModalEl.querySelector('#tdTableBody')!;
tableEl.innerHTML = '<tr><td colspan="7" class="td-empty-row">正在加载…</td></tr>';
}
/** 渲染全局概览卡片 */
function renderGlobalOverview(stats: AllSessionsTokenStats): void {
const { totals } = stats;
const totalTokens = totals.total_input + totals.total_output;
const avgPerRound = totals.total_rounds > 0 ? Math.round(totalTokens / totals.total_rounds) : 0;
const overviewEl = dashboardModalEl.querySelector('#tdOverview')!;
overviewEl.innerHTML = `
<div class="td-stat-row td-stat-row-global">
<div class="td-stat-card">
<div class="td-stat-icon">🌍</div>
<div class="td-stat-value">${formatTokenCount(totalTokens)}</div>
<div class="td-stat-label">全局 Token 总计</div>
<div class="td-stat-detail">输入 ${formatTokenCount(totals.total_input)} / 输出 ${formatTokenCount(totals.total_output)}</div>
</div>
<div class="td-stat-card">
<div class="td-stat-icon">📂</div>
<div class="td-stat-value">${totals.session_count}</div>
<div class="td-stat-label">总会话数</div>
</div>
<div class="td-stat-card">
<div class="td-stat-icon">💬</div>
<div class="td-stat-value">${totals.total_rounds}</div>
<div class="td-stat-label">总轮次</div>
</div>
<div class="td-stat-card">
<div class="td-stat-icon">⏱️</div>
<div class="td-stat-value">${formatDuration(totals.total_duration)}</div>
<div class="td-stat-label">总推理耗时</div>
</div>
</div>
<div class="td-stat-row td-stat-row-session">
<div class="td-stat-card td-stat-card-small">
<div class="td-stat-icon">📊</div>
<div class="td-stat-value">${formatTokenCount(avgPerRound)}</div>
<div class="td-stat-label">平均 Token/轮</div>
</div>
</div>
`;
}
/** 渲染全局柱状图(按会话) */
function renderGlobalChart(stats: AllSessionsTokenStats): void {
const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle');
if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按会话';
const chartEl = dashboardModalEl.querySelector('#tdChart')!;
const sessions = stats.sessions.filter(s => s.total_input > 0 || s.total_output > 0);
if (sessions.length === 0) {
chartEl.innerHTML = '<div class="td-empty">暂无会话数据</div>';
return;
}
// 最多显示 20 个会话
const displaySessions = sessions.slice(0, 20);
const maxTokens = Math.max(...displaySessions.map(s => s.total_input + s.total_output), 1);
const barsHtml = displaySessions.map(s => {
const total = s.total_input + s.total_output;
const heightPct = Math.max((total / maxTokens) * 100, 2);
const inputPct = total > 0 ? (s.total_input / total) * 100 : 0;
const title = s.title.length > 8 ? s.title.slice(0, 8) + '…' : s.title;
const tooltip = `${s.title}\n输入 ${formatTokenCount(s.total_input)} + 输出 ${formatTokenCount(s.total_output)} = ${formatTokenCount(total)} tokens\n${s.round_count} 轮 · ${formatDuration(s.total_duration)}`;
return `
<div class="td-bar-col" title="${tooltip}">
<div class="td-bar-values">
<span class="td-val-output">${formatTokenCount(s.total_output)}</span>
<span class="td-val-input">${formatTokenCount(s.total_input)}</span>
</div>
<div class="td-bar-wrapper">
<div class="td-bar" style="height:${heightPct}%;">
<div class="td-bar-fill">
<div class="td-bar-input" style="height:${inputPct}%;"></div>
</div>
</div>
</div>
<div class="td-bar-label">${title}</div>
</div>
`;
}).join('');
chartEl.innerHTML = `
<div class="td-chart-legend">
<span class="td-legend-item"><span class="td-legend-dot td-legend-output"></span>输出 Token</span>
<span class="td-legend-item"><span class="td-legend-dot td-legend-input"></span>输入 Token</span>
${sessions.length > 20 ? `<span class="td-legend-item td-legend-note">(仅显示最近 20 个会话)</span>` : ''}
</div>
<div class="td-chart-container">
<div class="td-chart-y-axis">
<span>${formatTokenCount(maxTokens)}</span>
<span>${formatTokenCount(Math.round(maxTokens / 2))}</span>
<span>0</span>
</div>
<div class="td-chart-bars">${barsHtml}</div>
</div>
`;
}
/** 渲染全局明细表格 */
function renderGlobalTable(stats: AllSessionsTokenStats): void {
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle');
if (tableTitleEl) tableTitleEl.textContent = '📋 会话汇总明细';
// 动态更新表头
updateTableHeader('global');
const tableEl = dashboardModalEl.querySelector('#tdTableBody')!;
const sessions = stats.sessions;
if (sessions.length === 0) {
tableEl.innerHTML = '<tr><td colspan="7" class="td-empty-row">暂无数据</td></tr>';
return;
}
tableEl.innerHTML = sessions.map(s => {
const total = s.total_input + s.total_output;
return `
<tr>
<td title="${s.title}">${s.title.length > 20 ? s.title.slice(0, 20) + '…' : s.title}</td>
<td><span class="td-model-tag">${s.model}</span></td>
<td class="td-num">${formatTokenCount(s.total_input)}</td>
<td class="td-num">${formatTokenCount(s.total_output)}</td>
<td class="td-num td-num-total">${formatTokenCount(total)}</td>
<td class="td-num">${s.round_count}</td>
<td class="td-time">${s.created_at ? formatTime(s.created_at) : '-'}</td>
</tr>
`;
}).join('');
}
/** 渲染当前会话视图 */
function renderSessionView(): void {
const session = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
const messages = session?.messages || [];
@@ -109,35 +344,47 @@ function renderDashboard(): void {
const totalDurationNs = rounds.reduce((sum, r) => sum + r.total_duration, 0);
const avgTokens = rounds.length > 0 ? Math.round(totalTokens / rounds.length) : 0;
// A. 概览区域
// 概览区域
const overviewEl = dashboardModalEl.querySelector('#tdOverview')!;
overviewEl.innerHTML = `
<div class="td-stat-card">
<div class="td-stat-icon">🔥</div>
<div class="td-stat-value">${formatTokenCount(totalTokens)}</div>
<div class="td-stat-label">总 Token 消耗</div>
<div class="td-stat-detail">输入 ${formatTokenCount(totalInputTokens)} / 输出 ${formatTokenCount(totalOutputTokens)}</div>
<div class="td-stat-row td-stat-row-global">
<div class="td-stat-card">
<div class="td-stat-icon">🔥</div>
<div class="td-stat-value">${formatTokenCount(totalTokens)}</div>
<div class="td-stat-label">会话 Token 消耗</div>
<div class="td-stat-detail">输入 ${formatTokenCount(totalInputTokens)} / 输出 ${formatTokenCount(totalOutputTokens)}</div>
</div>
<div class="td-stat-card">
<div class="td-stat-icon">💬</div>
<div class="td-stat-value">${userMsgs.length} / ${aiMsgs.length}</div>
<div class="td-stat-label">用户 / AI 消息</div>
</div>
<div class="td-stat-card">
<div class="td-stat-icon">📊</div>
<div class="td-stat-value">${formatTokenCount(avgTokens)}</div>
<div class="td-stat-label">平均 Token/轮</div>
</div>
<div class="td-stat-card">
<div class="td-stat-icon">⏱️</div>
<div class="td-stat-value">${formatDuration(totalDurationNs)}</div>
<div class="td-stat-label">总推理耗时</div>
</div>
</div>
<div class="td-stat-card">
<div class="td-stat-icon">💬</div>
<div class="td-stat-value">${userMsgs.length} / ${aiMsgs.length}</div>
<div class="td-stat-label">用户 / AI 消息</div>
</div>
<div class="td-stat-card">
<div class="td-stat-icon">📊</div>
<div class="td-stat-value">${formatTokenCount(avgTokens)}</div>
<div class="td-stat-label">平均 Token/轮</div>
</div>
<div class="td-stat-card">
<div class="td-stat-icon">⏱️</div>
<div class="td-stat-value">${formatDuration(totalDurationNs)}</div>
<div class="td-stat-label">总推理耗时</div>
<div class="td-stat-row td-stat-row-session">
<div class="td-stat-card td-stat-card-small">
<div class="td-stat-icon">🤖</div>
<div class="td-stat-value">${session?.model || '-'}</div>
<div class="td-stat-label">当前模型</div>
</div>
</div>
`;
// B. 柱状图
// 柱状图标题
const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle');
if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按轮次';
// 柱状图
const chartEl = dashboardModalEl.querySelector('#tdChart')!;
// 保存滚动位置(每 2 秒重建 HTML 会重置滚动)
const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null;
const savedScrollLeft = barsEl?.scrollLeft ?? 0;
if (rounds.length === 0) {
@@ -189,12 +436,18 @@ function renderDashboard(): void {
if (newBarsEl) newBarsEl.scrollLeft = savedScrollLeft;
}
// C. 明细表格
// 表格标题
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle');
if (tableTitleEl) tableTitleEl.textContent = '📋 轮次明细(按时间倒序)';
// 动态更新表头
updateTableHeader('session');
// 明细表格
const tableEl = dashboardModalEl.querySelector('#tdTableBody')!;
if (rounds.length === 0) {
tableEl.innerHTML = '<tr><td colspan="6" class="td-empty-row">暂无数据</td></tr>';
tableEl.innerHTML = '<tr><td colspan="7" class="td-empty-row">暂无数据</td></tr>';
} else {
// 按时间倒序
const sorted = [...rounds].reverse();
tableEl.innerHTML = sorted.map(r => {
const total = r.eval_count + r.prompt_eval_count;
@@ -212,3 +465,31 @@ function renderDashboard(): void {
}).join('');
}
}
/** 动态更新表头列 */
function updateTableHeader(mode: 'global' | 'session'): void {
const thead = dashboardModalEl.querySelector('.td-table thead tr');
if (!thead) return;
if (mode === 'global') {
thead.innerHTML = `
<th>会话</th>
<th>模型</th>
<th>输入</th>
<th>输出</th>
<th>合计</th>
<th>轮次</th>
<th>创建时间</th>
`;
} else {
thead.innerHTML = `
<th>轮次</th>
<th>角色</th>
<th>输入</th>
<th>输出</th>
<th>合计</th>
<th>耗时</th>
<th>时间</th>
`;
}
}