From 91a30193a7885c7800dbb4116b62c417cbb1374f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=B4=AB=E5=BD=B1233?=
Date: Fri, 10 Jul 2026 16:14:38 +0800
Subject: [PATCH] =?UTF-8?q?=20v0.14.13:=20Token=20Dashboard=20=E4=B8=8A?=
=?UTF-8?q?=E4=B8=8B=E6=96=87=E8=AE=A1=E7=AE=97=E4=BF=AE=E5=A4=8D=20+=20?=
=?UTF-8?q?=E6=A0=A1=E5=87=86=E6=AF=94=E4=BE=8B=E4=BF=AE=E6=AD=A3=20+=20?=
=?UTF-8?q?=E5=B7=B2=E6=9C=89TS=E9=94=99=E8=AF=AF=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 6 +-
package-lock.json | 4 +-
package.json | 2 +-
src/main/menu.ts | 2 +-
src/renderer/components/input-area.ts | 51 ++---
src/renderer/components/model-bar.ts | 32 +--
src/renderer/components/settings-modal.ts | 4 +-
src/renderer/components/token-dashboard.ts | 247 ++++++++++++++++++---
src/renderer/index.html | 8 +-
src/renderer/main.ts | 8 +-
src/renderer/services/agent-engine.ts | 51 +++--
src/renderer/services/context-manager.ts | 7 +-
src/renderer/services/memory-service.ts | 2 +-
src/renderer/services/sub-agent.ts | 28 +--
src/renderer/styles/style.css | 135 ++++++++---
src/renderer/types.d.ts | 6 +
16 files changed, 431 insertions(+), 162 deletions(-)
diff --git a/README.md b/README.md
index 553e37c..0faef20 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -253,7 +253,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-产出:`release/Metona Ollama Setup v0.14.12.exe`
+产出:`release/Metona Ollama Setup v0.14.13.exe`
## 🛠️ 常用命令
@@ -501,7 +501,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-Output: `release/Metona Ollama Setup v0.14.12.exe`
+Output: `release/Metona Ollama Setup v0.14.13.exe`
## 🛠️ Common Commands
diff --git a/package-lock.json b/package-lock.json
index 35f7b81..3b5f493 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
- "version": "0.14.12",
+ "version": "0.14.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
- "version": "0.14.12",
+ "version": "0.14.13",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
diff --git a/package.json b/package.json
index 87be502..9f51d19 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
- "version": "0.14.12",
+ "version": "0.14.13",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
diff --git a/src/main/menu.ts b/src/main/menu.ts
index 50b5980..0debb41 100644
--- a/src/main/menu.ts
+++ b/src/main/menu.ts
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 Metona Ollama',
- message: 'Metona Ollama Desktop v0.14.12',
+ message: 'Metona Ollama Desktop v0.14.13',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts
index 8ca6f13..e9ac1d7 100644
--- a/src/renderer/components/input-area.ts
+++ b/src/renderer/components/input-area.ts
@@ -530,7 +530,7 @@ async function handleRetry(): Promise {
result: null, status: 'pending', timestamp: Date.now()
});
},
- onNewIteration: (toolCalls) => {
+ onNewIteration: (toolCalls, stats) => {
removeCurrentPlaceholder();
const hasContent = !!retryContent?.trim();
if (hasContent || retryIterations === 0) {
@@ -540,6 +540,10 @@ async function handleRetry(): Promise {
timestamp: now,
...(retryThinkContent && { think: retryThinkContent }),
...(retryIterationToolRecords.length > 0 && { toolCalls: [...retryIterationToolRecords] }),
+ // P0 修复:中间迭代消息携带本轮独立 token 统计
+ ...(stats?.eval_count && { eval_count: stats.eval_count }),
+ ...(stats?.prompt_eval_count && { prompt_eval_count: stats.prompt_eval_count }),
+ ...(stats?.total_duration && { total_duration: stats.total_duration }),
};
retryIterationToolRecords = [];
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
@@ -634,11 +638,10 @@ async function handleRetry(): Promise {
updatedAt: Date.now()
}));
}
- await saveCurrentSession();
- if (loopStats?.ctx_tokens) updateCtxRemain(loopStats.ctx_tokens);
- }
- });
- } catch (err) {
+ await saveCurrentSession();
+ }
+ });
+ } catch (err) {
logError('重试失败', (err as Error).message);
appendSystemMessage(`❌ 重试失败: ${(err as Error).message}`);
} finally {
@@ -798,34 +801,6 @@ function stopGeneration(): void {
if (abortController) abortController.abort();
}
-/** 更新剩余上下文显示(使用 estimateTokens 估算实际占用,非 Ollama eval_count) */
-function updateCtxRemain(ctxTokens?: number): void {
- const el = document.querySelector('#ctxRemain') as HTMLElement;
- if (!el) return;
- const total = state.get(KEYS.NUM_CTX, 0);
- if (!total || !ctxTokens || ctxTokens <= 0) {
- el.style.display = 'none';
- return;
- }
- const remain = total - ctxTokens;
- if (remain <= 0) {
- el.style.display = 'inline-flex';
- el.textContent = '⚠ 上下文已满';
- el.style.color = 'var(--danger, #e74c3c)';
- } else {
- el.style.display = 'inline-flex';
- el.style.color = '';
- const remainFmt = remain >= 1024 ? `${(remain / 1024).toFixed(0)}K` : String(remain);
- const usedFmt = ctxTokens >= 1024 ? `${(ctxTokens / 1024).toFixed(0)}K` : String(ctxTokens);
- el.textContent = `${usedFmt} / ${total >= 1024 ? (total/1024).toFixed(0)+'K' : total}`;
- }
-}
-
-export function clearCtxRemain(): void {
- const el = document.querySelector('#ctxRemain') as HTMLElement;
- if (el) el.style.display = 'none';
-}
-
/** Token 预算比例:文件内容最多占用上下文窗口的比例 */
const FILE_TOKEN_BUDGET_RATIO = 0.3;
@@ -1264,7 +1239,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
result: null, status: 'pending', timestamp: Date.now()
});
},
- onNewIteration: (toolCalls) => {
+ onNewIteration: (toolCalls, stats) => {
// 保存上一轮的卡片(含工具记录)
const prevMsg: ChatMessage = {
role: 'assistant',
@@ -1273,6 +1248,10 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
timestamp: Date.now(),
...(thinkContent && { think: thinkContent }),
...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }),
+ // P0 修复:中间迭代消息携带本轮独立 token 统计
+ ...(stats?.eval_count && { eval_count: stats.eval_count }),
+ ...(stats?.prompt_eval_count && { prompt_eval_count: stats.prompt_eval_count }),
+ ...(stats?.total_duration && { total_duration: stats.total_duration }),
};
currentIterationToolRecords = [];
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
@@ -1372,8 +1351,6 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
}));
}
await saveCurrentSession();
- // 更新剩余上下文 — P0 修复:应使用 ctx_tokens(总上下文占用估算)而非 prompt_eval_count(仅最后一轮输入 token)
- if (loopStats?.ctx_tokens) updateCtxRemain(loopStats.ctx_tokens);
}
});
} catch (err) {
diff --git a/src/renderer/components/model-bar.ts b/src/renderer/components/model-bar.ts
index 15071e1..041f277 100644
--- a/src/renderer/components/model-bar.ts
+++ b/src/renderer/components/model-bar.ts
@@ -8,7 +8,6 @@ import { formatSize } from '../utils/utils.js';
import { OllamaAPI } from '../api/ollama.js';
import { ChatDB } from '../db/chat-db.js';
import { logModel, logDebug, logError, logWarn } from '../services/log-service.js';
-import { clearCtxRemain } from './input-area.js';
import type { OllamaModelDetail, ModelCaps } from '../types.js';
let modelSelectEl: HTMLSelectElement;
@@ -170,9 +169,6 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise
}
async function checkModelCapability(modelName: string): Promise {
- // 切换模型时清除旧上下文剩余显示
- clearCtxRemain();
-
if (modelCapabilityCache.has(modelName)) {
const cached = modelCapabilityCache.get(modelName)!;
applyCapability(modelName, cached);
@@ -294,15 +290,25 @@ export function formatCtxLen(n: number): string {
return String(n);
}
-/** 更新模型栏上下文长度显示(显示用户配置的值,而非模型自身值) */
-function updateCtxTotal(contextLength: number): void {
- const el = document.querySelector('#ctxTotal') as HTMLElement;
- if (!el) return;
- el.style.display = 'inline-flex';
- el.textContent = formatCtxLen(contextLength);
+/** 获取当前选中模型自身支持的上下文长度 */
+export function getModelContextLength(): number {
+ const model = modelSelectEl?.value;
+ if (!model) return 0;
+ const caps = modelCapabilityCache.get(model);
+ return caps?.contextLength || 0;
}
-/** 外部调用:更新上下文长度显示 */
-export function updateCtxTotalExternal(contextLength: number): void {
- updateCtxTotal(contextLength);
+/** 获取当前选中模型的名称 */
+export function getCurrentModelName(): string {
+ return modelSelectEl?.value || '';
+}
+
+/** 获取有效的上下文长度:min(模型支持值, 用户设置值) */
+export function getEffectiveNumCtx(): number {
+ const userCtx = state.get(KEYS.NUM_CTX, 131072);
+ const modelCtx = getModelContextLength();
+ if (modelCtx > 0 && modelCtx < userCtx) {
+ return modelCtx;
+ }
+ return userCtx;
}
diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts
index 6762c33..2e778a3 100644
--- a/src/renderer/components/settings-modal.ts
+++ b/src/renderer/components/settings-modal.ts
@@ -7,7 +7,7 @@ import { debounce } from '../utils/utils.js';
import { encryptData, decryptData } from '../services/crypto.js';
import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js';
import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js';
-import { loadModels, updateCtxTotalExternal, formatCtxLen } from './model-bar.js';
+import { loadModels, formatCtxLen } from './model-bar.js';
import { showToast } from './toast.js';
import { showConfirm } from './prompt-modal.js';
import { OllamaAPI } from '../api/ollama.js';
@@ -71,8 +71,6 @@ export function initSettingsModal(): void {
if (!val) return;
state.set(KEYS.NUM_CTX, val);
if (db) await db.saveSetting('numCtx', val);
- // 更新模型栏显示
- updateCtxTotalExternal(val);
logSetting('上下文长度', formatCtxLen(val));
});
diff --git a/src/renderer/components/token-dashboard.ts b/src/renderer/components/token-dashboard.ts
index 174958e..d682fcb 100644
--- a/src/renderer/components/token-dashboard.ts
+++ b/src/renderer/components/token-dashboard.ts
@@ -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, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
/** 打开仪表盘 */
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 {
- 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 {
}
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 = `
`;
const chartEl = dashboardModalEl.querySelector('#tdChart')!;
- chartEl.innerHTML = '正在加载全局数据…
';
+ chartEl.innerHTML = `${msg}
`;
const tableEl = dashboardModalEl.querySelector('#tdTableBody')!;
- tableEl.innerHTML = '| 正在加载… |
';
+ tableEl.innerHTML = `| ${msg} |
`;
}
/** 渲染全局概览卡片 */
@@ -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 `
-
+
@@ -275,7 +297,7 @@ function renderBarChart(
${formatTokenCount(d.output)}
${formatTokenCount(d.input)}
-
${d.label}
+
${escapeHtml(d.label)}
`;
}).join('');
@@ -284,7 +306,7 @@ function renderBarChart(
输出 Token
输入 Token
- ${note ? `${note}` : ''}
+ ${note ? `${escapeHtml(note)}` : ''}
${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 `
- | ${s.title.length > 20 ? s.title.slice(0, 20) + '…' : s.title} |
- ${s.model} |
+ ${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)} |
@@ -357,12 +395,141 @@ function renderGlobalTable(stats: AllSessionsTokenStats): void {
`;
}).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 = `
+
+
+
+
+
+ 已用上下文
+ ${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');
@@ -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 {
🤖
-
${session?.model || '-'}
+
${escapeHtml(currentModel)}
当前模型
`;
@@ -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 = '
| 暂无数据 |
';
+ tableEl.innerHTML = isStreaming
+ ? '
| ⏳ AI 正在回复中,暂无统计数据… |
'
+ : '
| 暂无数据 |
';
} else {
const sorted = [...rounds].reverse();
tableEl.innerHTML = sorted.map(r => {
@@ -440,7 +625,7 @@ function renderSessionView(): void {
return `
| R${r.round} |
- 🤖 AI${r.model ? ` ${r.model}` : ''} |
+ 🤖 AI${r.model ? ` ${escapeHtml(r.model)}` : ''} |
${formatTokenCount(r.prompt_eval_count)} |
${formatTokenCount(r.eval_count)} |
${formatTokenCount(total)} |
@@ -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;
+ }
}
/** 动态更新表头列 */
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 3bea339..e367777 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -28,7 +28,7 @@
-
-
@@ -732,6 +730,10 @@
+
📈 Token 消耗趋势 — 全局
diff --git a/src/renderer/main.ts b/src/renderer/main.ts
index 314fa77..8f6607c 100644
--- a/src/renderer/main.ts
+++ b/src/renderer/main.ts
@@ -14,10 +14,9 @@ import { generateId } from './utils/utils.js';
import { initToast, showToast } from './components/toast.js';
import { initLightbox, closeLightbox } from './components/lightbox.js';
import { initHeader, checkConnection } from './components/header.js';
-import { initModelBar, loadModels, setSelectedModel, updateCtxTotalExternal } from './components/model-bar.js';
+import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js';
import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js';
import { initInputArea } from './components/input-area.js';
-import { clearCtxRemain } from './components/input-area.js';
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
import { initMemoryModal } from './components/memory-modal.js';
@@ -257,9 +256,6 @@ async function startNewSession(): Promise
{
state.set(KEYS.CURRENT_SESSION, session);
logInfo('新建会话');
- // 清除剩余上下文显示
- clearCtxRemain();
-
// ── 强制中断正在进行的生成 ──
const abortController = state.get(KEYS.ABORT_CONTROLLER);
if (abortController) {
@@ -388,8 +384,6 @@ async function init(): Promise {
// 反显设置面板的下拉框
const ctxSelect = document.querySelector('#selectContextLength') as HTMLSelectElement;
if (ctxSelect) ctxSelect.value = String(numCtx);
- // 更新模型栏上下文显示
- updateCtxTotalExternal(numCtx);
(document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature);
document.querySelector('#tempValue')!.textContent = parseFloat(String(temperature)).toFixed(1);
diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts
index 1d83d05..f710fb1 100644
--- a/src/renderer/services/agent-engine.ts
+++ b/src/renderer/services/agent-engine.ts
@@ -25,6 +25,7 @@ import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO
import { runCompletionGate } from './completion-gate.js';
import { executeHooks } from './hooks.js';
import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
+import { getEffectiveNumCtx } from '../components/model-bar.js';
import type {
OllamaMessage,
OllamaStreamChunk,
@@ -607,7 +608,7 @@ export interface AgentCallbacks {
onToolCallError: (name: string, error: string, call: ToolCall) => void;
onDone: (finalContent: string, toolRecords?: ToolCallRecord[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number; ctx_tokens?: number }) => void;
onConfirmTool: (call: ToolCall) => Promise;
- onNewIteration?: (toolCalls?: ToolCall[]) => void;
+ onNewIteration?: (toolCalls?: ToolCall[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number }) => void;
/** Plan Mode: 计划已生成,等待用户确认 */
onPlanReady?: (plan: string, steps: string[]) => Promise;
}
@@ -937,14 +938,16 @@ async function handleInit(
ctx.messages.push(userMsg);
// 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪
- const numCtx = state.get(KEYS.NUM_CTX, 131072);
+ // 钳制:取 min(模型支持值, 用户设置值),防止手动设置超过模型能力
+ const numCtx = getEffectiveNumCtx();
const contextResult = buildContext(ctx.messages, { maxTokens: numCtx, windowSize: 20 });
ctx.messages.length = 0;
ctx.messages.push(...contextResult);
- // 自动压缩检测
- if (shouldAutoCompress(ctx.messages, numCtx)) {
- logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))} > ${Math.floor(numCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${numCtx})`);
+ // 钳制:取 min(模型支持值, 用户设置值)
+ const effectiveNumCtx = getEffectiveNumCtx();
+ if (shouldAutoCompress(ctx.messages, effectiveNumCtx)) {
+ logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))} > ${Math.floor(effectiveNumCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${effectiveNumCtx})`);
const compressAC = state.get(KEYS.ABORT_CONTROLLER) || new AbortController();
try {
const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC });
@@ -1337,7 +1340,7 @@ async function handleThinking(
stream: true,
think: state.get('thinkEnabled', false),
options: {
- num_ctx: state.get(KEYS.NUM_CTX, 131072),
+ num_ctx: getEffectiveNumCtx(),
temperature: state.get('temperature', 0.7)
},
...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() })
@@ -1394,6 +1397,13 @@ async function handleThinking(
abortController
);
+ // P0 修复:保存本轮独立 stats(供 onNewIteration / makeStats 使用,避免累计值导致重复计算)
+ ctx.lastLoopStats = {
+ eval_count: ctx.loopEvalCount || undefined,
+ prompt_eval_count: ctx.loopPromptEvalCount || undefined,
+ total_duration: ctx.loopInferenceNs || undefined,
+ };
+
// 累加 token 统计
ctx.totalEvalCount += ctx.loopEvalCount;
ctx.totalPromptEvalCount += ctx.loopPromptEvalCount;
@@ -2026,7 +2036,7 @@ async function handleCompressing(
api: OllamaAPI,
model: string,
): Promise {
- const numCtx = state.get(KEYS.NUM_CTX, 131072);
+ const numCtx = getEffectiveNumCtx();
if (shouldAutoCompress(ctx.messages, numCtx)) {
logInfo('COMPRESSING: 上下文压缩触发');
const compressAC = state.get(KEYS.ABORT_CONTROLLER) || new AbortController();
@@ -2050,11 +2060,25 @@ async function handleCompressing(
// ═══════════════════════════════════════════════════════════════
function makeStats(ctx: LoopContext) {
+ // P0 修复:返回本轮独立值而非累计值,避免与中间消息重复计算
+ // ctx_tokens 优先使用 Ollama 返回的实际 prompt_eval_count,回退到增强估算
+ let ctxTokens: number;
+ if (ctx.loopPromptEvalCount > 0) {
+ ctxTokens = ctx.loopPromptEvalCount + (ctx.loopEvalCount || 0);
+ } else {
+ // 增强估算:消息内容 + tool_calls/images 开销
+ ctxTokens = ctx.messages.reduce((sum, m) => {
+ let t = estimateTokens(m.content || '');
+ if (m.tool_calls?.length) t += m.tool_calls.length * 50;
+ if (m.images?.length) t += m.images.length * 100;
+ return sum + t;
+ }, 0);
+ }
return {
- eval_count: ctx.totalEvalCount || undefined,
- prompt_eval_count: ctx.totalPromptEvalCount || undefined,
- total_duration: ctx.totalInferenceNs || undefined,
- ctx_tokens: estimateTokens(ctx.messages.map(m => m.content || '').join('')), // 实际上下文占用
+ eval_count: ctx.lastLoopStats?.eval_count || undefined,
+ prompt_eval_count: ctx.lastLoopStats?.prompt_eval_count || undefined,
+ total_duration: ctx.lastLoopStats?.total_duration || undefined,
+ ctx_tokens: ctxTokens,
};
}
@@ -2097,6 +2121,7 @@ export async function runAgentLoop(
mode,
planRetries: 0,
startTime: Date.now(),
+ lastLoopStats: undefined,
};
state.set('_loopState', S.INIT);
@@ -2148,7 +2173,7 @@ export async function runAgentLoop(
}
// Token 感知的动态迭代预算
- const numCtx = state.get(KEYS.NUM_CTX, 131072);
+ const numCtx = getEffectiveNumCtx();
const usageRatio = estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx;
// P2-2: 上下文健康度软收敛 — 60% 时提前预警,让 AI 主动收敛
@@ -2170,7 +2195,7 @@ export async function runAgentLoop(
// 仅在 THINKING 阶段通知 UI(一次迭代只通知一次,不是每个状态切换都通知)
if (ctx.loopCount > 0 && ctx.state === S.THINKING && callbacks.onNewIteration) {
- callbacks.onNewIteration(ctx.prevToolCalls.length > 0 ? ctx.prevToolCalls : undefined);
+ callbacks.onNewIteration(ctx.prevToolCalls.length > 0 ? ctx.prevToolCalls : undefined, ctx.lastLoopStats);
}
// 状态分发
diff --git a/src/renderer/services/context-manager.ts b/src/renderer/services/context-manager.ts
index ecf26df..44e4776 100644
--- a/src/renderer/services/context-manager.ts
+++ b/src/renderer/services/context-manager.ts
@@ -28,12 +28,13 @@ export function recordActualTokens(actualInputTokens: number, actualOutputTokens
// C8: 模型切换时重置校准
if (modelName && modelName !== _calibrationModel) {
_calibrationModel = modelName;
- _calibrationRatio = 1.0;
+ _tokenCalibrationRatio = 1.0;
_calibrationSamples = 0;
}
if (actualInputTokens <= 0 || estimatedCount <= 0) return;
- const actualTotal = actualInputTokens + actualOutputTokens;
- const sampleRatio = actualTotal / Math.max(1, estimatedCount);
+ // 仅用 prompt_eval_count(实际输入 token)与估算值对比,
+ // 因为 estimatedCount 只估算消息内容(不含输出 token),加入 eval_count 会导致比值虚高
+ const sampleRatio = actualInputTokens / Math.max(1, estimatedCount);
// 指数移动平均,平滑异常值
const alpha = 0.3;
_tokenCalibrationRatio = _tokenCalibrationRatio * (1 - alpha) + sampleRatio * alpha;
diff --git a/src/renderer/services/memory-service.ts b/src/renderer/services/memory-service.ts
index 085acb1..022386c 100644
--- a/src/renderer/services/memory-service.ts
+++ b/src/renderer/services/memory-service.ts
@@ -794,7 +794,7 @@ export async function extractAndSaveMemories(
for (let i = allMsgs.length - 1; i >= 0 && rounds.length < 15; i--) {
const msg = allMsgs[i];
if (msg.role === 'assistant') {
- const round = { assistant: msg.content };
+ const round: { user?: string; assistant?: string } = { assistant: msg.content };
// 找到前面最近的 user 消息
for (let j = i - 1; j >= 0; j--) {
if (allMsgs[j].role === 'user') {
diff --git a/src/renderer/services/sub-agent.ts b/src/renderer/services/sub-agent.ts
index 76d9d87..99a16b3 100644
--- a/src/renderer/services/sub-agent.ts
+++ b/src/renderer/services/sub-agent.ts
@@ -59,21 +59,21 @@ export async function executeSubAgent(
const toolNames = [...SUB_AGENT_TOOL_WHITELIST].join(', ');
logInfo(`子 Agent 启动`, `任务: ${task.slice(0, 80)} | 工具: ${toolNames} | 模型: ${model}`);
- // 如果子代理使用了不同于主 AI 的模型,获取该模型的实际上下文长度
- const defaultModel = state.get('_defaultModel', '');
- let numCtx = state.get(KEYS.NUM_CTX, 131072);
- if (model !== defaultModel) {
- try {
- const detail = await api.showModel(model);
- const modelInfo = detail.model_info || {};
- for (const key of Object.keys(modelInfo)) {
- if (key.endsWith('.context_length')) {
- numCtx = Number(modelInfo[key]) || numCtx;
- break;
- }
+ // 钳制:取 min(模型支持值, 用户设置值),防止手动设置超过模型能力
+ const userCtx = state.get(KEYS.NUM_CTX, 131072);
+ let modelCtx = userCtx;
+ // 无论是否是默认模型,都获取模型实际上下文长度做钳制
+ try {
+ const detail = await api.showModel(model);
+ const modelInfo = detail.model_info || {};
+ for (const key of Object.keys(modelInfo)) {
+ if (key.endsWith('.context_length')) {
+ modelCtx = Number(modelInfo[key]) || userCtx;
+ break;
}
- } catch { /* 获取失败用默认值 */ }
- }
+ }
+ } catch { /* 获取失败用默认值 */ }
+ const numCtx = Math.min(modelCtx, userCtx);
const systemPrompt = `你是一个子任务执行 Agent,拥有只读工具权限。请高效完成指定任务,给出结果报告。
diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css
index 8488d97..6baa664 100644
--- a/src/renderer/styles/style.css
+++ b/src/renderer/styles/style.css
@@ -503,36 +503,6 @@ html, body {
border-color: rgba(212, 160, 60, 0.15);
}
-/* ── 上下文长度胶囊(模型栏) ── */
-.ctx-total {
- display: inline-flex;
- align-items: center;
- font-size: 11px;
- font-weight: 600;
- white-space: nowrap;
- padding: 3px 10px;
- border-radius: 20px;
- color: #47848F;
- background: rgba(71, 132, 143, 0.08);
- border: 1px solid rgba(71, 132, 143, 0.15);
- margin-left: 6px;
-}
-
-/* ── 剩余上下文胶囊 ── */
-.ctx-remain {
- display: inline-flex;
- align-items: center;
- font-size: 11px;
- font-weight: 600;
- white-space: nowrap;
- padding: 3px 10px;
- border-radius: 20px;
- color: #4CAF50;
- background: rgba(76, 175, 80, 0.08);
- border: 1px solid rgba(76, 175, 80, 0.15);
- margin-left: 2px;
-}
-
/* ── 温度滑块 ── */
.temp-slider {
width: 100%;
@@ -3738,7 +3708,7 @@ html, body {
/* 概览卡片组 */
.td-overview {
display: grid;
- grid-template-columns: repeat(4, 1fr);
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
@@ -4104,9 +4074,108 @@ html, body {
padding: 24px !important;
}
-/* 响应式 */
-@media (max-width: 900px) {
+/* 响应式:超窄屏幕强制 2 列 */
+@media (max-width: 600px) {
.td-overview {
grid-template-columns: repeat(2, 1fr);
}
}
+
+/* ═══════════════════════════════════════════════════════
+ 上下文使用率环形图
+ ═══════════════════════════════════════════════════════ */
+.td-ctx-ring-section {
+ margin-bottom: 20px;
+}
+
+.td-ctx-ring-wrap {
+ background: var(--bg-card);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: 20px;
+ box-shadow: var(--shadow-card);
+}
+
+.td-ctx-ring-container {
+ display: flex;
+ align-items: center;
+ gap: 28px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.td-ctx-ring-svg {
+ flex-shrink: 0;
+}
+
+.td-ctx-ring-details {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 200px;
+}
+
+.td-ctx-ring-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+}
+
+.td-ctx-ring-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+.td-ctx-dot-used {
+ background: #9B7ED8;
+}
+
+.td-ctx-dot-remain {
+ background: var(--border-subtle);
+}
+
+.td-ctx-ring-label-text {
+ color: var(--text-secondary);
+ flex: 1;
+}
+
+.td-ctx-ring-val {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.td-ctx-ring-total {
+ border-top: 1px solid var(--border-subtle);
+ padding-top: 8px;
+ margin-top: 2px;
+}
+
+.td-ctx-ring-total .td-ctx-ring-val {
+ color: var(--accent);
+ font-weight: 700;
+}
+
+.td-ctx-ring-note {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ font-style: italic;
+ margin-top: 2px;
+}
+
+.td-ctx-ring-source {
+ font-size: 10px;
+ color: var(--text-tertiary);
+ margin-top: 2px;
+ opacity: 0.8;
+}
+
+.td-ctx-ring-streaming {
+ font-size: 11px;
+ color: #F0A040;
+ margin-top: 4px;
+ font-weight: 500;
+}
diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts
index 01a67f2..e8d3078 100644
--- a/src/renderer/types.d.ts
+++ b/src/renderer/types.d.ts
@@ -403,6 +403,12 @@ export interface LoopContext {
planRetries: number;
/** 循环开始时间 */
startTime: number;
+ /** 上一轮独立 stats(供 onNewIteration / makeStats 使用,避免累计值导致重复计算) */
+ lastLoopStats?: {
+ eval_count?: number;
+ prompt_eval_count?: number;
+ total_duration?: number;
+ };
}
/** Agent 运行模式 */