/** * TokenDashboard - Token 实时监控仪表盘 * 支持全局统计(跨会话累计)和当前会话统计双视图切换 */ import { state, KEYS } from '../state/state.js'; import { formatTime } from '../utils/utils.js'; import { logError } from '../services/log-service.js'; import { getEffectiveNumCtx, getModelContextLength } from './model-bar.js'; import { estimateTokens } from '../services/context-manager.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; } /** P1 #5: HTML 转义,防止 XSS */ function escapeHtml(s: unknown): string { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** 打开仪表盘 */ 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(); }); // 视图切换按钮事件 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'); // 切换时刷新 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; llmCallCount: number; } /** * 收集轮次数据 — 按用户消息分组(一次用户消息 = 一轮)。 * Agent Loop 中一次用户交互可能触发多次 LLM 调用, * 这些调用的 token 统计会被聚合到同一个轮次中。 */ function collectRoundData(messages: ChatMessage[]): RoundData[] { const rounds: RoundData[] = []; let turnNum = 0; let currentTurn: RoundData | null = null; for (const msg of messages) { if (msg.role === 'user') { // 新轮次开始 if (currentTurn) rounds.push(currentTurn); turnNum++; currentTurn = { round: turnNum, role: 'assistant', eval_count: 0, prompt_eval_count: 0, total_duration: 0, timestamp: msg.timestamp, llmCallCount: 0, }; } else if (msg.role === 'assistant' && (msg.eval_count || msg.prompt_eval_count || msg.total_duration)) { if (!currentTurn) { // 边界情况:assistant 消息出现在任何 user 消息之前 turnNum++; currentTurn = { round: turnNum, role: 'assistant', eval_count: 0, prompt_eval_count: 0, total_duration: 0, timestamp: msg.timestamp, llmCallCount: 0, }; } currentTurn.eval_count += msg.eval_count || 0; currentTurn.prompt_eval_count += msg.prompt_eval_count || 0; currentTurn.total_duration += msg.total_duration || 0; currentTurn.llmCallCount++; currentTurn.model = msg.model || currentTurn.model; currentTurn.timestamp = msg.timestamp; } } if (currentTurn) rounds.push(currentTurn); 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); } /** P1 #3: 上次成功获取的全局数据(用于刷新期间保持旧数据可见,避免闪烁) */ let lastSuccessfulStats: AllSessionsTokenStats | null = null; let hasEverLoaded = false; /** P1 #3: 获取全局统计数据 — 每次都重新查询,不做永久缓存 */ async function fetchGlobalStats(): Promise { try { const desktop = window.metonaDesktop; if (!desktop?.db?.getAllTokenStats) return null; const result = await desktop.db.getAllTokenStats(); if (result) { lastSuccessfulStats = result; hasEverLoaded = true; return result; } } catch (err) { logError('Token Dashboard: 获取全局统计失败', String(err)); } return null; } function renderDashboard(): void { // 全局视图时隐藏上下文环形图区域(仅会话视图显示) const ringSection = dashboardModalEl.querySelector('#tdCtxRingSection') as HTMLElement | null; if (ringSection) ringSection.style.display = viewMode === 'session' ? '' : 'none'; if (viewMode === 'global') { renderGlobalView(); } else { renderSessionView(); } } /** P9: 全局刷新请求 ID,防止异步竞态 */ let globalFetchId = 0; /** 重新计算全局汇总 */ function recalcTotals(sessions: SessionTokenStat[]): GlobalTokenTotals { let totalInput = 0, totalOutput = 0, totalDuration = 0, totalRounds = 0; for (const s of sessions) { totalInput += s.total_input || 0; totalOutput += s.total_output || 0; totalDuration += s.total_duration || 0; totalRounds += s.round_count || 0; } return { total_input: totalInput, total_output: totalOutput, total_duration: totalDuration, total_rounds: totalRounds, session_count: sessions.length, }; } /** 渲染全局统计视图 */ function renderGlobalView(): void { const myFetchId = ++globalFetchId; fetchGlobalStats().then(stats => { if (myFetchId !== globalFetchId) return; // P9: 过期响应,丢弃 if (!stats) { // P1 #3/#9: 刷新失败时保留旧数据,仅在从未加载成功时显示错误 if (hasEverLoaded && lastSuccessfulStats) { renderGlobalOverview(lastSuccessfulStats); renderGlobalChart(lastSuccessfulStats); renderGlobalTable(lastSuccessfulStats); } else { renderGlobalFallback(true); } return; } // P2: 合并当前会话内存数据,确保实时性 const currentSession = state.get(KEYS.CURRENT_SESSION); if (currentSession && currentSession.messages.length > 0) { const rounds = collectRoundData(currentSession.messages); if (rounds.length > 0) { const totalInput = rounds.reduce((sum, r) => sum + r.prompt_eval_count, 0); const totalOutput = rounds.reduce((sum, r) => sum + r.eval_count, 0); const totalDuration = rounds.reduce((sum, r) => sum + r.total_duration, 0); const sessionStat: SessionTokenStat = { session_id: currentSession.id, title: currentSession.title, model: currentSession.model, created_at: currentSession.createdAt, total_input: totalInput, total_output: totalOutput, total_duration: totalDuration, round_count: rounds.length, }; const idx = stats.sessions.findIndex(s => s.session_id === currentSession.id); if (idx >= 0) { stats.sessions[idx] = sessionStat; } else { stats.sessions.push(sessionStat); } stats.totals = recalcTotals(stats.sessions); } } renderGlobalOverview(stats); renderGlobalChart(stats); renderGlobalTable(stats); }); } /** 全局统计获取失败时的降级处理 */ function renderGlobalFallback(isError = false): void { const overviewEl = dashboardModalEl.querySelector('#tdOverview')!; const msg = isError ? '全局统计加载失败,正在重试…' : '正在加载全局数据…'; overviewEl.innerHTML = `
🌍
-
${msg}
`; const chartEl = dashboardModalEl.querySelector('#tdChart')!; chartEl.innerHTML = `
${msg}
`; const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; tableEl.innerHTML = `${msg}`; } /** 渲染全局概览卡片 */ 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 }>, 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.input)} ${formatTokenCount(d.output)}
${escapeHtml(d.label)}
`; }).join(''); chartEl.innerHTML = `
输出 Token 输入 Token ${note ? `${escapeHtml(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; } // P2 #10: 统一排序 — 按 created_at 正序(图表左→右为时间递增) 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} 轮`, })); const noteText = sessions.length > sorted.length ? `最近 ${sorted.length} / 共 ${sessions.length} 个会话` : `共 ${sorted.length} 个会话`; renderBarChart(chartEl, chartData, noteText, savedScrollLeft); } /** 渲染全局明细表格 */ function renderGlobalTable(stats: AllSessionsTokenStats): void { const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null; if (tableTitleEl) tableTitleEl.textContent = '📋 会话汇总明细'; // 动态更新表头 updateTableHeader('global'); // P1 #4: 保存表格滚动位置 const tableWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null; const savedScrollTop = tableWrapEl?.scrollTop ?? 0; const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; const sessions = stats.sessions; if (sessions.length === 0) { tableEl.innerHTML = '暂无数据'; return; } // P2 #10: 统一排序 — 按 created_at 倒序(最新创建的在前) const sorted = [...sessions].sort((a, b) => b.created_at - a.created_at); // P2 #8: 限制表格行数,避免大量数据导致 DOM 性能问题 const MAX_ROWS = 50; const display = sorted.slice(0, MAX_ROWS); // 如果有截断,在表格标题中标注 if (tableTitleEl && sorted.length > MAX_ROWS) { tableTitleEl.textContent = `📋 会话汇总明细(最近 ${MAX_ROWS} 条 / 共 ${sorted.length} 条)`; } tableEl.innerHTML = display.map(s => { const total = s.total_input + s.total_output; return ` ${escapeHtml(s.title.length > 20 ? s.title.slice(0, 20) + '…' : s.title)} ${escapeHtml(s.model)} ${formatTokenCount(s.total_input)} ${formatTokenCount(s.total_output)} ${formatTokenCount(total)} ${s.round_count} ${s.created_at ? formatTime(s.created_at) : '-'} `; }).join(''); // P1 #4: 恢复表格滚动位置 if (savedScrollTop > 0) { const newWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null; if (newWrapEl) newWrapEl.scrollTop = savedScrollTop; } } /** * 计算当前已用上下文 token 数。 * 优先使用最后一条 assistant 消息的 prompt_eval_count(Ollama 精确值), * 再加上后续新增消息的估算 token(含 toolCalls/images 开销)。 * 无实际数据时回退到增强估算(全量消息内容 + toolCalls/images 开销)。 */ function computeUsedContextTokens(messages: ChatMessage[]): { usedTokens: number; isActual: boolean } { // 从末尾向前查找最后一条携带 prompt_eval_count 的 assistant 消息 let lastActualIdx = -1; for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; if (m.role === 'assistant' && m.prompt_eval_count && m.prompt_eval_count > 0) { lastActualIdx = i; break; } } if (lastActualIdx >= 0) { const lastActual = messages[lastActualIdx]; // 基准:Ollama 返回的实际输入 token + 输出 token let base = lastActual.prompt_eval_count! + (lastActual.eval_count || 0); // 累加 lastActual 之后新增消息的估算 token for (let i = lastActualIdx + 1; i < messages.length; i++) { const m = messages[i]; base += estimateTokens(m.content || ''); if (m.toolCalls?.length) base += m.toolCalls.length * 50; if (m.images?.length) base += m.images.length * 100; } return { usedTokens: base, isActual: true }; } // 无实际数据:增强估算(含 toolCalls/images 开销) let estimated = 0; for (const m of messages) { estimated += estimateTokens(m.content || ''); if (m.toolCalls?.length) estimated += m.toolCalls.length * 50; if (m.images?.length) estimated += m.images.length * 100; } return { usedTokens: estimated, isActual: false }; } /** 渲染上下文使用率环形图 */ function renderCtxRing(messages: ChatMessage[], isStreaming: boolean): void { const ringSection = dashboardModalEl.querySelector('#tdCtxRingSection') as HTMLElement; const ringEl = dashboardModalEl.querySelector('#tdCtxRing') as HTMLElement; if (!ringSection || !ringEl) return; ringSection.style.display = ''; const effectiveCtx = getEffectiveNumCtx(); const modelCtx = getModelContextLength(); const userCtx = state.get(KEYS.NUM_CTX, 131072); // ── 计算已用上下文 token ── // 优先使用 Ollama 返回的实际 prompt_eval_count(精确值), // 无实际数据时回退到增强估算(含 toolCalls/images 开销) const { usedTokens, isActual } = computeUsedContextTokens(messages); const remainTokens = Math.max(effectiveCtx - usedTokens, 0); const usagePct = effectiveCtx > 0 ? Math.min((usedTokens / effectiveCtx) * 100, 100) : 0; // SVG 环形图参数 const size = 140; const strokeWidth = 12; const radius = (size - strokeWidth) / 2; const circumference = 2 * Math.PI * radius; const dashOffset = circumference * (1 - usagePct / 100); // 颜色根据使用率变化 let ringColor = '#9B7ED8'; // 紫色 - 正常 if (usagePct >= 90) ringColor = '#E8734A'; // 珊瑚红 - 危险 else if (usagePct >= 70) ringColor = '#F0A040'; // 橙色 - 警告 // 钳制说明文字 const clampNote = modelCtx > 0 && modelCtx < userCtx ? `模型限制 ${formatTokenCount(modelCtx)} < 设置值 ${formatTokenCount(userCtx)},采用 ${formatTokenCount(effectiveCtx)}` : `设置值 ${formatTokenCount(userCtx)}`; ringEl.innerHTML = `
${usagePct.toFixed(0)}% 已用 / 总量
已用上下文 ${formatTokenCount(usedTokens)}
剩余上下文 ${formatTokenCount(remainTokens)}
有效上下文窗口 ${formatTokenCount(effectiveCtx)}
${escapeHtml(clampNote)}
${isActual ? '✅ 基于 Ollama 实际计数' : '⚙️ 基于估算(含工具/图片开销)'}
${isStreaming ? '
⚡ 实时更新中…
' : ''}
`; } /** 渲染当前会话视图 */ function renderSessionView(): void { const session = state.get(KEYS.CURRENT_SESSION); const messages = session?.messages || []; const isStreaming = state.get(KEYS.IS_STREAMING, false); 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; // P3 #12: 从最后一条 assistant 消息获取实际使用的模型 const lastAssistantMsg = [...messages].reverse().find(m => m.role === 'assistant' && m.model); const currentModel = lastAssistantMsg?.model || session?.model || '-'; // 渲染上下文使用率环形图 renderCtxRing(messages, isStreaming); // 概览区域 const overviewEl = dashboardModalEl.querySelector('#tdOverview')!; overviewEl.innerHTML = `
🔥
${formatTokenCount(totalTokens)}
会话 Token 消耗
输入 ${formatTokenCount(totalInputTokens)} / 输出 ${formatTokenCount(totalOutputTokens)}
💬
${userMsgs.length} / ${aiMsgs.length}
用户 / AI 消息
📊
${formatTokenCount(avgTokens)}
平均 Token/轮
⏱️
${formatDuration(totalDurationNs)}
总推理耗时
🤖
${escapeHtml(currentModel)}
当前模型
`; // 柱状图 const chartEl = dashboardModalEl.querySelector('#tdChart') as HTMLElement; const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null; const savedScrollLeft = barsEl?.scrollLeft ?? 0; // P4: 限制图表柱子数量,避免长会话性能下降 const MAX_CHART_ROUNDS = 50; const displayRounds = rounds.length > MAX_CHART_ROUNDS ? rounds.slice(-MAX_CHART_ROUNDS) : rounds; const chartData = displayRounds.map(r => ({ label: `R${r.round}`, input: r.prompt_eval_count, output: r.eval_count, tooltip: `第${r.round}轮${r.llmCallCount > 1 ? ` · ${r.llmCallCount}次调用` : ''} · 输入 ${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 消耗趋势 — 按轮次'; // P4 #6/#7: 图表 note — 显示轮次总数 + 流式提示 const noteParts: string[] = []; if (rounds.length > MAX_CHART_ROUNDS) noteParts.push(`最近 ${MAX_CHART_ROUNDS} / 共 ${rounds.length} 轮`); else noteParts.push(`共 ${rounds.length} 轮`); if (isStreaming) noteParts.push('⚡ AI 回复中…'); renderBarChart(chartEl, chartData, noteParts.join(' · '), savedScrollLeft); // 表格标题 const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null; // P1 #6: 流式输出期间显示提示 if (tableTitleEl) { tableTitleEl.textContent = isStreaming ? '📋 轮次明细(按时间倒序)— ⚡ AI 回复中,数据实时更新…' : '📋 轮次明细(按时间倒序)'; } // 动态更新表头 updateTableHeader('session'); // P1 #4: 保存表格滚动位置 const tableWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null; const savedScrollTop = tableWrapEl?.scrollTop ?? 0; // 明细表格 const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; if (rounds.length === 0) { tableEl.innerHTML = isStreaming ? '⏳ AI 正在回复中,暂无统计数据…' : '暂无数据'; } else { const sorted = [...rounds].reverse(); tableEl.innerHTML = sorted.map(r => { const total = r.eval_count + r.prompt_eval_count; return ` R${r.round} ${r.llmCallCount > 1 ? `${r.llmCallCount}次` : '1次'}${r.model ? ` ${escapeHtml(r.model)}` : ''} ${formatTokenCount(r.prompt_eval_count)} ${formatTokenCount(r.eval_count)} ${formatTokenCount(total)} ${formatDuration(r.total_duration)} ${r.timestamp ? formatTime(r.timestamp) : '-'} `; }).join(''); } // P1 #4: 恢复表格滚动位置 if (savedScrollTop > 0) { const newWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null; if (newWrapEl) newWrapEl.scrollTop = savedScrollTop; } } /** 动态更新表头列 */ 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 = ` 轮次 调用 输入 输出 合计 耗时 时间 `; } }