/** * TokenDashboard - 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 | 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(); } /** 关闭仪表盘 */ 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(); }); // 视图切换按钮事件 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 { stopAutoRefresh(); refreshTimer = setInterval(() => renderDashboard(), 2000); } function stopAutoRefresh(): void { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } } interface RoundData { round: number; role: string; eval_count: number; prompt_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.prompt_eval_count || msg.total_duration)) { roundNum++; rounds.push({ round: roundNum, role: msg.role, eval_count: msg.eval_count || 0, prompt_eval_count: msg.prompt_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); } /** 获取全局统计数据(带缓存) */ async function fetchGlobalStats(): Promise { 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 = `
🌍
-
全局统计加载中…
`; const chartEl = dashboardModalEl.querySelector('#tdChart')!; chartEl.innerHTML = '
正在加载全局数据…
'; const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; tableEl.innerHTML = '正在加载…'; } /** 渲染全局概览卡片 */ 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 = `
🌍
${formatTokenCount(totalTokens)}
全局 Token 总计
输入 ${formatTokenCount(totals.total_input)} / 输出 ${formatTokenCount(totals.total_output)}
📂
${totals.session_count}
总会话数
💬
${totals.total_rounds}
总轮次
⏱️
${formatDuration(totals.total_duration)}
总推理耗时
📊
${formatTokenCount(avgPerRound)}
平均 Token/轮
`; } /** 渲染柱状图(通用,全局/会话复用) */ function renderBarChart( chartEl: HTMLElement, data: Array<{ label: string; input: number; output: number; tooltip: string }>, title: string, note?: string, savedScrollLeft?: number ): void { if (data.length === 0) { chartEl.innerHTML = '
暂无数据
'; return; } const maxTokens = Math.max(...data.map(d => d.input + d.output), 1); // 网格线:5 条水平线(0%, 25%, 50%, 75%, 100%) const gridLines = [0, 0.25, 0.5, 0.75, 1].map(ratio => { const val = Math.round(maxTokens * ratio); return `
${formatTokenCount(val)}
`; }).join(''); const barsHtml = data.map(d => { const total = d.input + d.output; const heightPct = Math.max((total / maxTokens) * 100, 1.5); const inputPct = total > 0 ? (d.input / total) * 100 : 0; const outputPct = 100 - inputPct; return `
${formatTokenCount(d.output)} ${formatTokenCount(d.input)}
${d.label}
`; }).join(''); chartEl.innerHTML = `
输出 Token 输入 Token ${note ? `${note}` : ''}
${gridLines}
${barsHtml}
`; // 恢复滚动位置 if (savedScrollLeft && savedScrollLeft > 0) { const newBarsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null; if (newBarsEl) newBarsEl.scrollLeft = savedScrollLeft; } } /** 渲染全局柱状图(按会话) */ function renderGlobalChart(stats: AllSessionsTokenStats): void { const chartEl = dashboardModalEl.querySelector('#tdChart') as HTMLElement; const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null; const savedScrollLeft = barsEl?.scrollLeft ?? 0; const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle'); if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按会话'; const sessions = stats.sessions; if (sessions.length === 0) { chartEl.innerHTML = '
暂无数据
'; return; } // 按创建时间正序排列,取最近 30 个会话 const sorted = [...sessions].sort((a, b) => a.created_at - b.created_at).slice(-30); const chartData = sorted.map(s => ({ label: s.title.length > 10 ? s.title.slice(0, 10) + '…' : s.title, input: s.total_input, output: s.total_output, tooltip: `${s.title} · 模型 ${s.model} · 输入 ${formatTokenCount(s.total_input)} + 输出 ${formatTokenCount(s.total_output)} = ${formatTokenCount(s.total_input + s.total_output)} tokens · ${s.round_count} 轮`, })); renderBarChart(chartEl, chartData, '按会话', `最近 ${sorted.length} 个会话`, savedScrollLeft); } /** 渲染全局明细表格 */ function renderGlobalTable(stats: AllSessionsTokenStats): void { const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null; if (tableTitleEl) tableTitleEl.textContent = '📋 会话汇总明细'; // 动态更新表头 updateTableHeader('global'); const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; const sessions = stats.sessions; if (sessions.length === 0) { tableEl.innerHTML = '暂无数据'; return; } tableEl.innerHTML = sessions.map(s => { const total = s.total_input + s.total_output; return ` ${s.title.length > 20 ? s.title.slice(0, 20) + '…' : s.title} ${s.model} ${formatTokenCount(s.total_input)} ${formatTokenCount(s.total_output)} ${formatTokenCount(total)} ${s.round_count} ${s.created_at ? formatTime(s.created_at) : '-'} `; }).join(''); } /** 渲染当前会话视图 */ function renderSessionView(): 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 totalOutputTokens = rounds.reduce((sum, r) => sum + r.eval_count, 0); const totalInputTokens = rounds.reduce((sum, r) => sum + r.prompt_eval_count, 0); const totalTokens = totalInputTokens + totalOutputTokens; const totalDurationNs = rounds.reduce((sum, r) => sum + r.total_duration, 0); const avgTokens = rounds.length > 0 ? Math.round(totalTokens / rounds.length) : 0; // 概览区域 const overviewEl = dashboardModalEl.querySelector('#tdOverview')!; overviewEl.innerHTML = `
🔥
${formatTokenCount(totalTokens)}
会话 Token 消耗
输入 ${formatTokenCount(totalInputTokens)} / 输出 ${formatTokenCount(totalOutputTokens)}
💬
${userMsgs.length} / ${aiMsgs.length}
用户 / AI 消息
📊
${formatTokenCount(avgTokens)}
平均 Token/轮
⏱️
${formatDuration(totalDurationNs)}
总推理耗时
🤖
${session?.model || '-'}
当前模型
`; // 柱状图 const chartEl = dashboardModalEl.querySelector('#tdChart') as HTMLElement; const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null; const savedScrollLeft = barsEl?.scrollLeft ?? 0; const chartData = rounds.map(r => ({ label: `R${r.round}`, input: r.prompt_eval_count, output: r.eval_count, tooltip: `第${r.round}轮 · 输入 ${formatTokenCount(r.prompt_eval_count)} + 输出 ${formatTokenCount(r.eval_count)} = ${formatTokenCount(r.eval_count + r.prompt_eval_count)} tokens · ${formatDuration(r.total_duration)}`, })); const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle'); if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按轮次'; renderBarChart(chartEl, chartData, '按轮次', undefined, savedScrollLeft); // 表格标题 const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null; if (tableTitleEl) tableTitleEl.textContent = '📋 轮次明细(按时间倒序)'; // 动态更新表头 updateTableHeader('session'); // 明细表格 const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; if (rounds.length === 0) { tableEl.innerHTML = '暂无数据'; } else { const sorted = [...rounds].reverse(); tableEl.innerHTML = sorted.map(r => { const total = r.eval_count + r.prompt_eval_count; return ` R${r.round} 🤖 AI${r.model ? ` ${r.model}` : ''} ${formatTokenCount(r.prompt_eval_count)} ${formatTokenCount(r.eval_count)} ${formatTokenCount(total)} ${formatDuration(r.total_duration)} ${r.timestamp ? formatTime(r.timestamp) : '-'} `; }).join(''); } } /** 动态更新表头列 */ function updateTableHeader(mode: 'global' | 'session'): void { const thead = dashboardModalEl.querySelector('.td-table thead tr'); if (!thead) return; if (mode === 'global') { thead.innerHTML = ` 会话 模型 输入 输出 合计 轮次 创建时间 `; } else { thead.innerHTML = ` 轮次 角色 输入 输出 合计 耗时 时间 `; } }