Files
metona-ollama-desktop/src/renderer/components/token-dashboard.ts
T
thzxx db6cb8d2cc feat: v1.1.0 — 上下文引擎重构 + 技能系统升级 + SOUL.md 支持
- 版本号更新: 1.0.0 → 1.1.0,全量同步 package.json/lock/README/docs/UI
- 上下文长度自动检测: 切换模型时从 model_info 读取实际 context_length
- SOUL.md 支持: 每次发送自动扫描工作空间 SOUL.md,注入为首条 system 消息且不可压缩
- 系统提示词卡片: AI 回复顶部可折叠展示实际发送给模型的完整 system prompt
- Token 校准: 利用 Ollama 返回的实际计数动态修正估算器(EMA)
- 消息重要性评分: 纯规则打分,trim/summarize 按重要性而非时间顺序
- 结构化压缩: LLM 压缩输出 JSON 格式(topics/decisions/pendingTasks/constraints)
- 技能系统 v1.1: 语义匹配(embedding) + 参数自优化 + 未使用衰减 + 技能链合并
- 修复: 100+ TypeScript strict 模式编译错误清零
2026-06-04 21:47:18 +08:00

482 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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 {
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<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 {
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-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 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>
`;
}
/** 渲染柱状图(通用,全局/会话复用) */
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 = '<div class="td-empty">暂无数据</div>';
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 `<div class="td-gridline" style="bottom:${ratio * 100}%">
<span class="td-gridline-label">${formatTokenCount(val)}</span>
</div>`;
}).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 `
<div class="td-bar-col" title="${d.tooltip}">
<div class="td-bar-area">
<div class="td-bar-stack" style="height:${heightPct}%;">
<div class="td-bar-segment td-bar-output" style="height:${outputPct}%;"></div>
<div class="td-bar-segment td-bar-input-seg" style="height:${inputPct}%;"></div>
</div>
</div>
<div class="td-bar-labels">
<span class="td-val-output">${formatTokenCount(d.output)}</span>
<span class="td-val-input">${formatTokenCount(d.input)}</span>
</div>
<div class="td-bar-label">${d.label}</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>
${note ? `<span class="td-legend-item td-legend-note">${note}</span>` : ''}
</div>
<div class="td-chart-grid">
${gridLines}
<div class="td-chart-bars">${barsHtml}</div>
</div>
`;
// 恢复滚动位置
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 = '<div class="td-empty">暂无数据</div>';
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 = '<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 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 = `
<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(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 class="td-stat-card td-stat-card-small">
<div class="td-stat-icon">🤖</div>
<div class="td-stat-value">${session?.model || '-'}</div>
<div class="td-stat-label">当前模型</div>
</div>
`;
// 柱状图
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 = '<tr><td colspan="7" class="td-empty-row">暂无数据</td></tr>';
} else {
const sorted = [...rounds].reverse();
tableEl.innerHTML = sorted.map(r => {
const total = r.eval_count + r.prompt_eval_count;
return `
<tr>
<td>R${r.round}</td>
<td><span class="td-role-badge">🤖 AI</span>${r.model ? ` <span class="td-model-tag">${r.model}</span>` : ''}</td>
<td class="td-num">${formatTokenCount(r.prompt_eval_count)}</td>
<td class="td-num">${formatTokenCount(r.eval_count)}</td>
<td class="td-num td-num-total">${formatTokenCount(total)}</td>
<td class="td-num">${formatDuration(r.total_duration)}</td>
<td class="td-time">${r.timestamp ? formatTime(r.timestamp) : '-'}</td>
</tr>
`;
}).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>
`;
}
}