feat: Token 仪表盘支持全局统计,跨会话累计 Token 分析
This commit is contained in:
@@ -580,6 +580,70 @@ export function incrementSkillUsage(id: string, success: boolean, durationMs: nu
|
|||||||
persist();
|
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/Import ───
|
||||||
|
|
||||||
export interface ExportData {
|
export interface ExportData {
|
||||||
|
|||||||
+8
-1
@@ -15,7 +15,8 @@ import {
|
|||||||
saveToolCall, getToolCallsBySession,
|
saveToolCall, getToolCallsBySession,
|
||||||
saveTrace, getTracesBySession,
|
saveTrace, getTracesBySession,
|
||||||
saveSkill, getAllSkills, deleteSkill, clearAllSkills, searchSkills, incrementSkillUsage,
|
saveSkill, getAllSkills, deleteSkill, clearAllSkills, searchSkills, incrementSkillUsage,
|
||||||
exportAllSessions, importSessions
|
exportAllSessions, importSessions,
|
||||||
|
getAllSessionsTokenStats
|
||||||
} from './db/sqlite.js';
|
} from './db/sqlite.js';
|
||||||
import type { ExportData } from './db/sqlite.js';
|
import type { ExportData } from './db/sqlite.js';
|
||||||
|
|
||||||
@@ -421,6 +422,12 @@ export async function setupIPC(): Promise<void> {
|
|||||||
catch (err) { return { success: false, error: (err as Error).message }; }
|
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 ──
|
// ── MCP IPC ──
|
||||||
ipcMain.handle('mcp:startServer', async (_, config) => {
|
ipcMain.handle('mcp:startServer', async (_, config) => {
|
||||||
return startServer(config);
|
return startServer(config);
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
|
|||||||
clearAllSkills: () => ipcRenderer.invoke('db:clearAllSkills'),
|
clearAllSkills: () => ipcRenderer.invoke('db:clearAllSkills'),
|
||||||
searchSkills: (query: string, limit?: number) => ipcRenderer.invoke('db:searchSkills', query, limit),
|
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),
|
incrementSkillUsage: (id: string, success: boolean, durationMs: number) => ipcRenderer.invoke('db:incrementSkillUsage', id, success, durationMs),
|
||||||
|
getAllTokenStats: () => ipcRenderer.invoke('db:getAllTokenStats'),
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
getDir: () => ipcRenderer.invoke('workspace:getDir'),
|
getDir: () => ipcRenderer.invoke('workspace:getDir'),
|
||||||
|
|||||||
@@ -1,19 +1,50 @@
|
|||||||
/**
|
/**
|
||||||
* TokenDashboard - Token 实时监控仪表盘
|
* TokenDashboard - Token 实时监控仪表盘
|
||||||
* 显示当前会话的 Token 消耗统计、趋势图和明细表
|
* 支持全局统计(跨会话累计)和当前会话统计双视图切换
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { state, KEYS } from '../state/state.js';
|
import { state, KEYS } from '../state/state.js';
|
||||||
import { formatTime } from '../utils/utils.js';
|
import { formatTime } from '../utils/utils.js';
|
||||||
|
import { logError, logInfo } from '../services/log-service.js';
|
||||||
import type { ChatSession, ChatMessage } from '../types.js';
|
import type { ChatSession, ChatMessage } from '../types.js';
|
||||||
|
|
||||||
let dashboardModalEl: HTMLElement;
|
let dashboardModalEl: HTMLElement;
|
||||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
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 {
|
export function openTokenDashboard(): void {
|
||||||
if (!dashboardModalEl) return;
|
if (!dashboardModalEl) return;
|
||||||
dashboardModalEl.style.display = '';
|
dashboardModalEl.style.display = '';
|
||||||
|
globalStatsLoaded = false;
|
||||||
renderDashboard();
|
renderDashboard();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
}
|
}
|
||||||
@@ -34,6 +65,23 @@ export function initTokenDashboard(): void {
|
|||||||
dashboardModalEl.addEventListener('click', (e) => {
|
dashboardModalEl.addEventListener('click', (e) => {
|
||||||
if (e.target === dashboardModalEl) closeTokenDashboard();
|
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 {
|
function startAutoRefresh(): void {
|
||||||
@@ -95,7 +143,194 @@ function formatTokenCount(n: number): string {
|
|||||||
return String(n);
|
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 {
|
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 session = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||||
const messages = session?.messages || [];
|
const messages = session?.messages || [];
|
||||||
|
|
||||||
@@ -109,35 +344,47 @@ function renderDashboard(): void {
|
|||||||
const totalDurationNs = rounds.reduce((sum, r) => sum + r.total_duration, 0);
|
const totalDurationNs = rounds.reduce((sum, r) => sum + r.total_duration, 0);
|
||||||
const avgTokens = rounds.length > 0 ? Math.round(totalTokens / rounds.length) : 0;
|
const avgTokens = rounds.length > 0 ? Math.round(totalTokens / rounds.length) : 0;
|
||||||
|
|
||||||
// A. 概览区域
|
// 概览区域
|
||||||
const overviewEl = dashboardModalEl.querySelector('#tdOverview')!;
|
const overviewEl = dashboardModalEl.querySelector('#tdOverview')!;
|
||||||
overviewEl.innerHTML = `
|
overviewEl.innerHTML = `
|
||||||
<div class="td-stat-card">
|
<div class="td-stat-row td-stat-row-global">
|
||||||
<div class="td-stat-icon">🔥</div>
|
<div class="td-stat-card">
|
||||||
<div class="td-stat-value">${formatTokenCount(totalTokens)}</div>
|
<div class="td-stat-icon">🔥</div>
|
||||||
<div class="td-stat-label">总 Token 消耗</div>
|
<div class="td-stat-value">${formatTokenCount(totalTokens)}</div>
|
||||||
<div class="td-stat-detail">输入 ${formatTokenCount(totalInputTokens)} / 输出 ${formatTokenCount(totalOutputTokens)}</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>
|
||||||
<div class="td-stat-card">
|
<div class="td-stat-row td-stat-row-session">
|
||||||
<div class="td-stat-icon">💬</div>
|
<div class="td-stat-card td-stat-card-small">
|
||||||
<div class="td-stat-value">${userMsgs.length} / ${aiMsgs.length}</div>
|
<div class="td-stat-icon">🤖</div>
|
||||||
<div class="td-stat-label">用户 / AI 消息</div>
|
<div class="td-stat-value">${session?.model || '-'}</div>
|
||||||
</div>
|
<div class="td-stat-label">当前模型</div>
|
||||||
<div class="td-stat-card">
|
</div>
|
||||||
<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>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// B. 柱状图
|
// 柱状图标题
|
||||||
|
const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle');
|
||||||
|
if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按轮次';
|
||||||
|
|
||||||
|
// 柱状图
|
||||||
const chartEl = dashboardModalEl.querySelector('#tdChart')!;
|
const chartEl = dashboardModalEl.querySelector('#tdChart')!;
|
||||||
// 保存滚动位置(每 2 秒重建 HTML 会重置滚动)
|
|
||||||
const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null;
|
const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null;
|
||||||
const savedScrollLeft = barsEl?.scrollLeft ?? 0;
|
const savedScrollLeft = barsEl?.scrollLeft ?? 0;
|
||||||
if (rounds.length === 0) {
|
if (rounds.length === 0) {
|
||||||
@@ -189,12 +436,18 @@ function renderDashboard(): void {
|
|||||||
if (newBarsEl) newBarsEl.scrollLeft = savedScrollLeft;
|
if (newBarsEl) newBarsEl.scrollLeft = savedScrollLeft;
|
||||||
}
|
}
|
||||||
|
|
||||||
// C. 明细表格
|
// 表格标题
|
||||||
|
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle');
|
||||||
|
if (tableTitleEl) tableTitleEl.textContent = '📋 轮次明细(按时间倒序)';
|
||||||
|
|
||||||
|
// 动态更新表头
|
||||||
|
updateTableHeader('session');
|
||||||
|
|
||||||
|
// 明细表格
|
||||||
const tableEl = dashboardModalEl.querySelector('#tdTableBody')!;
|
const tableEl = dashboardModalEl.querySelector('#tdTableBody')!;
|
||||||
if (rounds.length === 0) {
|
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 {
|
} else {
|
||||||
// 按时间倒序
|
|
||||||
const sorted = [...rounds].reverse();
|
const sorted = [...rounds].reverse();
|
||||||
tableEl.innerHTML = sorted.map(r => {
|
tableEl.innerHTML = sorted.map(r => {
|
||||||
const total = r.eval_count + r.prompt_eval_count;
|
const total = r.eval_count + r.prompt_eval_count;
|
||||||
@@ -212,3 +465,31 @@ function renderDashboard(): void {
|
|||||||
}).join('');
|
}).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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -599,12 +599,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="max-height:75vh;overflow-y:auto;">
|
<div class="modal-body" style="max-height:75vh;overflow-y:auto;">
|
||||||
<div class="td-overview" id="tdOverview"></div>
|
<div class="td-overview" id="tdOverview"></div>
|
||||||
|
<div class="td-view-toggle" id="tdViewToggle">
|
||||||
|
<button class="td-view-btn active" data-view="global">🌍 全局统计</button>
|
||||||
|
<button class="td-view-btn" data-view="session">💬 当前会话</button>
|
||||||
|
</div>
|
||||||
<div class="td-section">
|
<div class="td-section">
|
||||||
<div class="td-section-title">📈 Token 消耗趋势</div>
|
<div class="td-section-title" id="tdChartTitle">📈 Token 消耗趋势 — 全局</div>
|
||||||
<div class="td-chart" id="tdChart"></div>
|
<div class="td-chart" id="tdChart"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="td-section">
|
<div class="td-section">
|
||||||
<div class="td-section-title">📋 明细(按时间倒序)</div>
|
<div class="td-section-title" id="tdTableTitle">📋 明细(按时间倒序)</div>
|
||||||
<div class="td-table-wrap">
|
<div class="td-table-wrap">
|
||||||
<table class="td-table">
|
<table class="td-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -3532,6 +3532,68 @@ html, body {
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 视图切换按钮 */
|
||||||
|
.td-view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-view-btn {
|
||||||
|
padding: 6px 18px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-view-btn:hover {
|
||||||
|
background: var(--bg-hover, var(--bg-card));
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-view-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 概览行布局 */
|
||||||
|
.td-stat-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-stat-row-global {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-stat-row-session {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-stat-card-small {
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-stat-card-small .td-stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图例备注 */
|
||||||
|
.td-legend-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* 区块标题 */
|
/* 区块标题 */
|
||||||
.td-section {
|
.td-section {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|||||||
Vendored
+19
@@ -438,6 +438,25 @@ export interface DBAPI {
|
|||||||
clearAllSkills: () => Promise<{ success: boolean; error?: string }>;
|
clearAllSkills: () => Promise<{ success: boolean; error?: string }>;
|
||||||
searchSkills: (query: string, limit?: number) => Promise<unknown[]>;
|
searchSkills: (query: string, limit?: number) => Promise<unknown[]>;
|
||||||
incrementSkillUsage: (id: string, success: boolean, durationMs: number) => Promise<{ success: boolean; error?: string }>;
|
incrementSkillUsage: (id: string, success: boolean, durationMs: number) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
getAllTokenStats(): Promise<{
|
||||||
|
sessions: Array<{
|
||||||
|
session_id: string;
|
||||||
|
title: string;
|
||||||
|
model: string;
|
||||||
|
created_at: number;
|
||||||
|
total_input: number;
|
||||||
|
total_output: number;
|
||||||
|
total_duration: number;
|
||||||
|
round_count: number;
|
||||||
|
}>;
|
||||||
|
totals: {
|
||||||
|
total_input: number;
|
||||||
|
total_output: number;
|
||||||
|
total_duration: number;
|
||||||
|
total_rounds: number;
|
||||||
|
session_count: number;
|
||||||
|
};
|
||||||
|
} | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Window global 声明 ──
|
// ── Window global 声明 ──
|
||||||
|
|||||||
Reference in New Issue
Block a user