diff --git a/src/main/db/sqlite.ts b/src/main/db/sqlite.ts index e90436e..987273e 100644 --- a/src/main/db/sqlite.ts +++ b/src/main/db/sqlite.ts @@ -580,6 +580,70 @@ export function incrementSkillUsage(id: string, success: boolean, durationMs: nu persist(); } +// ─── Token 全局统计 ─── + +export interface SessionTokenStat { + session_id: string; + title: string; + model: string; + created_at: number; + total_input: number; + total_output: number; + total_duration: number; + round_count: number; +} + +export interface GlobalTokenTotals { + total_input: number; + total_output: number; + total_duration: number; + total_rounds: number; + session_count: number; +} + +export interface AllSessionsTokenStats { + sessions: SessionTokenStat[]; + totals: GlobalTokenTotals; +} + +export function getAllSessionsTokenStats(): AllSessionsTokenStats { + const d = getDb(); + const sessionStats = queryAll(d, ` + SELECT + s.id as session_id, + s.title, + s.model, + s.created_at, + COALESCE(SUM(m.prompt_eval_count), 0) as total_input, + COALESCE(SUM(m.eval_count), 0) as total_output, + COALESCE(SUM(m.total_duration), 0) as total_duration, + COUNT(CASE WHEN m.role = 'assistant' AND (m.eval_count > 0 OR m.prompt_eval_count > 0) THEN 1 END) as round_count + FROM sessions s + LEFT JOIN messages m ON s.id = m.session_id + GROUP BY s.id + ORDER BY s.updated_at DESC + `); + + let totalInput = 0, totalOutput = 0, totalDuration = 0, totalRounds = 0; + for (const s of sessionStats) { + totalInput += (s.total_input as number) || 0; + totalOutput += (s.total_output as number) || 0; + totalDuration += (s.total_duration as number) || 0; + totalRounds += (s.round_count as number) || 0; + } + + return { + sessions: sessionStats as unknown as SessionTokenStat[], + totals: { + total_input: totalInput, + total_output: totalOutput, + total_duration: totalDuration, + total_rounds: totalRounds, + session_count: sessionStats.length, + } + }; +} + // ─── Export/Import ─── export interface ExportData { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index fdc448b..c40091c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -15,7 +15,8 @@ import { saveToolCall, getToolCallsBySession, saveTrace, getTracesBySession, saveSkill, getAllSkills, deleteSkill, clearAllSkills, searchSkills, incrementSkillUsage, - exportAllSessions, importSessions + exportAllSessions, importSessions, + getAllSessionsTokenStats } from './db/sqlite.js'; import type { ExportData } from './db/sqlite.js'; @@ -421,6 +422,12 @@ export async function setupIPC(): Promise { catch (err) { return { success: false, error: (err as Error).message }; } }); + // ── Token 全局统计 ── + ipcMain.handle('db:getAllTokenStats', () => { + try { return getAllSessionsTokenStats(); } + catch (err) { sendLog('error', '获取全局 Token 统计失败', (err as Error).message); return null; } + }); + // ── MCP IPC ── ipcMain.handle('mcp:startServer', async (_, config) => { return startServer(config); diff --git a/src/main/preload.ts b/src/main/preload.ts index 1ad5f5b..7da1ce3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -73,6 +73,7 @@ contextBridge.exposeInMainWorld('metonaDesktop', { clearAllSkills: () => ipcRenderer.invoke('db:clearAllSkills'), searchSkills: (query: string, limit?: number) => ipcRenderer.invoke('db:searchSkills', query, limit), incrementSkillUsage: (id: string, success: boolean, durationMs: number) => ipcRenderer.invoke('db:incrementSkillUsage', id, success, durationMs), + getAllTokenStats: () => ipcRenderer.invoke('db:getAllTokenStats'), }, workspace: { getDir: () => ipcRenderer.invoke('workspace:getDir'), diff --git a/src/renderer/components/token-dashboard.ts b/src/renderer/components/token-dashboard.ts index f9ef846..687b11a 100644 --- a/src/renderer/components/token-dashboard.ts +++ b/src/renderer/components/token-dashboard.ts @@ -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 | 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 { + 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 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 = '
暂无会话数据
'; + 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 ` +
+
+ ${formatTokenCount(s.total_output)} + ${formatTokenCount(s.total_input)} +
+
+
+
+
+
+
+
+
${title}
+
+ `; + }).join(''); + + chartEl.innerHTML = ` +
+ 输出 Token + 输入 Token + ${sessions.length > 20 ? `(仅显示最近 20 个会话)` : ''} +
+
+
+ ${formatTokenCount(maxTokens)} + ${formatTokenCount(Math.round(maxTokens / 2))} + 0 +
+
${barsHtml}
+
+ `; +} + +/** 渲染全局明细表格 */ +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 = '暂无数据'; + 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 || []; @@ -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 = ` -
-
🔥
-
${formatTokenCount(totalTokens)}
-
总 Token 消耗
-
输入 ${formatTokenCount(totalInputTokens)} / 输出 ${formatTokenCount(totalOutputTokens)}
+
+
+
🔥
+
${formatTokenCount(totalTokens)}
+
会话 Token 消耗
+
输入 ${formatTokenCount(totalInputTokens)} / 输出 ${formatTokenCount(totalOutputTokens)}
+
+
+
💬
+
${userMsgs.length} / ${aiMsgs.length}
+
用户 / AI 消息
+
+
+
📊
+
${formatTokenCount(avgTokens)}
+
平均 Token/轮
+
+
+
⏱️
+
${formatDuration(totalDurationNs)}
+
总推理耗时
+
-
-
💬
-
${userMsgs.length} / ${aiMsgs.length}
-
用户 / AI 消息
-
-
-
📊
-
${formatTokenCount(avgTokens)}
-
平均 Token/轮
-
-
-
⏱️
-
${formatDuration(totalDurationNs)}
-
总推理耗时
+
+
+
🤖
+
${session?.model || '-'}
+
当前模型
+
`; - // 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 = '暂无数据'; + tableEl.innerHTML = '暂无数据'; } 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 = ` + 会话 + 模型 + 输入 + 输出 + 合计 + 轮次 + 创建时间 + `; + } else { + thead.innerHTML = ` + 轮次 + 角色 + 输入 + 输出 + 合计 + 耗时 + 时间 + `; + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 2b2e429..f1b5f60 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -599,12 +599,16 @@