v0.14.13: Token Dashboard 上下文计算修复 + 校准比例修正 + 已有TS错误修复

This commit is contained in:
紫影233
2026-07-10 16:14:38 +08:00
parent c824562f68
commit 91a30193a7
16 changed files with 431 additions and 162 deletions
+219 -28
View File
@@ -5,7 +5,9 @@
import { state, KEYS } from '../state/state.js';
import { formatTime } from '../utils/utils.js';
import { logError, logInfo } from '../services/log-service.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;
@@ -37,14 +39,21 @@ interface AllSessionsTokenStats {
totals: GlobalTokenTotals;
}
let globalStatsCache: AllSessionsTokenStats | null = null;
let globalStatsLoaded = false;
/** P1 #5: HTML 转义,防止 XSS */
function escapeHtml(s: unknown): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** 打开仪表盘 */
export function openTokenDashboard(): void {
if (!dashboardModalEl) return;
dashboardModalEl.style.display = '';
globalStatsLoaded = false;
renderDashboard();
startAutoRefresh();
}
@@ -78,7 +87,6 @@ export function initTokenDashboard(): void {
toggleEl.querySelectorAll('.td-view-btn').forEach(btn => btn.classList.remove('active'));
target.classList.add('active');
// 切换时刷新
if (viewMode === 'global') globalStatsLoaded = false;
renderDashboard();
}
});
@@ -143,16 +151,19 @@ function formatTokenCount(n: number): string {
return String(n);
}
/** 获取全局统计数据(带缓存 */
/** P1 #3: 上次成功获取全局数据(用于刷新期间保持旧数据可见,避免闪烁 */
let lastSuccessfulStats: AllSessionsTokenStats | null = null;
let hasEverLoaded = false;
/** P1 #3: 获取全局统计数据 — 每次都重新查询,不做永久缓存 */
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;
lastSuccessfulStats = result;
hasEverLoaded = true;
return result;
}
} catch (err) {
@@ -162,6 +173,10 @@ async function fetchGlobalStats(): Promise<AllSessionsTokenStats | 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 {
@@ -173,7 +188,14 @@ function renderDashboard(): void {
function renderGlobalView(): void {
fetchGlobalStats().then(stats => {
if (!stats) {
renderGlobalFallback();
// P1 #3/#9: 刷新失败时保留旧数据,仅在从未加载成功时显示错误
if (hasEverLoaded && lastSuccessfulStats) {
renderGlobalOverview(lastSuccessfulStats);
renderGlobalChart(lastSuccessfulStats);
renderGlobalTable(lastSuccessfulStats);
} else {
renderGlobalFallback(true);
}
return;
}
renderGlobalOverview(stats);
@@ -183,19 +205,20 @@ function renderGlobalView(): void {
}
/** 全局统计获取失败时的降级处理 */
function renderGlobalFallback(): void {
function renderGlobalFallback(isError = false): void {
const overviewEl = dashboardModalEl.querySelector('#tdOverview')!;
const msg = isError ? '全局统计加载失败,正在重试…' : '正在加载全局数据…';
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 class="td-stat-label">${msg}</div>
</div>
`;
const chartEl = dashboardModalEl.querySelector('#tdChart')!;
chartEl.innerHTML = '<div class="td-empty">正在加载全局数据…</div>';
chartEl.innerHTML = `<div class="td-empty">${msg}</div>`;
const tableEl = dashboardModalEl.querySelector('#tdTableBody')!;
tableEl.innerHTML = '<tr><td colspan="7" class="td-empty-row">正在加载…</td></tr>';
tableEl.innerHTML = `<tr><td colspan="7" class="td-empty-row">${msg}</td></tr>`;
}
/** 渲染全局概览卡片 */
@@ -239,7 +262,6 @@ function renderGlobalOverview(stats: AllSessionsTokenStats): void {
function renderBarChart(
chartEl: HTMLElement,
data: Array<{ label: string; input: number; output: number; tooltip: string }>,
title: string,
note?: string,
savedScrollLeft?: number
): void {
@@ -264,7 +286,7 @@ function renderBarChart(
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-col" title="${escapeHtml(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>
@@ -275,7 +297,7 @@ function renderBarChart(
<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 class="td-bar-label">${escapeHtml(d.label)}</div>
</div>
`;
}).join('');
@@ -284,7 +306,7 @@ function renderBarChart(
<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>` : ''}
${note ? `<span class="td-legend-item td-legend-note">${escapeHtml(note)}</span>` : ''}
</div>
<div class="td-chart-grid">
${gridLines}
@@ -314,7 +336,7 @@ function renderGlobalChart(stats: AllSessionsTokenStats): void {
return;
}
// 按创建时间正序排列,取最近 30 个会话
// P2 #10: 统一排序 — 按 created_at 正序(图表左→右为时间递增)
const sorted = [...sessions].sort((a, b) => a.created_at - b.created_at).slice(-30);
const chartData = sorted.map(s => ({
@@ -324,7 +346,7 @@ function renderGlobalChart(stats: AllSessionsTokenStats): void {
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);
renderBarChart(chartEl, chartData, `最近 ${sorted.length} 个会话`, savedScrollLeft);
}
/** 渲染全局明细表格 */
@@ -335,6 +357,10 @@ function renderGlobalTable(stats: AllSessionsTokenStats): void {
// 动态更新表头
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;
@@ -343,12 +369,24 @@ function renderGlobalTable(stats: AllSessionsTokenStats): void {
return;
}
tableEl.innerHTML = sessions.map(s => {
// 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 `
<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 title="${escapeHtml(s.title)}">${escapeHtml(s.title.length > 20 ? s.title.slice(0, 20) + '…' : s.title)}</td>
<td><span class="td-model-tag">${escapeHtml(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>
@@ -357,12 +395,141 @@ function renderGlobalTable(stats: AllSessionsTokenStats): void {
</tr>
`;
}).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_countOllama 精确值),
* 再加上后续新增消息的估算 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<number>(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 = `
<div class="td-ctx-ring-container">
<svg class="td-ctx-ring-svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<circle class="td-ctx-ring-bg" cx="${size/2}" cy="${size/2}" r="${radius}"
fill="none" stroke="var(--border-subtle)" stroke-width="${strokeWidth}"/>
<circle class="td-ctx-ring-fg" cx="${size/2}" cy="${size/2}" r="${radius}"
fill="none" stroke="${ringColor}" stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-dasharray="${circumference}"
stroke-dashoffset="${dashOffset}"
transform="rotate(-90 ${size/2} ${size/2})"
style="transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease;"/>
<text class="td-ctx-ring-pct" x="${size/2}" y="${size/2 - 4}" text-anchor="middle"
fill="${ringColor}" font-size="22" font-weight="700" font-family="var(--font-mono)">
${usagePct.toFixed(0)}%
</text>
<text class="td-ctx-ring-label" x="${size/2}" y="${size/2 + 16}" text-anchor="middle"
fill="var(--text-tertiary)" font-size="10">
已用 / 总量
</text>
</svg>
<div class="td-ctx-ring-details">
<div class="td-ctx-ring-row">
<span class="td-ctx-ring-dot td-ctx-dot-used"></span>
<span class="td-ctx-ring-label-text">已用上下文</span>
<span class="td-ctx-ring-val">${formatTokenCount(usedTokens)}</span>
</div>
<div class="td-ctx-ring-row">
<span class="td-ctx-ring-dot td-ctx-dot-remain"></span>
<span class="td-ctx-ring-label-text">剩余上下文</span>
<span class="td-ctx-ring-val">${formatTokenCount(remainTokens)}</span>
</div>
<div class="td-ctx-ring-row td-ctx-ring-total">
<span class="td-ctx-ring-label-text">有效上下文窗口</span>
<span class="td-ctx-ring-val">${formatTokenCount(effectiveCtx)}</span>
</div>
<div class="td-ctx-ring-note">${escapeHtml(clampNote)}</div>
<div class="td-ctx-ring-source">${isActual ? '✅ 基于 Ollama 实际计数' : '⚙️ 基于估算(含工具/图片开销)'}</div>
${isStreaming ? '<div class="td-ctx-ring-streaming">⚡ 实时更新中…</div>' : ''}
</div>
</div>
`;
}
/** 渲染当前会话视图 */
function renderSessionView(): void {
const session = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
const messages = session?.messages || [];
const isStreaming = state.get<boolean>(KEYS.IS_STREAMING, false);
const userMsgs = messages.filter(m => m.role === 'user');
const aiMsgs = messages.filter(m => m.role === 'assistant');
@@ -374,6 +541,13 @@ function renderSessionView(): void {
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 = `
@@ -400,7 +574,7 @@ function renderSessionView(): void {
</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-value">${escapeHtml(currentModel)}</div>
<div class="td-stat-label">当前模型</div>
</div>
`;
@@ -420,19 +594,30 @@ function renderSessionView(): void {
const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle');
if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按轮次';
renderBarChart(chartEl, chartData, '按轮次', undefined, savedScrollLeft);
renderBarChart(chartEl, chartData, undefined, savedScrollLeft);
// 表格标题
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null;
if (tableTitleEl) tableTitleEl.textContent = '📋 轮次明细(按时间倒序)';
// 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 = '<tr><td colspan="7" class="td-empty-row">暂无数据</td></tr>';
tableEl.innerHTML = isStreaming
? '<tr><td colspan="7" class="td-empty-row">⏳ AI 正在回复中,暂无统计数据…</td></tr>'
: '<tr><td colspan="7" class="td-empty-row">暂无数据</td></tr>';
} else {
const sorted = [...rounds].reverse();
tableEl.innerHTML = sorted.map(r => {
@@ -440,7 +625,7 @@ function renderSessionView(): void {
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><span class="td-role-badge">🤖 AI</span>${r.model ? ` <span class="td-model-tag">${escapeHtml(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>
@@ -450,6 +635,12 @@ function renderSessionView(): void {
`;
}).join('');
}
// P1 #4: 恢复表格滚动位置
if (savedScrollTop > 0) {
const newWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null;
if (newWrapEl) newWrapEl.scrollTop = savedScrollTop;
}
}
/** 动态更新表头列 */