diff --git a/src/renderer/components/token-dashboard.ts b/src/renderer/components/token-dashboard.ts new file mode 100644 index 0000000..6f30aad --- /dev/null +++ b/src/renderer/components/token-dashboard.ts @@ -0,0 +1,182 @@ +/** + * TokenDashboard - Token 实时监控仪表盘 + * 显示当前会话的 Token 消耗统计、趋势图和明细表 + */ + +import { state, KEYS } from '../state/state.js'; +import { formatTime } from '../utils/utils.js'; +import type { ChatSession, ChatMessage } from '../types.js'; + +let dashboardModalEl: HTMLElement; +let refreshTimer: ReturnType | null = null; + +/** 打开仪表盘 */ +export function openTokenDashboard(): void { + if (!dashboardModalEl) return; + dashboardModalEl.style.display = ''; + renderDashboard(); + startAutoRefresh(); +} + +/** 关闭仪表盘 */ +export function closeTokenDashboard(): void { + if (!dashboardModalEl) return; + dashboardModalEl.style.display = 'none'; + stopAutoRefresh(); +} + +/** 初始化 */ +export function initTokenDashboard(): void { + dashboardModalEl = document.querySelector('#tokenDashboardModal')!; + + document.querySelector('#btnTokenDashboard')!.addEventListener('click', openTokenDashboard); + document.querySelector('#btnCloseTokenDashboard')!.addEventListener('click', closeTokenDashboard); + dashboardModalEl.addEventListener('click', (e) => { + if (e.target === dashboardModalEl) closeTokenDashboard(); + }); +} + +function startAutoRefresh(): void { + stopAutoRefresh(); + refreshTimer = setInterval(() => renderDashboard(), 2000); +} + +function stopAutoRefresh(): void { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } +} + +interface RoundData { + round: number; + role: string; + eval_count: number; + total_duration: number; + timestamp: number; + model?: string; +} + +function collectRoundData(messages: ChatMessage[]): RoundData[] { + const rounds: RoundData[] = []; + let roundNum = 0; + for (const msg of messages) { + if (msg.role === 'assistant' && (msg.eval_count || msg.total_duration)) { + roundNum++; + rounds.push({ + round: roundNum, + role: msg.role, + eval_count: msg.eval_count || 0, + total_duration: msg.total_duration || 0, + timestamp: msg.timestamp, + model: msg.model, + }); + } + } + return rounds; +} + +function formatDuration(ns: number): string { + if (!ns) return '-'; + const ms = ns / 1_000_000; + if (ms < 1000) return `${Math.round(ms)}ms`; + const s = ms / 1000; + if (s < 60) return `${s.toFixed(1)}s`; + const m = Math.floor(s / 60); + const rem = (s % 60).toFixed(0); + return `${m}m${rem}s`; +} + +function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); +} + +function renderDashboard(): void { + const session = state.get(KEYS.CURRENT_SESSION); + const messages = session?.messages || []; + + const userMsgs = messages.filter(m => m.role === 'user'); + const aiMsgs = messages.filter(m => m.role === 'assistant'); + const rounds = collectRoundData(messages); + + const totalTokens = rounds.reduce((sum, r) => sum + r.eval_count, 0); + 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 = ` +
+
🔥
+
${formatTokenCount(totalTokens)}
+
总 Token 消耗
+
+
+
💬
+
${userMsgs.length} / ${aiMsgs.length}
+
用户 / AI 消息
+
+
+
📊
+
${formatTokenCount(avgTokens)}
+
平均 Token/轮
+
+
+
⏱️
+
${formatDuration(totalDurationNs)}
+
总推理耗时
+
+ `; + + // B. 柱状图 + const chartEl = dashboardModalEl.querySelector('#tdChart')!; + if (rounds.length === 0) { + chartEl.innerHTML = '
暂无数据,发送消息后开始统计
'; + } else { + const maxTokens = Math.max(...rounds.map(r => r.eval_count), 1); + const barsHtml = rounds.map(r => { + const heightPct = Math.max((r.eval_count / maxTokens) * 100, 2); + const tooltip = `第${r.round}轮 · ${formatTokenCount(r.eval_count)} tokens · ${formatDuration(r.total_duration)}`; + return ` +
+
${formatTokenCount(r.eval_count)}
+
+
+
+
R${r.round}
+
+ `; + }).join(''); + + chartEl.innerHTML = ` +
+
+ ${formatTokenCount(maxTokens)} + ${formatTokenCount(Math.round(maxTokens / 2))} + 0 +
+
${barsHtml}
+
+ `; + } + + // C. 明细表格 + const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; + if (rounds.length === 0) { + tableEl.innerHTML = '暂无数据'; + } else { + // 按时间倒序 + const sorted = [...rounds].reverse(); + tableEl.innerHTML = sorted.map(r => ` + + R${r.round} + 🤖 AI${r.model ? ` ${r.model}` : ''} + ${formatTokenCount(r.eval_count)} + ${formatDuration(r.total_duration)} + ${r.timestamp ? formatTime(r.timestamp) : '-'} + + `).join(''); + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 1945907..be15d3c 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -57,6 +57,11 @@ + + + + + +